diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md new file mode 100644 index 0000000000..48b7b0f952 --- /dev/null +++ b/packages/credentials/credentials/README.md @@ -0,0 +1,45 @@ +# dsh-credentials + +English | [中文](README.zh.md) + +Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: + +**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file. + +**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin. + +**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret. + +## Surface + +```ts +import { credentialRef } from '@deepseek-ai/dsh-credentials' + +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 +await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref +await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +``` + +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. + +The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front. + +## Providers + +[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. + +## Model Experience + +Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer. +- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing. +- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation. diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md new file mode 100644 index 0000000000..ef9e32ebad --- /dev/null +++ b/packages/credentials/credentials/README.zh.md @@ -0,0 +1,45 @@ +# dsh-credentials + +[English](README.md) | 中文 + +抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: + +**配置只携带对秘密的引用,绝不携带秘密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答"配置了吗、来自哪层、能否写入";轮换秘密不触碰任何配置文件。 + +**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM adapter 每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 + +**空的存储值等于不存在。** 处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的秘密。 + +## 接口面 + +```ts +import { credentialRef } from '@deepseek-ai/dsh-credentials' + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell 标识符,品牌类型 +const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } —— 绝不含值 +await ctx.credentials.set(ref, 'sk-…') // 被只读来源遮蔽时拒绝 +await ctx.credentials.unset(ref) // 不存在时为 no-op;同样的遮蔽规则 +``` + +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新"已配置"徽标。 + +`set`/`unset` 的遮蔽规则是刻意的 fail-loud:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 + +## Providers + +[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带秘密。 + +## Model Experience + +Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费者。 +- **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 +- **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json new file mode 100644 index 0000000000..d907b0a1bb --- /dev/null +++ b/packages/credentials/credentials/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-credentials", + "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts new file mode 100644 index 0000000000..2df89132ac --- /dev/null +++ b/packages/credentials/credentials/src/index.ts @@ -0,0 +1,114 @@ +/** + * Credential seam (`ctx.credentials`). Settings and composition files carry + * *references* to secrets — environment-variable names — while providers own + * the actual values and their storage. Consumers resolve a reference once per + * operation, so a changed credential reaches the next operation without any + * plugin restart, and configuration surfaces describe a reference without + * ever seeing its value. + * @module @deepseek-ai/dsh-credentials + */ + +import { Context, Service } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +export type CredentialRef = Branded<'CredentialRef'> + +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Brand a raw string as a {@link CredentialRef}. + * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`. + * @returns the branded reference. + */ +export function credentialRef(value: string): CredentialRef { + if (!REF_PATTERN.test(value)) { + throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`) + } + return value as CredentialRef +} + +/** One resolved credential value and the source layer that supplied it. */ +export interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} + +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +export interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} + +declare module 'cordis' { + interface Context { + credentials: Credentials + } + + interface Events { + /** + * Committed change to a provider-managed credential source: a `set`, an + * `unset`, or an external edit observed in storage. Ambient + * process-environment changes are not observable and never emit. + * @param ref - the reference whose stored value changed. + * @mode emit + */ + 'credentials/updated'(ref: CredentialRef): void + } +} + +/** + * Abstract credential service. Providers implement the four operations over + * their source layers; one seam-wide rule binds them all: an empty stored + * value is absent everywhere — `resolve` skips it, `describe` reports it + * unconfigured — so a blank never masquerades as a configured secret. + */ +export abstract class Credentials extends Service { + constructor(ctx: Context) { + super(ctx, 'credentials') + } + + /** + * Resolve one reference to its current value. Resolution is per call: + * consumers re-resolve at each operation and must not cache across + * operations — that per-operation read is what makes a changed credential + * reach the next operation without a restart. + * @param ref - the reference to resolve. + * @returns the value and its source, or `undefined` while unconfigured. + */ + 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. + */ + abstract describe(ref: CredentialRef): Promise + + /** + * Durably store one value in the provider-managed writable source. Rejects + * while a read-only source shadows the reference — the write would appear + * to succeed while resolution keeps returning the shadowing value — and + * rejects an empty value (use {@link unset}). + * @param ref - the reference to store. + * @param value - the non-empty secret value. + */ + abstract set(ref: CredentialRef, value: string): Promise + + /** + * Remove one reference from the provider-managed writable source; removing + * an absent reference is a no-op. Rejects while a read-only source shadows + * the reference, like {@link set}. + * @param ref - the reference to remove. + */ + abstract unset(ref: CredentialRef): Promise +} + +export default Credentials diff --git a/packages/credentials/credentials/src/invariant.ts b/packages/credentials/credentials/src/invariant.ts new file mode 100644 index 0000000000..23c2dda45b --- /dev/null +++ b/packages/credentials/credentials/src/invariant.ts @@ -0,0 +1,38 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-credentials`. + * @module @deepseek-ai/dsh-credentials/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-credentials' + +/** Cordis companion plugin name. */ +export const name = 'credentials-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Install the commit-event lifecycle contract: `credentials/updated` names a + * committed provider-source change, so it can only fire while a credentials + * service is live — an emission after disposal means a provider leaked work + * past its teardown quiescence. The value relation itself (`describe` + * agreeing with `resolve`) is asynchronous provider I/O and stays pinned by + * each provider's own suite. + */ +const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { + ctx.on('credentials/updated', (ref) => { + if (ctx.get('credentials') === undefined) { + fail(`credentials/updated for "${ref}" emitted without a live credentials service`) + } + }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/credentials/credentials/tests/credentials.spec.ts b/packages/credentials/credentials/tests/credentials.spec.ts new file mode 100644 index 0000000000..9b4cf7b1e8 --- /dev/null +++ b/packages/credentials/credentials/tests/credentials.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { credentialRef } from '../src/index.ts' +import type { CredentialRef } from '../src/index.ts' +import { MemoryCredentials } from './memory.ts' + +const REF = credentialRef('DEEPSEEK_API_KEY') + +async function boot(seed: Record = {}): Promise { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, seed) + return ctx +} + +describe('credentialRef', () => { + it('brands POSIX shell identifiers', () => { + expect(credentialRef('DEEPSEEK_API_KEY')).toBe('DEEPSEEK_API_KEY') + expect(credentialRef('_private')).toBe('_private') + expect(credentialRef('lower_case9')).toBe('lower_case9') + }) + + it('rejects every other shape', () => { + for (const invalid of ['', '9LEADING', 'WITH-DASH', 'WITH SPACE', 'ns:key']) { + expect(() => credentialRef(invalid)).toThrow(TypeError) + } + }) +}) + +describe('the credentials seam through the memory provider', () => { + it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => { + const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' }) + expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-seeded', source: 'memory' }) + expect(await ctx.credentials.describe(REF)).toEqual({ configured: true, source: 'memory', writable: true }) + }) + + it('treats an empty stored value as absent everywhere', async () => { + const ctx = await boot({ DEEPSEEK_API_KEY: '' }) + expect(await ctx.credentials.resolve(REF)).toBeUndefined() + expect(await ctx.credentials.describe(REF)).toEqual({ configured: false, writable: true }) + }) + + it('stores through set, removes through unset, and emits the committed change', async () => { + const ctx = await boot() + const events: CredentialRef[] = [] + ctx.on('credentials/updated', ref => void events.push(ref)) + + await ctx.credentials.set(REF, 'sk-live') + expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' }) + await ctx.credentials.unset(REF) + expect(await ctx.credentials.resolve(REF)).toBeUndefined() + expect(events).toEqual([REF, REF]) + }) + + it('rejects an empty set and keeps an absent unset silent', async () => { + const ctx = await boot() + const events: CredentialRef[] = [] + ctx.on('credentials/updated', ref => void events.push(ref)) + + await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/) + await ctx.credentials.unset(REF) + expect(events).toEqual([]) + }) + + it('removes the service with its fiber', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(MemoryCredentials) + expect(ctx.get('credentials')).toBeDefined() + await fiber.dispose() + expect(ctx.get('credentials')).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials/tests/invariant.spec.ts b/packages/credentials/credentials/tests/invariant.spec.ts new file mode 100644 index 0000000000..dccde4843f --- /dev/null +++ b/packages/credentials/credentials/tests/invariant.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { credentialRef } from '../src/index.ts' +import * as CredentialsInvariant from '../src/invariant.ts' +import { MemoryCredentials } from './memory.ts' + +const REF = credentialRef('DEEPSEEK_API_KEY') + +describe('credentials invariant companion', () => { + it('accepts a committed change emitted by a live service', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + await ctx.plugin(MemoryCredentials) + + await expect(ctx.credentials.set(REF, 'sk-live')).resolves.toBeUndefined() + }) + + it('fails an update event emitted without a live service', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + + expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/) + }) + + it('reserves the package name against duplicate registration', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-credentials', () => {}) + }).toThrow(/already registered/) + }) +}) diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts new file mode 100644 index 0000000000..c562d8ab0a --- /dev/null +++ b/packages/credentials/credentials/tests/memory.ts @@ -0,0 +1,51 @@ +import type { Context } from 'cordis' +import { Credentials } from '../src/index.ts' +import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts' + +/** + * In-memory credentials provider for interface and consumer tests: one + * always-writable `memory` source seeded from plugin config. + */ +export class MemoryCredentials extends Credentials { + private readonly store = new Map() + + constructor(ctx: Context, seed: Record = {}) { + super(ctx) + for (const [key, value] of Object.entries(seed)) this.store.set(key, value) + } + + override resolve(ref: CredentialRef): Promise { + const value = this.store.get(ref) + return Promise.resolve(value === undefined || value.length === 0 + ? undefined + : { value, source: 'memory' }) + } + + override describe(ref: CredentialRef): Promise { + const value = this.store.get(ref) + const configured = value !== undefined && value.length > 0 + return Promise.resolve({ + configured, + ...configured ? { source: 'memory' } : {}, + writable: true, + }) + } + + override set(ref: CredentialRef, value: string): Promise { + if (value.length === 0) { + return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset')) + } + this.store.set(ref, value) + this.ctx.emit('credentials/updated', ref) + return Promise.resolve() + } + + override unset(ref: CredentialRef): Promise { + if (this.store.delete(ref)) { + this.ctx.emit('credentials/updated', ref) + } + return Promise.resolve() + } +} + +export default MemoryCredentials diff --git a/packages/credentials/credentials/tsconfig.json b/packages/credentials/credentials/tsconfig.json new file mode 100644 index 0000000000..5bc7a9fcf5 --- /dev/null +++ b/packages/credentials/credentials/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2036f68aa6..31ff52fe9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2062,6 +2062,18 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/credentials/credentials: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/examples/acp-demo: devDependencies: '@cordisjs/plugin-include': diff --git a/tsconfig.base.json b/tsconfig.base.json index a93c67b304..9ffb5618d7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -88,6 +88,7 @@ "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", "./packages/settings/*/src/invariant.ts", + "./packages/credentials/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -173,6 +174,7 @@ "./packages/session-query/*/src", "./packages/session-title/*/src", "./packages/settings/*/src", + "./packages/credentials/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", "./packages/storage/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 92eeea6a94..67e340e898 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -64,6 +64,7 @@ { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, + { "path": "./packages/credentials/credentials" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" },