fix(settings): protect saved keys and preserve clear failure state

This commit is contained in:
Magolor committed 2026-11-06 16:58:59 +08:00
1 parent 8b50763c0d
commit 86247d664b
35 files changed
+287 -184

No files matched your search

@@ -17,7 +17,7 @@ import { openSettings, connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCA
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
it.skipIf(webSnapshotMode() === 'record').each(['deepseek-official', 'google', 'openai', 'clear-fixture'])(
it.skipIf(webSnapshotMode() === 'record').each(['deepseek-official', 'google', 'openai', 'acme-gateway'])(
'clears the saved %s key and sends the next request with environment authentication', async (provider) => {
const messages = provider === 'deepseek-official'
const google = provider === 'google'
@@ -120,6 +120,52 @@ it.skipIf(webSnapshotMode() === 'record').each(['deepseek-official', 'google', '
},
)
it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])(
'protects saved keys and reveals only unsaved replacements in $name', async ({ engine }) => {
const scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
let browser: Browser | undefined
try {
await scaffold.ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'fixture-protected-old-key')
browser = await engine.launch()
const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
await page.goto(scaffold.authenticatedUrl)
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '模型', exact: true }).click()
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.inputValue()).toBe('')
expect(await dialog.getByRole('button', { name: '显示密钥' }).count()).toBe(0)
expect(await page.locator('body').innerHTML()).not.toContain('fixture-protected-old-key')
await key.press('Backspace')
await dialog.getByText('保存后清除已保存密钥,并使用环境认证', { exact: true }).waitFor()
expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)')
await dialog.getByRole('button', { name: '撤销清除', exact: true }).click()
expect(await key.getAttribute('placeholder')).toBe('••••••••')
await key.fill('fixture-new-key')
await dialog.getByRole('button', { name: '显示密钥', exact: true }).click()
expect(await key.getAttribute('type')).toBe('text')
expect(await key.inputValue()).toBe('fixture-new-key')
await key.press('ControlOrMeta+A')
await key.press('ControlOrMeta+X')
await expect.poll(() => key.inputValue()).toBe('')
await key.fill('fixture-new-key')
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.inputValue()).toBe('')
expect(await key.getAttribute('type')).toBe('password')
expect(await dialog.getByRole('button', { name: '显示密钥' }).count()).toBe(0)
expect(await page.locator('body').innerHTML()).not.toContain('fixture-new-key')
} finally {
try { await browser?.close() } finally { await scaffold.close() }
}
},
)
it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])(
'keeps a foreign model drag from editing another provider input in $name', async ({ engine }) => {
const scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
@@ -200,8 +246,8 @@ 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('留空保留现有认证(已保存 API key)')
await messages.getByText('已保存 API key:sk-m…ding', { exact: true }).waitFor()
expect(await key.getAttribute('placeholder')).toBe('••••••••')
await messages.getByText('已保存 API key', { exact: true }).waitFor()
await messages.getByText('自定义设置', { exact: true }).click()
expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder'))
.toBe('https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)')
@@ -228,12 +274,12 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett
expect(await page.locator('body').innerText()).not.toContain('sk-e2e-')
await dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true }).click()
expect(await key.inputValue()).toBe('')
await messages.getByText('已保存 API key:sk-e…ages', { exact: true }).waitFor()
await messages.getByText('已保存 API key', { exact: true }).waitFor()
expect(await key.getAttribute('type')).toBe('password')
await key.fill('sk-cancelled-replacement')
expect(await messages.getByText('已保存 API key:sk-e…ages', { exact: true }).count()).toBe(0)
expect(await messages.getByText('已保存 API key', { exact: true }).count()).toBe(0)
await key.fill('')
await messages.getByText('已保存 API key:sk-e…ages', { exact: true }).waitFor()
await messages.getByText('已保存 API key', { exact: true }).waitFor()
await messages.getByRole('button', { name: '保存', exact: true }).click()
await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor()
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')).toBe(credentials)
@@ -18,8 +18,8 @@
- button "认证说明": "?"
- button "清除已保存密钥"
- textbox "API 密钥":
- /placeholder: 留空保留现有认证(已保存 API key)
- paragraph: 已保存 API key:sk-m…ding
- /placeholder: ••••••••
- paragraph: 已保存 API key
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
@@ -18,8 +18,8 @@
- button "认证说明": "?"
- button "清除已保存密钥"
- textbox "API 密钥":
- /placeholder: 留空保留现有认证(已保存 API key)
- paragraph: 已保存 API key:dsh_…test
- /placeholder: ••••••••
- paragraph: 已保存 API key
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
@@ -18,8 +18,8 @@
- button "认证说明": "?"
- button "清除已保存密钥"
- textbox "API 密钥":
- /placeholder: 留空保留现有认证(已保存 API key)
- paragraph: 已保存 API key:dsh_…test
- /placeholder: ••••••••
- paragraph: 已保存 API key
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
+1 -1
View File
@@ -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('留空保留现有认证(已保存 API key)', { exact: true }).waitFor({ timeout: 10_000 })
await dialog.getByPlaceholder('••••••••', { 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()
@@ -116,7 +116,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await expect.poll(
() => configuredInput.getAttribute('placeholder'),
{ timeout: 10_000 },
).toBe('留空保留现有认证(已保存 API key)')
).toBe('••••••••')
const secondReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
@@ -258,7 +258,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('留空保留现有认证(已保存 API key)')
).toBe('••••••••')
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
await settings.getByRole('button', { name: '保存', exact: true }).click()
+2 -2
View File
@@ -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: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` |
| `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` |
| `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) |
+2 -2
View File
@@ -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: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` |
| `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` |
| `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) |
+2 -2
View File
@@ -12,8 +12,8 @@
en: baaab4bca76bea6b
zh: 54ccdc03ffd44f63
/user-credentials/description:
en: 5150dd250ea3a4b2
zh: 944d0fffca37dec5
en: f7b09588c0f34952
zh: 1b93d673c5bf6a80
/user-credentials/change-commits:
en: 464315a953330eb2
zh: d9815591f3abc2cb
+3 -5
View File
@@ -32,21 +32,19 @@ interface ResolvedCredential {
## Description
`describe(ref)` reports whether a reference resolves, its source, writability, and an optional masked preview of a managed credential. It never returns the full value or an ambient credential preview. 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 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
/**
* Source and writability facts for one reference, safe for configuration UIs —
* never the full value. Providers may include a masked preview of a managed
* credential; ambient credentials do not expose a preview.
* never the value. The view has no slot a value could ride in, which is what
* lets the whole read half cross the Remote wire.
*/
interface CredentialInfo {
/** Whether resolving the reference would currently return a value. */
configured: boolean
/** Source layer currently supplying the value; absent while unconfigured. */
source?: string
/** Masked managed value for display only; short values must be fully masked. */
preview?: string
/** Whether the active provider can write this reference. */
writable: boolean
}
+3 -5
View File
@@ -32,21 +32,19 @@ interface ResolvedCredential {
## 描述
`describe(ref)` 报告引用是否可解析、来源、可写性,以及可选的托管凭据脱敏摘要。它不返回完整值,也不提供环境凭据摘要。本地提供方将其管理的文件排在启动环境之前,因此保存值总会成为生效且可写的那一层;移除后会重新使用启动环境或 `.env` 后备值。
`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地提供方将其管理的文件排在启动环境之前,因此保存值总会成为生效且可写的那一层;移除后会重新使用启动环境或 `.env` 后备值。
```ts type-equiv
/**
* Source and writability facts for one reference, safe for configuration UIs —
* never the full value. Providers may include a masked preview of a managed
* credential; ambient credentials do not expose a preview.
* never the value. The view has no slot a value could ride in, which is what
* lets the whole read half cross the Remote wire.
*/
interface CredentialInfo {
/** Whether resolving the reference would currently return a value. */
configured: boolean
/** Source layer currently supplying the value; absent while unconfigured. */
source?: string
/** Masked managed value for display only; short values must be fully masked. */
preview?: string
/** Whether the active provider can write this reference. */
writable: boolean
}
+2 -2
View File
@@ -9,8 +9,8 @@
en: 44852da64a6a39ec
zh: 4c280e428f0fec08
/configure-models/add-a-third-party-provider:
en: 2bfca1d989fce463
zh: f86948426dad32b0
en: c0993fb8f0131a5e
zh: 8932850d36a0f3d7
/configure-models/add-a-custom-model-api:
en: 1f55d7d94cbfbd80
zh: 94e5a8bc7625d760
+1 -1
View File
@@ -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 uses the placeholder **Leave blank to keep current authentication (API key saved)** and a green masked-key preview below the empty input. Select **Clear saved key**, then **Save**, to remove the stored override and use environment authentication. **Undo clear** or **Cancel** preserves the key. Environment fallback uses **Leave blank to use env as auth** and one detection hint while the input is empty. A nonempty input shows no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials.
A saved credential displays a fixed mask in the input and a green saved-key hint. The mask does not expose the key or its length. Saved keys cannot be revealed, copied, cut, or partially edited; typing or pasting replaces the whole key. Backspace or Delete stages whole-key removal. Select **Clear saved key**, then **Save**, to remove the stored override and use environment authentication. **Keep saved key** or **Cancel** preserves the 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 nonempty input shows 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.
+1 -1
View File
@@ -20,7 +20,7 @@
认证 **?** 说明已保存凭据的优先级和环境后备。密钥留空时,使用已保存凭据,或尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。
已保存凭据使用**留空保留现有认证(已保存 API key)**占位文字,在空输入框下方显示绿色的密钥脱敏摘要。选择**清除已保存密钥**,再点**保存**,即可移除存储覆盖并使用环境认证。**撤销清除**或**取消**会保留密钥。环境后备使用**留空使用环境认证**占位文字,输入留空时显示一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。
已保存凭据在输入框中显示固定掩码,下方显示绿色的已保存密钥提示。掩码不暴露密钥或长度。已保存密钥不能显示、复制、剪切或局部编辑;键入或粘贴会整项替换。Backspace 或 Delete 会暂存整项删除。选择**清除已保存密钥**,再点**保存**,即可移除存储覆盖并使用环境认证。保存前,**撤销清除**或**取消**会保留密钥。删除被拒绝时,设置保持不变。密钥已删除但设置失败时,警告会说明此删除无法撤销;可重试**保存**完成配置更新,或**关闭**并保留部分提交结果。清除共享凭据会影响使用同一引用的全部功能。没有环境凭据时,清除后提供商将没有认证。环境后备使用**留空使用环境认证**占位文字,输入留空时显示一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。
密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查当前输入,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。原生密码框不提供剪切;可选中草稿后按 Backspace 清空,或显示明文后再剪切。已保存凭据仍保持只写。
@@ -46,7 +46,6 @@ function projectCredentialInfo(info: CredentialInfo): CredentialInfo {
return {
configured: info.configured,
...info.source === undefined ? {} : { source: info.source },
...info.preview === undefined ? {} : { preview: info.preview },
writable: info.writable,
}
}
@@ -14,16 +14,6 @@ class LeakyCredentials extends MemoryCredentials {
}
}
/** A managed provider returning a display preview alongside an undeclared secret. */
class PreviewCredentials extends MemoryCredentials {
override describe(): Promise<CredentialInfo> {
return Promise.resolve({
configured: true, source: 'file', writable: true,
preview: 'sk-m…abcd', value: 'sk-managed-secret-abcd',
} as CredentialInfo)
}
}
/** A store whose write rejects with a bare string, the way some client libraries do. */
class LiteralRejectingCredentials extends MemoryCredentials {
override async set(): Promise<void> {
@@ -115,15 +105,6 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
expect(JSON.stringify(described)).not.toContain('sk-leaked')
})
it('returns the masked preview without the original stored credential', async () => {
const controller = await boot({}, PreviewCredentials)
const described = await controller.describe(['DEEPSEEK_API_KEY'])
expect(described.DEEPSEEK_API_KEY).toEqual({
configured: true, source: 'file', writable: true, preview: 'sk-m…abcd',
})
expect(JSON.stringify(described)).not.toContain('sk-managed-secret-abcd')
})
it('stores and removes through the same references the batch describes', async () => {
const controller = await boot()
await controller.set('DEEPSEEK_API_KEY', 'sk-live')
@@ -18,11 +18,11 @@
en: db5aaf7f702e1f0c
zh: 409a78c14fb2b6e3
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/api-keys:
en: c4865eabb48e3839
zh: 66f5c6698845112f
en: 980f4a0dcc47c6e0
zh: 603ea270e1b6f79d
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
en: 19fbfafb53ea1593
zh: 880f6703111383fd
en: b4f40dfd8857e64e
zh: 21486ade0dc4c259
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
en: 59472e717c2f87be
zh: dc2a51a5a40138bc
+3 -3
View File
@@ -41,15 +41,15 @@ Host configuration `credentialOnboarding` defaults to `true`. The Electron prelo
### API keys
API-key inputs start empty and 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. 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 a fixed mask as a placeholder, never a credential value or its length. They have no 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 `<ROUTE>_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.
The editor uses redacted `CredentialInfo` to choose the saved-credential placeholder or environment detection, without displaying a credential-source label. Refreshing the snapshot updates an open editor without a separate probe. The hint receives only a Host-masked preview of a saved key and never changes field writability.
The editor uses redacted `CredentialInfo` to choose the saved-credential placeholder or environment detection, without displaying a credential-source label. Refreshing the snapshot updates an open editor without a separate probe. The hint receives no credential value and never changes field writability.
### Editing a provider
A blank key field preserves existing authentication. A saved credential shows a green notice with its masked preview below the input; providers without a preview show only that a key is saved. Otherwise the field offers environment authentication. **Clear saved key** stages removal until Save; Undo or Cancel preserves the key. Entering a new key replaces the removal draft. A catalog route drops its user-layer derived reference to restore native authentication; explicit aliases, inherited references, and custom routes retain their reference for environment fallback. 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 nonempty input shows 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.
A blank key field preserves existing authentication. A saved credential shows a fixed input mask and a green saved-key notice. Otherwise the field offers environment authentication. **Clear saved key** stages removal until Save; Undo or Cancel preserves the key. 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, without Undo; Close leaves that partial result in place. A catalog route drops its user-layer derived reference to restore native authentication; aliases that differ from `<ROUTE>_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 nonempty input shows 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.
@@ -41,15 +41,15 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会
### API 密钥
API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。输入非空时,显示与会话密钥遮罩一致的眼睛按钮:**显示密钥**展示当前输入,**隐藏密钥**重新遮住输入。修改或清空输入后会重新遮住密钥。已保存凭据从不读入输入框。原生密码框不提供剪切;可选中后按 Backspace 清空草稿,或显示明文后再剪切。
API 密钥输入框通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。输入非空时,显示与会话密钥遮罩一致的眼睛按钮:**显示密钥**展示当前输入,**隐藏密钥**重新遮住输入。修改或清空输入后会重新遮住密钥。已保存密钥以固定掩码占位显示,不表示凭据值或长度;不提供显示按钮,并阻止复制和剪切。键入或粘贴会整项替换,Backspace 或 Delete 会暂存整项删除。已保存凭据从不读入输入框。原生密码框不提供剪切;可选中后按 Backspace 清空草稿,或显示明文后再剪切。
编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_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 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。
编辑器使用脱敏 `CredentialInfo` 选择已保存凭据的占位文字或环境检测,不显示凭据来源标签。快照刷新会更新打开的编辑器,无需单独探测。提示只接收 Host 生成的已保存密钥脱敏摘要,不改变字段的可写性。
编辑器使用脱敏 `CredentialInfo` 选择已保存凭据的占位文字或环境检测,不显示凭据来源标签。快照刷新会更新打开的编辑器,无需单独探测。提示从不接收凭据值,也不改变字段的可写性。
### 编辑提供商
密钥字段留空时保留现有认证。已保存凭据在输入框下方显示绿色提示及脱敏摘要;提供方未提供摘要时,只提示已保存密钥。否则提示使用环境认证。**清除已保存密钥**将删除操作暂存到「保存」时执行;撤销或取消会保留密钥。输入新密钥会替换待删除草稿。目录路由会移除用户层中的派生引用以恢复原生认证;显式别名、继承引用和自定义路由保留原引用以使用环境后备。待删除期间暂不允许获取模型列表。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。输入非空时,不显示认证状态文字。页面读取原生变量名和具名引用,每批最多 64 项,不将变量值读入 Client。
密钥字段留空时保留现有认证。已保存凭据显示固定的输入掩码和绿色的已保存密钥提示。否则提示使用环境认证。**清除已保存密钥**将删除操作暂存到「保存」时执行;撤销或取消会保留密钥。输入新密钥会替换待删除草稿。删除被拒绝时,设置保持不变。删除成功但设置失败时,编辑器说明密钥已删除,并提供「保存」重试设置;不再提供撤销,「关闭」会保留这一部分提交结果。目录路由会移除用户层中的派生引用以恢复原生认证;与 `<ROUTE>_API_KEY` 不同的别名、继承引用和自定义路由保留原引用以使用环境后备。手写的同名常规引用与页面派生引用按相同方式处理。隐藏的 OAuth 引用不提供已保存 API 密钥掩码或清除控件。待删除期间暂不允许获取模型列表。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。输入非空时,不显示认证状态文字。页面读取原生变量名和具名引用,每批最多 64 项,不将变量值读入 Client。
目录未提供固定默认端点时,输入框下方显示提供方的 `baseURLEnv` 配置变量名。Profile 中的端点仍是占位符的首选。
@@ -1,4 +1,4 @@
/** Write-only credential input with the same reveal behavior as masked conversation text. */
/** Credential drafts can be revealed; saved keys are represented by a protected mask. */
import { useState } from 'react'
import type { InputHTMLAttributes, ReactNode } from 'react'
import type { en } from './locales.ts'
@@ -12,26 +12,39 @@ const EYE_OFF_PATH = `${EYE_CONTOUR}M2.5 2.5L13.5 13.5`
interface ApiKeyInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, '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. */
readonly saved?: {
readonly clear?: () => void
}
/** Section copy, including the same reveal labels as SecretText. */
readonly t: (key: keyof typeof en) => string
}
/**
* Reveal only a nonempty draft; a different value starts masked, as in SecretText.
* Reveal only a nonempty draft. Saved keys allow whole replacement or deletion.
* @param props - current draft, input attributes, and translated control labels.
* @returns a password input with an optional reveal button.
*/
export function ApiKeyInput({ value, t, ...props }: ApiKeyInputProps): ReactNode {
export function ApiKeyInput({ value, saved, t, ...props }: ApiKeyInputProps): ReactNode {
const [revealedValue, setRevealedValue] = useState<string | undefined>()
const revealed = value.length > 0 && revealedValue === value
const revealed = saved === undefined && value.length > 0 && revealedValue === value
return (
<div className={styles['keyInput']}>
<div className={`${styles['keyInput']} ${saved === undefined ? '' : styles['savedKeyInput']}`}>
<input
{...props}
className={`${styles['input']} ${value.length > 0 ? styles['keyInputFilled'] : ''}`}
className={`${styles['input']} ${saved === undefined && value.length > 0 ? styles['keyInputFilled'] : ''}`}
type={revealed ? 'text' : 'password'}
autoComplete="new-password"
value={value}
placeholder={saved === undefined ? props.placeholder : '••••••••'}
onCopy={(event) => { if (saved !== undefined) event.preventDefault() }}
onCut={(event) => { if (saved !== undefined) event.preventDefault() }}
onKeyDown={(event) => {
if (saved !== undefined && (event.key === 'Backspace' || event.key === 'Delete')) {
event.preventDefault()
saved.clear?.()
}
}}
onChange={(event) => {
setRevealedValue(undefined)
props.onChange?.(event)
@@ -844,6 +844,11 @@ select.input {
position: relative;
}
.savedKeyInput .input::placeholder {
color: var(--dsw-alias-label-primary);
opacity: 1;
}
.keyInputFilled {
padding-right: 40px;
}
@@ -200,9 +200,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
return { ...loaded, retryPolicy: { ...policy, mode } }
})
// An empty string preserves authentication; a reference stages its removal.
const [keyDraft, setKeyDraft] = useState<string | { readonly ref: string }>('')
const [keyDraft, setKeyDraft] = useState<string | { readonly ref: string; readonly removed?: true }>()
const keyText = typeof keyDraft === 'string' ? keyDraft : ''
const keyRemoval = typeof keyDraft === 'string' ? undefined : keyDraft
const keyRemoval = typeof keyDraft === 'object' ? keyDraft : undefined
const [busy, setBusy] = useState(false)
const [listBusy, setListBusy] = useState(false)
const { onBusyChange } = props
@@ -313,9 +313,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
*/
const applyOnce = async (): Promise<string | undefined> => {
const ns = namespace.ns
// A catalog provider returns to native authentication when this page's
// derived reference is removed. Explicit aliases and custom routes keep
// their reference so its environment fallback remains addressable.
// Removing a catalog route's conventional reference restores native auth.
// Other aliases and custom routes keep their named environment fallback.
const profileDraft = clearsDerivedRef ? schema.deletePath(draft, ['apiKeyEnv']) : draft
// A pi-ai profile names the conventional reference only when this page is
// about to store a key. Otherwise the provider keeps its native auth path.
@@ -348,6 +347,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
: materializesNativeProfile
? [{ op: 'set', path: [...settingsPath], value: {} }]
: pathOps(settingsPath, committedOriginal, next)
if (keyRemoval !== undefined && keyRemoval.removed !== true) {
const removed = await operations.removeCredential(keyRemoval.ref)
if (removed !== undefined) return removed
// A later settings failure cannot restore a secret that was deleted.
setKeyDraft({ ...keyRemoval, removed: true })
}
if (ops.length > 0) {
const written = await operations.writeSettings(ns, ops, expectedRevision)
if (written.kind !== 'written') return written.kind === 'conflict' ? t('conflict') : written.message
@@ -355,10 +360,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
setExpectedRevision(written.view.revision)
setDraft(next)
}
if (keyRemoval !== undefined) {
const removed = await operations.removeCredential(keyRemoval.ref)
if (removed !== undefined) return removed
} else if (keyValue.length > 0) {
if (keyValue.length > 0) {
const stored = await operations.storeCredential(keyRef, keyValue)
if (stored !== undefined) return stored
}
@@ -438,15 +440,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const detectedRef = detected?.[0]
const detectedVisibleRef = detectedRef === undefined || !visibleRefs.includes(detectedRef) ? undefined : detectedRef
const missingRefs = checkedRefs.length > 0 && checkedRefs.every(([, info]) => info?.configured === false)
const savedPreview = detected?.[1]?.preview
const savedCredential = detected?.[1]?.source === 'file' || savedPreview !== undefined
const savedCredential = detected?.[1]?.source === 'file' && detectedVisibleRef !== undefined
const removalRef = keyRemoval?.ref ?? detectedRef
const credentialHint = keyText.length > 0 || props.credentialRequired === true
? undefined
: keyRemoval !== undefined ? t('keyRemovePending')
: keyRemoval !== undefined ? t(keyRemoval.removed === true ? 'keyRemoveApplied' : 'keyRemovePending')
: keyHint === undefined ? undefined
: savedCredential
? savedPreview === undefined ? t('keySaved') : t('keySavedPreview').replace('{preview}', () => savedPreview)
? t('keySaved')
: t(detectedRef === undefined ? missingRefs ? 'keyFallbackMissing' : 'keyFallbackUnchecked'
: detectedVisibleRef === undefined ? 'keyFallbackDetectedHidden' : 'keyFallbackDetected')
.replace('{hint}', () => detectedVisibleRef ?? keyHint)
@@ -482,27 +483,36 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
<span className={styles['fieldLabel']}>
{t('keyInput')}
{authHelp}
{keyText.length === 0 && props.credentialRequired !== true && removalRef !== undefined
&& (keyRemoval !== undefined || (savedCredential && detected?.[1]?.writable === true))
{keyRemoval?.removed !== true && keyText.length === 0 && props.credentialRequired !== true && removalRef !== undefined
&& (keyRemoval !== undefined || (savedCredential && detected[1].writable))
? <button
type="button"
className={styles['linkButton']}
disabled={disabled}
onClick={() => { setKeyDraft(keyRemoval !== undefined ? '' : { ref: removalRef }) }}
onClick={() => {
setKeyDraft(keyRemoval !== undefined ? undefined : { ref: removalRef })
setFailure(undefined)
}}
>{t(keyRemoval !== undefined ? 'keyRemoveUndo' : 'keyRemove')}</button>
: null}
</span>
<ApiKeyInput
t={t}
value={keyText}
{...keyDraft === undefined && savedCredential ? {
saved: {
...detected[1].writable && removalRef !== undefined
? { clear: () => { setKeyDraft({ ref: removalRef }); setFailure(undefined) } } : {},
},
} : {}}
placeholder={keyPlaceholder}
aria-label={t('keyInput')}
aria-invalid={shownKeyFailure !== undefined && !protocolKeyMissing}
aria-describedby={credentialHint === undefined ? undefined : `${props.provider}-credential-hint`}
required={props.credentialRequired === true || protocolNeedsKeyHere}
autoFocus={props.autoFocusCredential === true}
disabled={disabled}
onChange={(event) => { setKeyDraft(event.target.value) }}
disabled={disabled || keyRemoval?.removed === true}
onChange={(event) => { setKeyDraft(event.target.value); setFailure(undefined) }}
/>
{credentialHint === undefined ? null : <p id={`${props.provider}-credential-hint`} className={styles[keyRemoval !== undefined || missingRefs ? 'notice' : detectedRef === undefined ? 'advancedHint' : 'savedNotice']}>{credentialHint}</p>}
{/* A key the protocol requires is a requirement still to meet, not a fault. */}
@@ -674,7 +684,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|| (props.credentialRequired === true && keyValue.length === 0)}
submitLabelKey={props.submitLabelKey ?? 'apply'}
submitBusyLabelKey={props.submitBusyLabelKey ?? 'applying'}
{...props.cancelLabelKey === undefined ? {} : { cancelLabelKey: props.cancelLabelKey }}
cancelLabelKey={keyRemoval?.removed === true ? 'close' : props.cancelLabelKey ?? 'cancel'}
onCancel={() => { props.onClose(false) }}
onSubmit={() => { props.onSubmitCredential?.(); void apply() }}
/>
@@ -43,10 +43,10 @@ export const en = {
keyPlaceholderFallback: 'Leave blank to use env as auth ({hint})',
keyPlaceholderSaved: 'Leave blank to keep current authentication (API key saved)',
keySaved: 'API key saved',
keySavedPreview: 'API key saved: {preview}',
keyRemove: 'Clear saved key',
keyRemoveUndo: 'Undo clear',
keyRemoveUndo: 'Keep saved key',
keyRemovePending: 'Save to clear the saved key and use environment authentication.',
keyRemoveApplied: 'Key cleared. Retry Save to finish settings; this deletion cannot be undone.',
keyFallbackMissing: 'Env var not detected. Enter an API key, or configure {hint} in your env.',
keyFallbackDetected: 'Env var detected. ({hint})',
keyFallbackDetectedHidden: 'Env var detected.',
@@ -224,10 +224,10 @@ export const zh: { [Key in keyof typeof en]: string } = {
keyPlaceholderFallback: '留空使用环境认证({hint})',
keyPlaceholderSaved: '留空保留现有认证(已保存 API key)',
keySaved: '已保存 API key',
keySavedPreview: '已保存 API key:{preview}',
keyRemove: '清除已保存密钥',
keyRemoveUndo: '撤销清除',
keyRemovePending: '保存后清除已保存密钥,并使用环境认证',
keyRemoveApplied: '密钥已清除,请重试保存以完成配置更新;此删除无法撤销',
keyFallbackMissing: '未检测到环境变量。请填写 API 密钥,或设置环境变量 {hint}。',
keyFallbackDetected: '已检测到环境变量({hint})',
keyFallbackDetectedHidden: '已检测到环境变量',
@@ -401,11 +401,11 @@ export const ja = {
'keyPlaceholder': 'APIキーを入力',
'keyPlaceholderFallback': '空欄のままにすると環境変数を認証に使用します ({hint})',
'keyPlaceholderSaved': '空欄のままにすると現在の認証を保持します(APIキー保存済み)',
keySaved: 'APIキー保存済み',
keySavedPreview: 'APIキー保存済み: {preview}',
keyRemove: '保存済みキーを削除',
keyRemoveUndo: '削除を取り消す',
keyRemovePending: '保存すると保存済みキーを削除し、環境変数の認証を使用します。',
'keySaved': 'APIキー保存済み',
'keyRemove': '保存済みキーを削除',
'keyRemoveUndo': '削除を取り消す',
'keyRemovePending': '保存すると保存済みキーを削除し、環境変数の認証を使用します。',
'keyRemoveApplied': 'キーは削除済みです。保存を再試行して設定を更新してください。削除は取り消せません。',
'keyFallbackMissing': '環境変数が検出されません。APIキーを入力するか、環境変数に{hint}を設定してください。',
'keyFallbackDetected': '環境変数を検出しました。({hint})',
'keyFallbackDetectedHidden': '環境変数を検出しました。',
@@ -580,10 +580,10 @@ export const es = {
keyPlaceholderFallback: 'Déjelo en blanco para usar env como autenticación ({hint})',
keyPlaceholderSaved: 'Déjelo en blanco para conservar la autenticación actual (clave API guardada)',
keySaved: 'Clave API guardada',
keySavedPreview: 'Clave API guardada: {preview}',
keyRemove: 'Borrar clave guardada',
keyRemoveUndo: 'Deshacer borrado',
keyRemovePending: 'Guarde para borrar la clave guardada y usar la autenticación del entorno.',
keyRemoveApplied: 'Clave borrada. Reintente guardar para completar la configuración; no se puede deshacer.',
keyFallbackMissing: 'No se detectó la variable de entorno. Ingrese una clave API o configure {hint} en su entorno.',
keyFallbackDetected: 'Se detectó var env. ({hint})',
keyFallbackDetectedHidden: 'Se detectó var env.',
@@ -758,10 +758,10 @@ export const fr = {
keyPlaceholderFallback: 'Laissez vide pour utiliser env comme authentification ({hint})',
keyPlaceholderSaved: 'Laissez vide pour conserver l’authentification actuelle (clé API enregistrée)',
keySaved: 'Clé API enregistrée',
keySavedPreview: 'Clé API enregistrée : {preview}',
keyRemove: 'Effacer la clé enregistrée',
keyRemoveUndo: 'Annuler l’effacement',
keyRemovePending: 'Enregistrez pour effacer la clé enregistrée et utiliser l’authentification de l’environnement.',
keyRemoveApplied: 'Clé supprimée. Réessayez d’enregistrer pour terminer les paramètres ; la suppression est irréversible.',
keyFallbackMissing: "Var d'environnement non détectée. Entrez une clé API ou configurez {hint} dans votre environnement.",
keyFallbackDetected: "Var d'environnement détectée. ({hint})",
keyFallbackDetectedHidden: "Var d'environnement détectée.",
@@ -936,10 +936,10 @@ export const de = {
keyPlaceholderFallback: 'Lassen Sie das Feld leer, um env als Authentifizierung zu verwenden ({hint})',
keyPlaceholderSaved: 'Leer lassen, um die aktuelle Authentifizierung beizubehalten (API-Schlüssel gespeichert)',
keySaved: 'API-Schlüssel gespeichert',
keySavedPreview: 'API-Schlüssel gespeichert: {preview}',
keyRemove: 'Gespeicherten Schlüssel löschen',
keyRemoveUndo: 'Löschen rückgängig machen',
keyRemovePending: 'Speichern, um den gespeicherten Schlüssel zu löschen und die Umgebungsauthentifizierung zu verwenden.',
keyRemoveApplied: 'Schlüssel gelöscht. Speichern erneut versuchen, um die Einstellungen abzuschließen; das Löschen ist unwiderruflich.',
keyFallbackMissing: 'Umgebungsvariable nicht erkannt. Geben Sie einen API-Schlüssel ein oder konfigurieren Sie {hint} in Ihrer Umgebung.',
keyFallbackDetected: 'Umgebungsvariable erkannt. ({hint})',
keyFallbackDetectedHidden: 'Umgebungsvariable erkannt.',
@@ -1114,10 +1114,10 @@ export const ru = {
keyPlaceholderFallback: 'Оставьте пустым, чтобы использовать env в качестве аутентификации ({hint})',
keyPlaceholderSaved: 'Оставьте поле пустым, чтобы сохранить текущую аутентификацию (API-ключ сохранён)',
keySaved: 'API-ключ сохранён',
keySavedPreview: 'API-ключ сохранён: {preview}',
keyRemove: 'Удалить сохранённый ключ',
keyRemoveUndo: 'Отменить удаление',
keyRemovePending: 'Сохраните, чтобы удалить сохранённый ключ и использовать аутентификацию из окружения.',
keyRemoveApplied: 'Ключ удалён. Повторите сохранение настроек; удаление нельзя отменить.',
keyFallbackMissing: 'Переменная Env не обнаружена. Введите ключ API или настройте {hint} в своей среде.',
keyFallbackDetected: 'Обнаружена переменная Env. ({hint})',
keyFallbackDetectedHidden: 'Обнаружена переменная Env.',
@@ -1292,10 +1292,10 @@ export const pt = {
keyPlaceholderFallback: 'Deixe em branco para usar env como autenticação ({hint})',
keyPlaceholderSaved: 'Deixe em branco para manter a autenticação atual (chave API salva)',
keySaved: 'Chave API salva',
keySavedPreview: 'Chave API salva: {preview}',
keyRemove: 'Limpar chave salva',
keyRemoveUndo: 'Desfazer limpeza',
keyRemovePending: 'Salve para limpar a chave salva e usar a autenticação do ambiente.',
keyRemoveApplied: 'Chave apagada. Tente salvar novamente para concluir as configurações; a exclusão não pode ser desfeita.',
keyFallbackMissing: 'Env var não detectado. Insira uma chave API ou configure {hint} em seu ambiente.',
keyFallbackDetected: 'Variável de ambiente detectada. ({hint})',
keyFallbackDetectedHidden: 'Variável de ambiente detectada.',
@@ -1455,7 +1455,7 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
// The field names the profile's reference; a stored value stays hidden.
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
expect(editorKey.placeholder).toBe(en.keyPlaceholderSaved)
expect(editorKey.placeholder).toBe('••••••••')
// pi-ai carries Base URL too: the stored override shows as the value and
// the effective profile endpoint, ahead of the catalog's, as its placeholder.
fireEvent.click(screen.getByText(en.customized))
@@ -1641,7 +1641,7 @@ describe('ModelsSection', () => {
face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'gateway/internal')))
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
expect(editorKey.placeholder).toBe(en.keyPlaceholderSaved)
expect(editorKey.placeholder).toBe('••••••••')
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
@@ -2215,7 +2215,7 @@ it.each(['file', 'env', 'project-env', 'user-env'] as const)('shows the existing
else expect(screen.getAllByText(hint)).toHaveLength(1)
expect(input.value).toBe('')
expect(input.placeholder)
.toBe(source === 'file' ? en.keyPlaceholderSaved : en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY'))
.toBe(source === 'file' ? '••••••••' : en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY'))
fireEvent.change(input, { target: { value: 'new-key' } })
expect(screen.queryByText(hint)).toBeNull()
expect(screen.queryByText(/^API key source:/)).toBeNull()
@@ -2225,33 +2225,69 @@ 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'])('shows only a saved-key preview for %s and preserves it when the draft is cleared', (provider) => {
const preview = 'sk-m…abcd'
const hint = en.keySavedPreview.replace('{preview}', preview)
it.each(['openai', 'anthropic', 'google', 'deepseek', 'acme-gateway'])('protects the saved %s key and reveals only its replacement', (provider) => {
renderPiAiEditor(provider, piAiNamespaceWith(provider, { apiKeyEnv: 'TEAM_API_KEY' }), {
credential: { configured: true, source: 'file', writable: true, preview },
credential: { configured: true, source: 'file', writable: true },
})
const input = screen.getByLabelText<HTMLInputElement>(en.keyInput)
expect(input.value).toBe('')
expect(input.type).toBe('password')
expect(input.placeholder).toBe(en.keyPlaceholderSaved)
const notice = screen.getByText(hint)
expect(input.getAttribute('aria-describedby')).toBe(notice.id)
expect(input.placeholder).toBe('••••••••')
expect(screen.queryByLabelText(en.keyReveal)).toBeNull()
expect(fireEvent.copy(input)).toBe(false)
expect(fireEvent.cut(input)).toBe(false)
fireEvent.change(input, { target: { value: 'replacement-key' } })
expect(screen.queryByText(hint)).toBeNull()
expect(screen.queryByText(en.keySaved)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.keyReveal }))
expect(input.type).toBe('text')
expect(input.value).toBe('replacement-key')
expect(fireEvent.copy(input)).toBe(true)
expect(fireEvent.cut(input)).toBe(true)
fireEvent.change(input, { target: { value: '' } })
expect(screen.getByText(hint)).toBeTruthy()
expect(input.value).toBe('')
expect(screen.getByText(en.keySaved)).toBeTruthy()
expect(input.placeholder).toBe(en.keyPlaceholderSaved)
expect(screen.queryByLabelText(en.keyReveal)).toBeNull()
})
it('uses a managed preview from a credential provider with its own source label', () => {
it.each(['Backspace', 'Delete'])('stages whole saved-key deletion with %s and can undo it', async (key) => {
const { unset } = await mountSection()
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
const input = screen.getByLabelText<HTMLInputElement>(en.keyInput)
expect(input.placeholder).toBe('••••••••')
fireEvent.keyDown(input, { key: 'ArrowLeft' })
expect(input.placeholder).toBe('••••••••')
fireEvent.keyDown(input, { key })
expect(input.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY'))
expect(screen.getByText(en.keyRemovePending)).toBeTruthy()
expect(unset).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: en.keyRemoveUndo }))
expect(input.placeholder).toBe('••••••••')
fireEvent.keyDown(input, { key })
fireEvent.click(screen.getByRole('button', { name: en.apply }))
await waitFor(() => { expect(unset).toHaveBeenCalledExactlyOnceWith('OPENAI_API_KEY') })
})
it('keeps a saved read-only credential masked when deletion is unavailable', () => {
renderPiAiEditor('openai', piAiNamespaceWith('openai', { apiKeyEnv: 'OPENAI_API_KEY' }), {
credential: { configured: true, source: 'keychain', writable: true, preview: 'sk-m…abcd' },
credential: { configured: true, source: 'file', writable: false },
})
expect(screen.getByText(en.keySavedPreview.replace('{preview}', 'sk-m…abcd'))).toBeTruthy()
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).placeholder).toBe(en.keyPlaceholderSaved)
expect(screen.queryByText(/Env var detected/)).toBeNull()
const input = screen.getByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.keyDown(input, { key: 'Delete' })
expect(input.placeholder).toBe('••••••••')
expect(screen.queryByText(en.keyRemovePending)).toBeNull()
expect(screen.queryByRole('button', { name: en.keyRemove })).toBeNull()
})
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 },
})
const input = screen.getByLabelText<HTMLInputElement>(en.keyInput)
expect(input.value).toBe('')
expect(input.placeholder).toBe(en.keyPlaceholder)
expect(screen.queryByRole('button', { name: en.keyRemove })).toBeNull()
fireEvent.keyDown(input, { key: 'Delete' })
expect(screen.queryByText(en.keyRemovePending)).toBeNull()
})
it('stages key removal and lets Undo or Cancel retain the saved credential', async () => {
@@ -2265,7 +2301,7 @@ it('stages key removal and lets Undo or Cancel retain the saved credential', asy
expect(unset).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: en.keyRemoveUndo }))
expect(screen.getByText(en.keySaved)).toBeTruthy()
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).placeholder).toBe(en.keyPlaceholderSaved)
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).placeholder).toBe('••••••••')
fireEvent.click(screen.getByRole('button', { name: en.keyRemove }))
fireEvent.click(screen.getByRole('button', { name: en.cancel }))
expect(unset).not.toHaveBeenCalled()
@@ -2298,29 +2334,68 @@ it.each([
else expect(mutate).not.toHaveBeenCalled()
})
it('can replace a key after its removal was refused, without losing its derived reference', async () => {
const namespace = piAiNamespaceWith('google', { apiKeyEnv: 'GOOGLE_API_KEY' })
const written = { ...namespace, revision: namespace.revision + 1,
user: { providers: { google: {} } }, value: { providers: { google: {} } } }
const mutate: ReturnType<typeof vi.fn> = vi.fn(() => Promise.resolve(remoteOk(written)))
const { face, unset, set } = scriptedFace({
mutate, unset: vi.fn(() => Promise.resolve(remoteFail('credential store locked'))),
it.each(['undo', 'cancel', 'replace'])('keeps authentication unchanged after refused deletion, then %s', async (action) => {
const { face, unset, set, mutate } = scriptedFace({
unset: vi.fn(() => Promise.resolve(remoteFail('credential store locked'))),
})
render(<ProviderEditor provider="google" displayName="Google" namespace={namespace}
const closed = vi.fn()
render(<ProviderEditor provider="google" displayName="Google"
namespace={piAiNamespaceWith('google', { apiKeyEnv: 'GOOGLE_API_KEY' })}
settingsPath={['providers', 'google']} schema={settingsSchema} operations={operationsWith(face)}
t={t} readOnly={false} onClose={vi.fn()} nativeAuth={{ env: ['GEMINI_API_KEY'], oauth: false }}
t={t} readOnly={false} onClose={closed} nativeAuth={{ env: ['GEMINI_API_KEY'], oauth: false }}
credential={{ configured: true, source: 'file', writable: true }} />)
fireEvent.click(screen.getByRole('button', { name: en.keyRemove }))
fireEvent.click(screen.getByRole('button', { name: en.apply }))
await screen.findByText('credential store locked')
expect(mutate).not.toHaveBeenCalled()
expect(screen.getByText(en.keyRemovePending)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'new-key' } })
if (action === 'cancel') {
fireEvent.click(screen.getByRole('button', { name: en.cancel }))
expect(closed).toHaveBeenCalledWith(false)
} else {
if (action === 'undo') {
fireEvent.click(screen.getByRole('button', { name: en.keyRemoveUndo }))
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).placeholder).toBe('••••••••')
} else {
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'new-key' } })
}
expect(screen.queryByText('credential store locked')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.apply }))
await waitFor(() => { expect(closed).toHaveBeenCalledWith(true) })
if (action === 'replace') expect(set).toHaveBeenCalledExactlyOnceWith('GOOGLE_API_KEY', 'new-key')
else expect(set).not.toHaveBeenCalled()
}
expect(unset).toHaveBeenCalledExactlyOnceWith('GOOGLE_API_KEY')
expect(mutate).not.toHaveBeenCalled()
})
it.each(['retry', 'close'])('reports a completed deletion when settings fail, then %s', async (action) => {
const namespace = piAiNamespaceWith('google', { apiKeyEnv: 'GOOGLE_API_KEY' })
const written = { ...namespace, revision: namespace.revision + 1,
user: { providers: { google: {} } }, value: { providers: { google: {} } } }
const mutate = vi.fn().mockResolvedValueOnce(remoteFail('settings locked')).mockResolvedValueOnce(remoteOk(written))
const { face, unset } = scriptedFace({ mutate })
const closed = vi.fn()
render(<ProviderEditor provider="google" displayName="Google" namespace={namespace}
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 }} />)
fireEvent.click(screen.getByRole('button', { name: en.keyRemove }))
fireEvent.click(screen.getByRole('button', { name: en.apply }))
await waitFor(() => { expect(set).toHaveBeenCalledExactlyOnceWith('GOOGLE_API_KEY', 'new-key') })
expect(unset).toHaveBeenCalledTimes(1)
expect(mutate.mock.calls[1]?.[1]).toEqual([
{ op: 'set', path: ['providers', 'google', 'apiKeyEnv'], value: 'GOOGLE_API_KEY' },
])
await screen.findByText('settings locked')
expect(screen.getByText(en.keyRemoveApplied)).toBeTruthy()
expect(screen.queryByRole('button', { name: en.keyRemoveUndo })).toBeNull()
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).disabled).toBe(true)
if (action === 'retry') {
fireEvent.click(screen.getByRole('button', { name: en.apply }))
await waitFor(() => { expect(closed).toHaveBeenCalledWith(true) })
expect(mutate).toHaveBeenCalledTimes(2)
} else {
fireEvent.click(screen.getByRole('button', { name: en.close }))
expect(closed).toHaveBeenCalledWith(false)
expect(mutate).toHaveBeenCalledOnce()
}
expect(unset).toHaveBeenCalledExactlyOnceWith('GOOGLE_API_KEY')
})
it('clears the selected reference when refreshed metadata names a different credential', async () => {
@@ -2364,7 +2439,7 @@ it('refreshes a blank editor from saved credentials to environment detection wit
const { face, controller } = await mountSection()
const reads = face.credentials.describe.mock.calls.length
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).placeholder).toBe(en.keyPlaceholderSaved)
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).placeholder).toBe('••••••••')
expect(screen.queryByText(/^API key source:/)).toBeNull()
expect(screen.queryByText(/Env var detected/)).toBeNull()
expect(face.credentials.describe).toHaveBeenCalledTimes(reads)
@@ -24,8 +24,8 @@
en: fccad7d354c2af71
zh: 5b95b5890e21f80f
/deepseek-ai-dsh-credentials-local/use-this-package/storing-and-removing-keys:
en: 892c025758def492
zh: 2b04ea8dabee95aa
en: 5e3d45470142b9dd
zh: 1f8358f987769d3a
/deepseek-ai-dsh-credentials-local/use-this-package/where-keys-come-from:
en: a55fac148b62e007
zh: fdb3d19ccae676ee
@@ -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?, preview?, writable } — never the value
await ctx.credentials.describe(ref) // { configured, source?, writable } — 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 full value. Stored keys longer than 12 characters expose only their first and last four characters in `preview`; shorter keys are fully masked. Environment credentials have no preview. Records persist in the same file, addressed by `<owner>/<id>` 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. Records persist in the same file, addressed by `<owner>/<id>` and managed with the seam's record operations (`readRecord`, `describeRecord`, `listRecords`, `modifyRecord`, `deleteRecord`).
### Where keys come from
@@ -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?, preview?, writable } — never the value
await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
await ctx.credentials.unset(ref) // remove
```
你保存的密钥会被下一个按名引用它的请求使用;`describe` 报告它是否已设置、来自哪里、能否写入——绝不返回完整值。超过 12 个字符的已保存密钥只在 `preview` 中显示首尾各四个字符;较短密钥全部遮住。环境凭据不提供摘要。记录也持久化在同一文件中:插件按 `<owner>/<id>` 寻址一条记录,并用 seam 的记录操作(`readRecord`、`describeRecord`、`listRecords`、`modifyRecord`、`deleteRecord`)管理它。
你保存的密钥会被下一个按名引用它的请求使用;`describe` 报告它是否已设置、来自哪里、能否写入——绝不返回值本身。记录也持久化在同一文件中:插件按 `<owner>/<id>` 寻址一条记录,并用 seam 的记录操作(`readRecord`、`describeRecord`、`listRecords`、`modifyRecord`、`deleteRecord`)管理它。
### 密钥从哪里来
@@ -633,10 +633,7 @@ export class LocalCredentialProvider extends CredentialProvider {
override describe(ref: CredentialRef): Promise<CredentialInfo> {
const stored = this.values.get(ref)
if (stored !== undefined) return Promise.resolve({
configured: true, source: 'file', writable: true,
preview: stored.length > 12 ? `${stored.slice(0, 4)}…${stored.slice(-4)}` : '••••••••',
})
if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
// 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 })
@@ -59,23 +59,6 @@ describe('resolveSpec', () => {
})
describe('layering and reads', () => {
it.each([
['a', '••••••••'],
['123456789012', '••••••••'],
['1234567890123', '1234…0123'],
['sk-managed-secret-abcd', 'sk-m…abcd'],
])('describes a stored credential with a masked preview (%s)', async (value, preview) => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
await ctx.credentials.set(KEY, value)
const info = await ctx.credentials.describe(KEY)
expect(info).toEqual({ configured: true, source: 'file', writable: true, preview })
expect(info.preview).not.toBe(value)
expect(await ctx.credentials.resolve(KEY)).toEqual({ value, source: 'file' })
await ctx.credentials.unset(KEY)
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
})
it('treats an absent file as an empty writable store', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
@@ -90,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, preview: '••••••••' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
})
it('lets the managed file win over a non-empty process environment', async () => {
@@ -100,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, preview: '••••••••' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
})
it('treats an empty environment value as absent, falling through to the file', async () => {
@@ -110,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, preview: '••••••••' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
})
it('fails boot loud when the document exists but cannot be read', async () => {
@@ -148,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, preview: '••••••••' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined()
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' })
})
@@ -247,7 +230,7 @@ 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, preview: '••••••••' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
await expect(ctx.credentials.set(KEY, 'next')).resolves.toBeUndefined()
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'next', source: 'file' })
})
@@ -399,7 +382,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, preview: 'line… two' })
expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
})
it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', async () => {
@@ -24,8 +24,8 @@
en: 8f3500119608f91b
zh: 147fdb5389affbc0
/deepseek-ai-dsh-credentials/use-this-package/storing-checking-and-removing-keys:
en: d354985614dafc0d
zh: 3ff9fdd480521283
en: fa334e2d4438e0b6
zh: 009b9252ed046886
/deepseek-ai-dsh-credentials/use-this-package/storing-updating-and-removing-records:
en: 300c3f1f13abdca4
zh: ec7a6486ac88889c
+2 -2
View File
@@ -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?, preview?, writable } — never the value
const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — 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 full value. A provider may supply a masked `preview` for a managed key; ambient credentials have no preview.
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.
### Storing, updating, and removing records
@@ -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?, preview?, writable } — never the value
const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — 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` 报告密钥是否已设置、来自哪里、能否写入——它绝不返回完整值。提供方可为托管密钥提供脱敏的 `preview`;环境凭据不提供摘要。
用 `set` 存储密钥、用 `unset` 移除、用 `describe` 检查状态、在操作需要时用 `resolve` 读取当前值。`describe` 报告密钥是否已设置、来自哪里、能否写入——它绝不返回值。
### 存储、更新与移除记录
@@ -61,16 +61,14 @@ export type CredentialRecord = ApiKeyRecord | GrantRecord
/**
* Source and writability facts for one reference, safe for configuration UIs —
* never the full value. Providers may include a masked preview of a managed
* credential; ambient credentials do not expose a preview.
* never the value. The view has no slot a value could ride in, which is what
* lets the whole read half cross the Remote wire.
*/
export interface CredentialInfo {
/** Whether resolving the reference would currently return a value. */
configured: boolean
/** Source layer currently supplying the value; absent while unconfigured. */
source?: string
/** Masked managed value for display only; short values must be fully masked. */
preview?: string
/** Whether the active provider can write this reference. */
writable: boolean
}
@@ -6280,7 +6280,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CredentialInfo',
declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n preview?: string;\n writable: boolean;\n}',
declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}',
},
{
name: 'CredentialKey',
@@ -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, preview: '••••••••' })
.toEqual({ configured: true, source: 'file', writable: true })
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,7 +335,7 @@ 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, preview: '••••••••' })
expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true })
// Rotation still works after the restart, and the next request uses it.
await credentials.set(KEY_REF, 'rotated-after-restart')
await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] })