Merge remote-tracking branch 'origin/master' into feature/issue-1470-skill-invoke

# Conflicts:
#	docs/module-graph.md
This commit is contained in:
Yichen Jiang
2026-08-08 14:03:29 +08:00
248 changed files with 4129 additions and 1479 deletions

View File

@@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
Naming notes:
- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)).
- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)).
- `src/types.ts` contains only types — no runtime code.
- Tests live at package level under `tests/`, not `src/__tests__/`.
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36
README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575

View File

@@ -10,6 +10,14 @@ The current Client assembly mounts only the Goal Remote contribution. Cordis eff
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
## Build boundary
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
## Model Experience
None, as this BFF selects Remote application methods and identity policy but registers no model surface.

View File

@@ -10,6 +10,14 @@
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。
## 构建边界
仓库中的普通包只属于一个 TypeScript faceHost 包登记在根 `tsconfig.host.json`Client 包登记在根 `tsconfig.client.json``api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json``tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle不得因一个包同时存在 `src/index.ts``src/client/index.ts` 就复制本包的拆分。
## 模型体验
无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/index.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../goal/goal"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/agent-lookup.ts",
"src/index.ts",
"src/invariant.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
},
{
"path": "../../typert/registry"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -1,42 +1,11 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"files": [],
"references": [
{
"path": "../../../vendor/cordis"
"path": "./tsconfig.host.json"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../typert/type-meta"
},
{
"path": "../../typert/registry"
},
{
"path": "../../ui/commands"
},
{
"path": "../../goal/goal"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../support/invariants"
"path": "./tsconfig.client.json"
}
]
}

View File

@@ -1,3 +1,7 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js'])
export default clientBundle(
'@deepseek-ai/dsh-api-remotes',
['lib/types/index.js', 'lib/types/invariant.js'],
{ hostPhase: true },
)

View File

@@ -77,12 +77,10 @@
- id: settings
name: '@deepseek-ai/dsh-settings-local'
# Credential store: the live process environment over `$DSH_HOME/.env`
# (owner-only file, hot-reloaded). Adapters resolve their key references
# through it at each request, so no key is inlined in this file. The web
# Models page's key inputs write it through `credentials.set`; nothing hoists
# the document into the process environment, which would make every stored key
# read as an unrotatable ambient override.
# Credential sources: inherited environment over the managed
# `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks.
# Adapters resolve references per request; the Models page writes only the
# managed document, which is never materialized into the process environment.
- id: credentials
name: '@deepseek-ai/dsh-credentials-local'
@@ -382,7 +380,6 @@
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'

View File

@@ -35,11 +35,6 @@
# once the web UI owns the choice per session.
mode: !!js process.env.DSH_TOOLS_MODE
- id: llm-deepseek
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
# ── web-only host rows, the transport layer, and the browser roster ─────────
# `dshClient` rows are the browser roster the modules node half scans into

View File

@@ -25,7 +25,6 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-typert-registry"
],
"platform": "web",
@@ -49,14 +48,12 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-api-remotes": "^0.0.1",
"@deepseek-ai/dsh-type-meta": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",

View File

@@ -1,7 +1,6 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -179,8 +178,8 @@ declare module 'cordis' {
}
}
/** Required services: the Remote root, wire handle, and Client TypeRT registry. */
export const inject = ['remote', 'connection', 'typert']
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.

View File

@@ -20,9 +20,6 @@
{
"path": "../connection"
},
{
"path": "../../api/remotes"
},
{
"path": "../../host/apiproxy"
},

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-schema-form',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-test-runtime',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -9,6 +9,7 @@
* The virtual loader registers each real stylesheet as a watch dependency.
*/
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { UserConfig } from 'tsdown'
@@ -34,6 +35,12 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
/** Generated descriptor/codec contribution with no shared runtime identity. */
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
/**
* Workspace mode replaces an empty config array with the root defaults. A
* falsey entry instead removes this package before entry resolution.
*/
const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
/**
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
@@ -61,19 +68,85 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
/**
* Build the tsdown config for one UI plugin package: the node-half lib build
* plus the browser client bundle. A package-level tsdown.config.ts REPLACES
* the root workspace shape, so the lib half must be restated here — dropping
* it leaves the package without lib/index.js and the host Loader cannot
* import its node half.
* plus the browser client bundle. Client packages emit both halves during the
* Client pass by default; packages needed for Host reflection may opt into the
* earlier Host pass. A package-level tsdown.config.ts REPLACES the root
* workspace shape, so the lib half must be restated here — dropping it leaves
* the package without lib/index.js and the host Loader cannot import its node
* half.
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
* handoff and onto the injected style tags.
* @param libEntry - node-half entries, spelled at the call site so the
* package-invariants gate can see `lib/types/invariant.js` in each package's
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
* @returns tsdown user configs emitting lib/*.js and lib/client.js.
* @param options - phase placement, lib overrides, and companion Node configs.
* @returns ENV-selected tsdown config for the current build face.
*/
export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] {
return [{
export function clientBundle(
id: string,
libEntry: readonly string[],
options: ClientBundleOptions = {},
): BuildFaceConfig {
const lib = clientLibraryConfig(id, libEntry, options.lib)
return ({ env }) => {
const face = buildFace(env?.DSH_BUILD_FACE)
const client = clientConfig(id, face === undefined
? 'src/client/index.ts'
: 'lib/types/client/index.js')
const node = [lib, ...(options.companions ?? [])]
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
return [...node, client]
}
}
/**
* Build a Client-only Node library during the Client pass.
* @param id - Package name used in tsdown diagnostics.
* @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
* @returns ENV-selected tsdown config for the Client build face.
*/
export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
const lib = clientLibraryConfig(id, libEntry)
return clientOnly([lib])
}
/**
* Select arbitrary package-local configs only during the Client pass.
* @param configs - Node-side configs emitted after Client tsc.
* @returns ENV-selected tsdown config for the Client build face.
*/
export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
? [SKIP_WORKSPACE_BUILD]
: [...configs]
}
interface ClientBundleOptions {
/** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
readonly hostPhase?: boolean
/** Additional Node-side configs emitted alongside the package library. */
readonly companions?: readonly UserConfig[]
/** Overrides for the package's primary Node-side library config. */
readonly lib?: UserConfig
}
type BuildFace = 'host' | 'client' | undefined
type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
function buildFace(value: unknown): BuildFace {
if (value === undefined || value === 'host' || value === 'client') return value
throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
}
function clientLibraryConfig(
id: string,
libEntry: readonly string[],
overrides: UserConfig = {},
): UserConfig {
return {
name: id,
entry: [...libEntry],
outDir: 'lib',
format: ['esm'],
@@ -82,8 +155,14 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
fixedExtension: false,
dts: false,
clean: false,
}, {
entry: { client: 'src/client/index.ts' },
...overrides,
}
}
function clientConfig(id: string, entry: string): UserConfig {
return {
name: `${id}/client`,
entry: { client: entry },
// Browser bundle lands next to the node half (single lib/ artifact dir;
// the entryFileNames pin keeps it exactly lib/client.js). clean must stay
// off — a default clean would wipe the node-half output emitted above.
@@ -139,7 +218,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
name: 'dsh-css-modules-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.module.css')) return null
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
@@ -182,5 +261,15 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
},
}]
}
}
/** Resolve an emitted JS asset import against its source-tree counterpart. */
function sourceAssetPath(source: string, importer: string): string {
const emitted = resolvePath(dirname(importer), source)
if (existsSync(emitted)) return emitted
const marker = `${sep}lib${sep}types${sep}`
const boundary = emitted.indexOf(marker)
if (boundary < 0) return emitted
return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
}

View File

@@ -15,7 +15,7 @@
"path": "../locale"
},
{
"path": "../../api/remotes"
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../runtime"

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: cf4e50630339c4055e9ae2df37246b814af06966
README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f
README.md: 66f23d3f77adae23fcebd3adbb6c5c47ce16ae2f
README.zh.md: dcb35c624aca73b558532210750f684e5cc54e85

View File

@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field 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 `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. 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; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field 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 `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model list and endpoint interrogation

View File

@@ -4,11 +4,11 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile因此能保留提供方原生认证例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中它是按模型的能力而同一提供方下各模型接受的档位并不一致因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile因此能保留提供方原生认证例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中它是按模型的能力而同一提供方下各模型接受的档位并不一致因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。`apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段,而不重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定trim 之后必须非空,且每个字符都是可打印 ASCII`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm``normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它只修改自己看得见的字段而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定trim 之后必须非空,且每个字符都是可打印 ASCII`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm``normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型列表与端点询问

View File

@@ -80,8 +80,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
* Remove one user-added provider and its page-managed credential. Credential
* removal comes first so a second-step failure leaves the provider row visible
* and the whole operation safely retryable; both unsets are idempotent.
* The settings removal names the profile rather than rebuilding its redacted
* namespace, which would drop literal secrets stored elsewhere.
* The settings removal names the profile rather than rebuilding its whole
* namespace from a partial view.
* @param api - settings and credential wire faces.
* @param controller - the page store to refresh.
* @param target - the provider's settings address and optional managed credential.
@@ -112,16 +112,14 @@ export async function removeProviderProfile(
}
/**
* Whether a whole-section provider still needs its first key: nothing marks
* the credential configured and no literal `apiKey` is stored, so the page
* opens the setup card instead of showing a row.
* Whether a whole-section provider still needs its first key: an unconfigured
* credential opens the setup card instead of showing a row.
* @param row - the joined provider row.
* @returns whether to render the setup card.
*/
export function needsSetup(row: ProviderRow): boolean {
if (row.entry.settingsPath.length > 0) return false
if (row.credential?.configured === true) return false
return !row.literalApiKeyConfigured
return row.credential?.configured !== true
}
function targetOf(row: ProviderRow): EditorTarget {
@@ -264,7 +262,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
)
}
const open = !adding && editing?.provider === row.entry.provider
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
const credentialConfigured = row.credential?.configured === true
const credentialMissing = !credentialConfigured
&& row.apiKeyEnv !== undefined
&& row.credential?.configured === false

View File

@@ -14,9 +14,8 @@
* model picker offers each model its own levels; `settings.yaml` keeps the
* profile field for a deployment that knows its route. Everything else stays
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
* path ops against the stored section — the card reads the redacted
* descriptor, so it names only the fields it can see and a stored literal
* secret is never collaterally removed.
* path ops against the stored section — the card names only the fields it can
* see instead of rebuilding the whole subtree from a partial descriptor.
*/
import { useEffect, useMemo, useState } from 'react'
@@ -72,10 +71,9 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
/**
* The minimal path ops carrying `after` over `before`, both as the card sees
* them (that is, redacted). Only keys the card observed are named: a stored
* `role('secret')` field appears in neither side, so it produces no op and
* survives the write — the whole reason edits are path-addressed rather than
* a rebuilt section.
* them. Only keys the card observed are named; fields absent from both sides
* produce no op, which is why edits are path-addressed rather than a rebuilt
* section.
* @param base - path of the edited subtree inside the user section.
* @param before - the subtree as loaded, or undefined when it is new.
* @param after - the subtree as edited.
@@ -197,9 +195,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
/**
* The write for this card, or a failure message. Every edit travels as
* path ops against the STORED section: the draft comes from the redacted
* descriptor, so a wholesale replace rebuilt from it would delete the
* literal secrets the wire never returned. Ops name only the fields this
* card can see, so a stored secret is untouched by construction.
* descriptor, so a wholesale replace rebuilt from it could delete fields
* outside the card. Ops name only the fields this card can see.
*/
const applyOnce = async (): Promise<string | undefined> => {
const ns = namespace.ns

View File

@@ -31,8 +31,6 @@ export interface ProviderRow {
apiKeyEnv: string | undefined
/** Credential state for {@link apiKeyEnv}, once described. */
credential: CredentialView | undefined
/** Whether the redacted secret sidecar reports an effective literal `apiKey`. */
literalApiKeyConfigured: boolean
}
/** Page snapshot. */
@@ -97,19 +95,6 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
}
/** Whether one namespace's redacted sidecar reports a set literal API key. */
function literalApiKeyConfigured(
namespace: SettingsNamespaceView | undefined,
path: readonly string[],
): boolean {
if (namespace === undefined) return false
const secretPath = [...path, 'apiKey']
return namespace.secrets.some(secret =>
secret.set
&& secret.path.length === secretPath.length
&& secret.path.every((key, index) => key === secretPath[index]))
}
/** The models settings page controller (one per settings surface). */
export class ModelsSettingsStore {
/** The snapshot the section renders from (uSES-safe store). */
@@ -170,7 +155,6 @@ export class ModelsSettingsStore {
removable,
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
credential: undefined,
literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath),
}
})
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
@@ -257,7 +241,6 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
reason: 'settings-unavailable',
}
}
if (row.literalApiKeyConfigured) return { kind: 'configured' }
if (row.apiKeyEnv === undefined) {
return {
kind: 'unavailable',

View File

@@ -35,9 +35,7 @@ function capacityInputs(label: string): HTMLInputElement[] {
}
const PiAiConfig = Schema.object({
token: Schema.string().role('secret'),
providers: Schema.dict(Schema.object({
apiKey: Schema.string().role('secret'),
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string(),
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
@@ -46,7 +44,6 @@ const PiAiConfig = Schema.object({
})
const DeepSeekConfig = Schema.object({
apiKey: Schema.string().role('secret'),
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string().pattern(/^https:\/\//),
reasoningEffort: Schema.union(['off', 'high', 'max']),
@@ -99,7 +96,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
user: { baseURL: 'https://base' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
secrets: [],
revision: 0,
},
{
@@ -118,7 +115,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
applies: 'live',
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
secrets: [],
revision: 0,
},
]
@@ -262,22 +259,17 @@ describe('ModelsSection', () => {
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
})
it('decides setup need from the joined credential state and literal-key sidecar', () => {
it('decides setup need from the joined credential state', () => {
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
const row = (
credential: ProviderRow['credential'],
literalApiKeyConfigured = false,
): ProviderRow => ({
const row = (credential: ProviderRow['credential']): ProviderRow => ({
entry,
configured: true,
removable: false,
apiKeyEnv: 'X',
credential,
literalApiKeyConfigured,
})
expect(needsSetup(row(undefined))).toBe(true)
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
expect(needsSetup(row(undefined, true))).toBe(false)
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
expect(needsSetup(nested)).toBe(false)
})
@@ -294,9 +286,7 @@ describe('ModelsSection', () => {
expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
})
it('names only the fields the card can see, so an unseen secret survives', () => {
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
// side, so no op mentions it and the seam leaves it alone.
it('names only changed fields instead of rebuilding the section', () => {
expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
.toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
@@ -724,8 +714,7 @@ describe('ModelsSection', () => {
})
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
// The data-loss shape: the old path rebuilt the section from the REDACTED
// user layer and replaced it wholesale, deleting any stored literal key.
// The old path rebuilt the whole user section to clear one inherited field.
const { replace, update, mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
@@ -798,9 +787,7 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the edited field travels: apiKeyEnv and headers were already stored
// with these values, so no op restates them — and the profile's stored
// literal apiKey, absent from the redacted view the card read, is named by
// nothing at all.
// with these values, so no op restates them.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
@@ -1132,8 +1119,8 @@ describe('ModelsSection', () => {
})
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
// The section rebuild is what dropped stored literal secrets: this page
// only ever holds the redacted descriptor, so the removal names the path.
// The page only needs to name the profile path; rebuilding the section
// would widen the write for no benefit.
const { face, mutate, replace, controller } = await mountSection()
await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],

View File

@@ -28,7 +28,6 @@ function harness(options: {
providerActive?: boolean
settingsNamespace?: boolean
apiKeyEnv?: string | null
literal?: boolean
configured?: () => boolean
credential?: { source?: string; writable: boolean }
describeFailure?: string
@@ -66,7 +65,7 @@ function harness(options: {
? {}
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: options.literal === true }],
secrets: [],
revision: 0,
}],
})),
@@ -153,11 +152,10 @@ describe('DeepSeekOnboardingDialog', () => {
}
})
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
it('skips an absent adapter and an already-configured environment credential', async () => {
for (const h of [
harness({ provider: false }),
harness({ providerSettingsNs: '' }),
harness({ literal: true, describeFailure: 'credential seam absent' }),
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)

View File

@@ -19,7 +19,6 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
removable: false,
apiKeyEnv: 'DEEPSEEK_API_KEY',
credential: missingCredential,
literalApiKeyConfigured: false,
...overrides,
}
}
@@ -64,13 +63,6 @@ describe('deepSeekReadiness', () => {
}))).toEqual({ kind: 'configured' })
})
it('accepts the redacted literal-key sidecar before judging the credential domain', () => {
expect(deepSeekReadiness(state({
credentialError: 'credentials service absent',
rows: [row({ literalApiKeyConfigured: true, credential: undefined })],
}))).toEqual({ kind: 'configured' })
})
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
kind: 'unavailable',

View File

@@ -25,7 +25,7 @@ const NAMESPACES = [
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
base: { baseURL: 'https://base' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: false }],
secrets: [],
revision: 0,
},
{
@@ -85,7 +85,6 @@ describe('ModelsSettingsStore', () => {
removable: false,
apiKeyEnv: 'DEEPSEEK_API_KEY',
credential: { configured: false, writable: true },
literalApiKeyConfigured: false,
})
expect(byProvider.get('openai')).toMatchObject({
configured: true,
@@ -131,30 +130,6 @@ describe('ModelsSettingsStore', () => {
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
})
it('joins a configured literal key from the redacted secret sidecar', async () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{
...NAMESPACES[0],
secrets: [
{ path: ['apiKey', 'nested'], set: true },
{ path: ['different'], set: true },
{ path: ['apiKey'], set: true },
],
}] as never,
})),
providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
})
const store = new ModelsSettingsStore(face)
await store.load()
expect(store.store.getSnapshot().rows[0]).toMatchObject({
literalApiKeyConfigured: true,
apiKeyEnv: 'DEEPSEEK_API_KEY',
})
})
it('surfaces a directory failure and keeps the last good rows', async () => {
const { face } = api()
const store = new ModelsSettingsStore(face)

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* ui-primitives is browser-only, but its lib bundle IS imported under plain
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* (loader module table / vite source paths), which compile src directly and
* never read lib.
*/
export default defineConfig({
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
@@ -28,4 +28,4 @@ export default defineConfig({
return 'export default {};'
},
}],
})
}])

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-ui-slots',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -1,11 +1,11 @@
import { clientBundle } from '../tsdown.client.ts'
const [lib, client] = clientBundle(
export default clientBundle(
'@deepseek-ai/dsh-client-ui-theme',
['lib/types/index.js', 'lib/types/invariant.js'],
{
lib: {
copy: [{ from: 'src/styles/*', to: 'lib/styles' }],
},
},
)
export default [{
...lib,
copy: [{ from: 'src/styles/*', to: 'lib/styles' }],
}, client]

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* Root and invariant shapes as SEPARATE single-entry bundles: a multi-entry
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* runtime — browser consumers resolve this package through the loader module
* table.
*/
export default defineConfig([
export default clientOnly([
{
entry: { index: 'lib/types/index.js' },
outDir: 'lib',

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* Root-shape lib build plus a css stub: the shell's components import
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* this node lib build stubs every css import to an empty module — importing
* the lib under plain node must not crash on an asset specifier.
*/
export default defineConfig({
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
@@ -28,4 +28,4 @@ export default defineConfig({
return 'export default {};'
},
}],
})
}])

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
README.md: 7d541d42efc942310e9a4066a9edadedb608ef0b
README.zh.md: 6a3d697551c8607bbab59a81c84eb725bf6191eb
README.md: 8e95a890a8e38172cf8984653a01c59570f0061a
README.zh.md: 04ad07ae4e703ab0416d1d8f1bb6a6ff90adf337

View File

@@ -2,37 +2,56 @@
English | [中文](README.zh.md)
File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence.
File-backed [credentials](../credentials/README.md) provider: four layers, one honest precedence.
| Layer | Source id | Writable | Wins |
|---|---|---|---|
| Live process environment | `env` | no | always |
| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise |
| Inherited process environment | `env` | no | always |
| `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | over both `.env` layers |
| `<invocation cwd>/.env` | `project-env` | not here | over the user `.env` |
| `$DSH_HOME/.env` | `user-env` | not here | otherwise |
The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back.
The launching environment wins because a per-run override (`DEEPSEEK_API_KEY=… dsh`, a CI secret, a container `-e`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see.
Everything below it loses to the managed store, so a key written by the Models page takes effect immediately even when an older key sits in a `.env`. Those two layers still resolve when nothing is stored, and `describe()` names them `project-env` or `user-env` with `writable: true` — storing a key replaces them as the effective source.
Under the product CLI, resolution reads the launcher's frozen [environment snapshot](../../util/environment/README.md) rather than `process.env`: only the snapshot can say whether a value came from the launching shell or from a file. A composition the product CLI did not boot has the inherited environment as its only layer, which keeps embedders on the semantics they already had.
## Config
| Field | Default | Meaning |
|---|---|---|
| `path` | `<harness home>/.env` | Credentials document location. |
| `path` | `<harness home>/.credentials.yaml` | Credentials document location. |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. |
| `watch` | `true` | Hot-publish external edits. |
| `debounceMs` | `100` | Watcher write-settle window. |
## The document
dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten.
A YAML mapping of credential reference to value, and nothing else:
Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule.
```yaml
DEEPSEEK_API_KEY: sk-…
OPENAI_API_KEY: sk-…
```
The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the secret I stored has no effect". A non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload. There is no `version` field and no wrapper level; the format is the mapping.
Writes patch the parsed document rather than rebuilding it, so comments and the formatting of every untouched entry survive. A comment directly above an entry is that entry's annotation and is removed with it. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. An on-disk document that no longer parses fails the write instead of overwriting content the provider could not understand.
Any string value round-trips, multi-line values included, so no entry is unwritable for want of a quoting style. An empty stored value is absent, per the seam rule — which is why an empty string in the document is rejected outright: `unset` removes a key, it does not blank it.
## Permissions
The provider creates the directory `0700` and creates or atomically replaces the document `0600`. It holds what it *reads* to that same bound: on POSIX a document carrying any group or other permission bit fails before its contents are parsed — at boot and on every reload — and the error names the `chmod 600` repair. Windows has no mode to inspect, so the check is skipped there rather than faked.
## Hot reload
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address.
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
## Security boundary
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)), so reaching the value takes a deliberate read of a path the agent was not given.
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)) so reaching the value takes a deliberate read of a path the agent was not given.
That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package.
@@ -46,9 +65,7 @@ No direct invalidation; credentials never enter a request prefix.
## Known Limitations and Deferred Work
- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly.
- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check.
- **A same-UID process can read the document** — see [Security boundary](#security-boundary): the file-effect sandbox modes do not deny reads, and an OS-keychain provider is deferred.
- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format.
- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there.
- **Environment changes are invisible** — the snapshot is frozen at launch, so a variable exported after startup reaches neither resolution nor `describe`; changing an environment-sourced credential takes a restart.
- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.

View File

@@ -2,37 +2,56 @@
[English](README.md) | 中文
文件型[凭据](../credentials/README.md)提供方:层来源,一条诚实的优先级。
文件型[凭据](../credentials/README.md)提供方:层来源,一条诚实的优先级。
| 层 | 来源 id | 可写 | 优先 |
|---|---|---|---|
| 当前进程环境 | `env` | 否 | 始终优先 |
| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset` | 其余情况 |
| 继承的进程环境 | `env` | 否 | 始终优先 |
| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset` | 高于两个 `.env` |
| `<invocation cwd>/.env` | `project-env` | 不在此处 | 高于用户 `.env` |
| `$DSH_HOME/.env` | `user-env` | 不在此处 | 其余情况 |
环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false``set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。
启动环境优先,因为按次覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false``set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。
它之下的一切都输给受管存储,因此 Models 页写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会解析,`describe()` 会把来源报告为 `project-env``user-env``writable: true`——存入一个密钥就会取代它们成为生效来源。
在产品 CLI命令行界面解析读取的是启动器冻结的[环境快照](../../util/environment/README.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。
## 配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| `path` | `<harness home>/.env` | 凭据文档位置。 |
| `path` | `<harness home>/.credentials.yaml` | 凭据文档位置。 |
| `dshHome` | `$DSH_HOME``~/.dsh` | `path` 缺省时使用的 harness home。 |
| `watch` | `true` | 热发布外部编辑。 |
| `debounceMs` | `100` | watcher 写入稳定窗口。 |
## 文档本身
dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行注释、无关行、CRLF 行尾,以及另一个键的带引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。
一个从凭据引用到值的 YAML mapping除此之外别无他物
值按 dotenv 能逐字读回的最窄样式渲染——裸值其次单引号完全字面再次双引号仅限无反斜杠双引号读取会展开转义。任何样式都无法表示的值以及已经跨越多个物理行的条目都会明确报错而不是被静默破坏。空的存储值等于不存在seam 规则)。
```yaml
DEEPSEEK_API_KEY: sk-…
OPENAI_API_KEY: sk-…
```
该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。
写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖提供方读不懂的内容。
任何字符串值都能往返包括多行值因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。
## 权限
提供方以 `0700` 创建目录,以 `0600` 创建或原子替换文档。它对*读取*同样守住这条界线:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在解析其内容之前失败——启动时与每次 reload 都检查——并在错误里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode因此在那里跳过该检查而不是伪造它。
## 热重载
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则明确报错。非 POSIX 标识符的键属于被保留的文件内容seam 无法寻址。
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
## 安全边界
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程bash、文件系统工具以同一用户身份运行而已交付的 `workspace-write` 文件策略限制的是修改而非读取因此它们读这个文件与读该用户拥有的任何其他文件毫无二致也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles)因此要拿到这个值,需要刻意去读一条并未交给 agent智能体的路径。
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程bash、文件系统工具以同一用户身份运行而已交付的 `workspace-write` 文件策略限制的是修改而非读取因此它们读这个文件与读该用户拥有的任何其他文件毫无二致也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles)——因此要拿到这个值,需要刻意去读一条并未交给 agent智能体的路径。
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到OS 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。
@@ -46,9 +65,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一
## 已知限制与暂缓事项
- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。
- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。
- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary)文件效果沙箱模式不会拒绝读取OS 钥匙串提供方仍是延后项。
- **无法表示的值明确报错**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返
- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。
- **环境变化不可见**:快照在启动时冻结,因此启动之后 export 的变量既不会进入解析,也不会进入 `describe`;要更换来自环境的凭据需要重启
- **原子但不具备崩溃持久性**——继承自 `dsh-atomic-write`;存储在启动时重新读取。

View File

@@ -27,18 +27,20 @@
"peerDependencies": {
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"chokidar": "^4.0.3",
"dotenv": "^17.2.0",
"schemastery": "^3.18.0"
"schemastery": "^3.18.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -1,30 +1,58 @@
/**
* File-backed credentials provider layering the live process environment over
* a `$DSH_HOME/.env` document. The environment is authoritative and read-only
* (a launch-time override must win, and must be visibly read-only rather than
* silently shadow writes); the file is the provider-managed writable source:
* every write re-reads the document under a cross-process writer lock before
* rewriting only its own line — preserving every other byte, physical line
* endings and quoted multi-line values included — external edits hot-publish
* through the seam, and each reload replaces the snapshot wholesale so a
* deleted entry never lingers in memory.
* File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
* against the environment by how much each layer is trusted:
*
* ```text
* inherited process environment (read-only, wins)
* > $DSH_HOME/.credentials.yaml (provider-managed, writable)
* > <invocation cwd>/.env (read-only fallback)
* > $DSH_HOME/.env (read-only fallback)
* ```
*
* The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
* secret, or a container `-e` is this run's explicit intent; it cannot be
* edited from inside, so it must be *visibly* read-only rather than silently
* shadow writes. Everything below it loses to the managed store, so a key the
* Models page writes takes effect immediately even when an older key sits in
* the user's `.env`.
*
* The invoking project may supply a key, because the product trusts the
* project it is launched in. It ranks below the managed store, so a key stored
* through the Models page is never displaced by one a checkout happens to carry.
*
* The file is the provider-managed writable source: every write re-reads the
* document under a cross-process writer lock before patching only its own key
* — comments and the formatting of every untouched entry survive — external
* edits hot-publish through the seam, and each reload replaces the snapshot
* wholesale so a deleted entry never lingers in memory.
*
* The document holds nothing but credentials, which is why it is a strict
* `CredentialRef`-to-string mapping rather than a dotenv file: a store the
* Harness owns and never materializes into the environment cannot also serve
* as the user's environment layer, and conflating the two is what made a
* non-secret in the old `$DSH_HOME/.env` silently unreachable.
* @module @deepseek-ai/dsh-credentials-local
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile } from 'node:fs/promises'
import { mkdir, readFile, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { parse } from 'dotenv'
import { Document, parseDocument, type YAMLError } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment'
/** Basename of the credentials document inside the harness home. */
export const CREDENTIALS_FILENAME = '.credentials.yaml'
/** Plugin config: file location and hot-reload behavior. */
export interface Config {
/** Credentials document path; defaults to `.env` under the harness home. */
/** Credentials document path; defaults to `.credentials.yaml` under the harness home. */
path?: string
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
@@ -43,146 +71,135 @@ interface ResolvedSpec {
/**
* Resolve the runtime spec from plugin config: an explicit `path` wins,
* otherwise the document lives at `<harness home>/.env`.
* otherwise the document lives at `<harness home>/.credentials.yaml`.
* @param config - raw plugin config.
* @returns the resolved file location and watch behavior.
*/
export function resolveSpec(config: Config): ResolvedSpec {
return {
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')),
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)),
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
}
/** Permission bits outside the owner; a credentials document must have none of them. */
const GROUP_OTHER_BITS = 0o077
/**
* Reject a credentials document other OS users can read, before its contents
* are read at all. The provider creates and replaces the file at `0600`, but a
* hand-written or externally generated one carries whatever umask produced it,
* and silently serving secrets out of a world-readable file would make the
* mode the provider promises meaningless.
*
* POSIX only: Windows has no mode to inspect — its ACLs are not expressible
* here — so the check is skipped rather than faked, and the file's protection
* there is whatever the create and replace APIs express.
* @param filename - absolute path of the document.
* @throws when the file exists with group or other permission bits set.
*/
async function assertOwnerOnly(filename: string): Promise<void> {
/* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */
if (process.platform === 'win32') return
let mode: number
try {
mode = (await stat(filename)).mode
} catch (error) {
if (!isENOENT(error)) throw error
return
}
const offending = mode & GROUP_OTHER_BITS
if (offending === 0) return
throw new Error(
`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});`
+ ` run "chmod 600 ${filename}" before starting again`,
)
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Values that survive a dotenv round-trip without quoting. */
const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/
/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */
function hasControlCharacters(value: string): boolean {
for (const char of value) {
if (char.charCodeAt(0) < 0x20) return true
}
return false
/**
* Describe one YAML parse failure without quoting the source. The parser's own
* message embeds the offending line, which here holds a secret.
* @param error - the parser's error.
* @returns the error code with its line and column.
*/
function describeYamlError(error: YAMLError): string {
const at = error.linePos?.[0]
/* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`
return `${error.code}${where}`
}
/**
* Render one `KEY=value` line in the narrowest style dotenv reads back
* verbatim: bare, then single quotes (fully literal), then double quotes
* (safe only without backslashes, which double-quote reading expands).
* A value no style can represent fails loud instead of corrupting silently.
* Parse one credentials document into its entries. The document is a strict
* mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
* key that is not a POSIX identifier, a non-string value, and an empty string
* are all rejected rather than skipped, because this file holds nothing but
* credentials and a silently ignored entry reads as "the key I stored has no
* effect". Duplicate keys surface as parser errors. An empty document is an
* empty store.
* @param text - the document's text.
* @param filename - absolute path, quoted in errors.
* @returns the parsed entries, keyed by reference.
*/
function renderLine(ref: CredentialRef, value: string): string {
if (BARE_VALUE.test(value)) return `${ref}=${value}`
if (hasControlCharacters(value)) {
throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`)
export function parseCredentialsDocument(text: string, filename: string): Map<string, string> {
// `prettyErrors` is on only for `linePos`; `error.message` is never used,
// because the parser quotes the offending source line and in this document
// that line is a secret. Only the code and position leave this function, and
// the same rule governs every other diagnostic here — a key name is safe to
// print, a value is not.
const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
if (document.errors.length > 0) {
throw new Error(`credentials-local: invalid document at ${filename}: ${
document.errors.map(describeYamlError).join('; ')}`)
}
if (!value.includes('\'')) return `${ref}='${value}'`
if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"`
throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`)
}
/** Split text into physical lines with their terminators attached. */
function physicalLines(text: string): string[] {
return text.length === 0 ? [] : text.split(/(?<=\n)/)
}
/** One physical line's content without its terminator. */
function lineContent(line: string): string {
if (line.endsWith('\r\n')) return line.slice(0, -2)
if (line.endsWith('\n')) return line.slice(0, -1)
return line
}
/** One physical line's terminator (empty on a final unterminated line). */
function lineTerminator(line: string): string {
return line.slice(lineContent(line).length)
}
/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/
/** Quote characters dotenv reads across physical lines. */
const MULTILINE_QUOTES = ['\'', '"', '`']
/**
* The quote character an assignment's value part opens without closing on its
* own line — the following physical lines are that value's continuation, not
* assignments — or `undefined` for a single-line value.
*/
function opensMultiline(valuePart: string): string | undefined {
const trimmed = valuePart.trimStart()
const quote = trimmed[0]
if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined
const rest = trimmed.slice(1)
const body = quote === '"' ? rest.replaceAll('\\"', '') : rest
return body.includes(quote) ? undefined : quote
}
/** Whether a continuation line closes the given quote. */
function closesQuote(content: string, quote: string): boolean {
const body = quote === '"' ? content.replaceAll('\\"', '') : content
return body.includes(quote)
const root: unknown = document.toJS() ?? {}
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`)
}
const entries = new Map<string, string>()
for (const [key, value] of Object.entries(root as Record<string, unknown>)) {
// credentialRef throws on anything that is not a POSIX identifier, which
// is exactly the constraint a stored reference must satisfy to be
// addressable through the seam.
credentialRef(key)
// The key name is quoted, never the value: a wrong-typed entry is still a
// secret the user meant to store.
if (typeof value !== 'string') {
throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`)
}
if (value.length === 0) {
throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`)
}
entries.set(key, value)
}
return entries
}
/**
* Replace, insert, or delete one reference's assignment while preserving
* every other byte: untouched lines keep their exact content and terminators
* (CRLF included), and the physical lines inside another key's quoted
* multi-line value are never mistaken for assignments. The first matching
* assignment is rewritten in place with its own line ending; later duplicates
* drop (dotenv reads the last one, so a surviving duplicate would override
* the edit); an insert appends in the document's dominant ending style.
* Render the next document text with one reference set or deleted. Editing
* the parsed document rather than rebuilding it keeps comments and the
* formatting of every untouched entry; an absent document starts a fresh one.
* @param text - the current document text, `undefined` while the file is absent.
* @param ref - the reference to write.
* @param value - the new value, or `undefined` to delete the key.
* @returns the text to persist.
*/
function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string {
const lines = physicalLines(text ?? '')
const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n'
const out: string[] = []
let placed = false
let pendingQuote: string | undefined
for (const line of lines) {
const content = lineContent(line)
if (pendingQuote !== undefined) {
// Inside a quoted multi-line value: never an assignment, always kept.
if (closesQuote(content, pendingQuote)) pendingQuote = undefined
out.push(line)
continue
}
const match = ASSIGNMENT.exec(content)
if (match === null) {
out.push(line)
continue
}
const [, key, valuePart] = match
if (key !== ref) {
/* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */
pendingQuote = opensMultiline(valuePart ?? '')
out.push(line)
continue
}
// The write path refuses multi-line targets before rendering, so the
// matched assignment is single-line and drops or rewrites wholesale.
if (rendered !== undefined && !placed) {
out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`)
placed = true
}
}
if (rendered !== undefined && !placed) {
const last = out[out.length - 1]
if (last !== undefined && lineTerminator(last) === '') {
out[out.length - 1] = `${last}${dominant}`
}
out.push(`${rendered}${dominant}`)
}
return out.join('')
function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string {
// `text` only ever caches content that parsed successfully, so this re-parse
// for the mutable comment-preserving tree cannot fail.
const document = text === undefined ? new Document({}) : parseDocument(text)
if (value === undefined) document.deleteIn([ref])
else document.setIn([ref], value)
return document.toString()
}
/** File-backed credentials provider (`$DSH_HOME/.env`). */
/** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
export class CredentialsLocal extends Credentials {
/* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
settings-local (prefer symmetry for parallel values); extracting the shared
@@ -225,6 +242,22 @@ export class CredentialsLocal extends Credentials {
this.spec = resolveSpec(config)
}
/** The inherited-environment value for a reference, or `undefined` when empty or unset. */
private inherited(ref: CredentialRef): string | undefined {
const entry = environmentOf(this.ctx).getFrom(ref, ['process'])
return entry !== undefined && entry.value.length > 0 ? entry.value : undefined
}
/**
* The `.env` fallback for a reference — below the managed store, never above
* it. The invoking project ranks over the user's home file, matching the
* environment layering: the more specific location wins.
*/
private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined {
const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env'])
return entry !== undefined && entry.value.length > 0 ? entry : undefined
}
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
yield async () => {
// Drain: refuse new operations, then settle the queued ones so disposal
@@ -270,24 +303,26 @@ export class CredentialsLocal extends Credentials {
}
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
const env = process.env[ref]
if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' })
const inherited = this.inherited(ref)
if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' })
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' })
if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
const fallback = this.dotenvFallback(ref)
if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source })
return Promise.resolve(undefined)
}
override describe(ref: CredentialRef): Promise<CredentialInfo> {
const env = process.env[ref]
if (env !== undefined && env.length > 0) {
// Only the inherited environment is unwritable: it is the one layer this
// process cannot edit. A user `.env` value is writable in the sense that
// matters — storing a key replaces it as the effective one.
if (this.inherited(ref) !== undefined) {
return Promise.resolve({ configured: true, source: 'env', writable: false })
}
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) {
// A quoted multi-line value resolves fine but the line editor refuses to
// rewrite it, so writability must say what set() would actually do.
return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') })
}
if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
const fallback = this.dotenvFallback(ref)
if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true })
return Promise.resolve({ configured: false, writable: true })
}
@@ -350,12 +385,7 @@ export class CredentialsLocal extends Credentials {
await this.reconcileFromDisk()
const existing = this.values.get(ref)
if (value === undefined && existing === undefined) return
if (existing !== undefined && existing.includes('\n')) {
throw new Error(
`credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`,
)
}
const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value))
const nextText = renderDocument(this.text, ref, value)
// 0600: a document holding secrets is never world-readable.
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
this.text = nextText
@@ -368,19 +398,27 @@ export class CredentialsLocal extends Credentials {
})
}
/** Reject a write the live environment would shadow into apparent no-effect. */
/**
* Reject a write the inherited environment would shadow into apparent
* no-effect. Only that layer can shadow a write: everything else this
* provider resolves ranks below the document being written.
*/
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
const env = process.env[ref]
if (env !== undefined && env.length > 0) {
if (this.inherited(ref) !== undefined) {
throw new Error(
`credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be`
+ ' shadowed; change the launching environment instead',
`credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be`
+ ' shadowed; unset it in the shell you start dsh from instead',
)
}
}
/** Boot read: an absent file is an empty store; any other failure is loud. */
/**
* Boot read: an absent file is an empty store; an invalid one fails the
* plugin's activation, because a credentials document that exists but
* cannot be trusted must never be treated as "no credentials stored".
*/
private async loadInitial(): Promise<void> {
await assertOwnerOnly(this.spec.filename)
let text: string
try {
text = await readFile(this.spec.filename, 'utf8')
@@ -388,8 +426,8 @@ export class CredentialsLocal extends Credentials {
if (!isENOENT(error)) throw error
return
}
this.values = parseCredentialsDocument(text, this.spec.filename)
this.text = text
this.values = new Map(Object.entries(parse(text)))
}
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
@@ -415,12 +453,15 @@ export class CredentialsLocal extends Credentials {
/**
* Compare the on-disk text against the cache and publish any difference
* into the seam. Absence publishes the empty store; an unreadable file
* throws, so each caller picks its policy — a reload warns and keeps the
* last good snapshot, a write fails loud. dotenv parsing is lenient by
* design and cannot fail.
* into the seam. Absence publishes the empty store; an unreadable or
* invalid document throws, so each caller picks its policy — a reload warns
* and keeps the last good snapshot, a write fails loud rather than
* overwriting a document it could not understand.
*/
private async reconcileFromDisk(): Promise<void> {
// Re-checked on every reload and before every write: an external editor or
// a restored backup can loosen the mode after boot.
await assertOwnerOnly(this.spec.filename)
let text: string | undefined
try {
text = await readFile(this.spec.filename, 'utf8')
@@ -429,7 +470,7 @@ export class CredentialsLocal extends Credentials {
text = undefined
}
if (text === this.text || this.isClosed()) return
const next = text === undefined ? new Map<string, string>() : new Map(Object.entries(parse(text)))
const next = text === undefined ? new Map<string, string>() : parseCredentialsDocument(text, this.spec.filename)
const changed = this.changedRefs(this.values, next)
this.text = text
this.values = next
@@ -437,21 +478,12 @@ export class CredentialsLocal extends Credentials {
}
/* jscpd:ignore-end */
/** Seam-addressable entries whose effective (non-empty) value changed. */
/** Entries whose stored value changed; the parser has already proven every key addressable. */
private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
const changed: CredentialRef[] = []
for (const key of new Set([...prev.keys(), ...next.keys()])) {
const before = prev.get(key)
const after = next.get(key)
const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined
const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined
if (effectiveBefore === effectiveAfter) continue
try {
changed.push(credentialRef(key))
} catch (_unaddressableKey) {
// A key that is not a POSIX identifier is preserved file content the
// seam cannot address, so no observer could ever see it change.
}
if (prev.get(key) === next.get(key)) continue
changed.push(credentialRef(key))
}
return changed
}

View File

@@ -42,7 +42,7 @@ describe('write-drain teardown', () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await fiber
const service = ctx.credentials

View File

@@ -4,9 +4,15 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-environment'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
function writeCredentials(file: string, text: string): Promise<void> {
return writeFile(file, text, { mode: 0o600 })
}
const KEY = credentialRef('DSH_CRED_TEST')
const OTHER = credentialRef('DSH_CRED_OTHER')
@@ -42,29 +48,29 @@ function updates(ctx: Context): CredentialRef[] {
}
describe('resolveSpec', () => {
it('defaults to .env under the harness home with watching on', () => {
it('defaults to .credentials.yaml under the harness home with watching on', () => {
const spec = resolveSpec({ dshHome: '/custom/home' })
expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 })
expect(spec).toEqual({ filename: resolve('/custom/home/.credentials.yaml'), watch: true, debounceMs: 100 })
})
it('lets an explicit path win over the home', () => {
const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 })
expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 })
const spec = resolveSpec({ path: '/etc/dsh/creds.yaml', dshHome: '/ignored', watch: false, debounceMs: 5 })
expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.yaml'), watch: false, debounceMs: 5 })
})
})
describe('layering and reads', () => {
it('treats an absent file as an empty writable store', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
})
it('serves file entries, including export-prefixed and quoted values', async () => {
it('serves file entries alongside comments and quoted values', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n')
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' })
@@ -73,22 +79,22 @@ describe('layering and reads', () => {
it('lets a non-empty process environment win read-only over the file', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=from-file\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_TEST: from-file\n')
const ctx = await boot({ path, watch: false })
vi.stubEnv('DSH_CRED_TEST', 'from-env')
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
})
it('treats empty values as absent in both layers', async () => {
it('treats an empty environment value as absent, falling through to the file', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
const ctx = await boot({ path, watch: false })
vi.stubEnv('DSH_CRED_TEST', '')
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
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 () => {
@@ -100,110 +106,280 @@ describe('layering and reads', () => {
})
})
describe('line-editing writes', () => {
it('appends a missing key to a fresh 0600 document and emits the commit', async () => {
describe('layer ladder', () => {
// inherited process env > .credentials.yaml > $DSH_HOME/.env, and the
// invoking directory's .env supplies no credential at all.
async function bootLayered(
path: string,
layers: Parameters<typeof createEnvironmentSnapshot>[0],
): Promise<Context> {
const ctx = new Context()
ctx.provide(DSH_ENVIRONMENT_KEY, createEnvironmentSnapshot(layers))
const fiber = ctx.plugin(CredentialsLocal, { path, watch: false })
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
const ctx = await bootLayered(path, [
{ source: 'process', values: {} },
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } },
])
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
// The old dead end is gone: a key sitting in the user's .env no longer
// makes the stored one unwritable.
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' })
})
it('serves the user .env only when nothing is stored', async () => {
const dir = await tempDir()
const ctx = await bootLayered(join(dir, '.credentials.yaml'), [
{ source: 'process', values: {} },
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
])
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-user-env', source: 'user-env' })
// Writable: storing a key replaces it as the effective one.
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true })
})
it('serves the invoking project .env over the user one, but never over the store', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
// The product trusts the project it is launched in, so a checkout may
// carry its own key — ranked above the user's home file (more specific
// wins) and below the managed store, which a stored key must never lose to.
const layers = [
{ source: 'process' as const, values: {} },
{ source: 'project-env' as const, path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } },
{ source: 'user-env' as const, path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user' } },
]
const bare = await bootLayered(path, layers)
expect(await bare.credentials.resolve(KEY)).toEqual({ value: 'from-project', source: 'project-env' })
expect(await bare.credentials.describe(KEY)).toEqual({ configured: true, source: 'project-env', writable: true })
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
const stored = await bootLayered(path, layers)
expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
})
it('refuses a document other OS users can read', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 })
const ctx = new Context()
// Before the contents are read at all: serving secrets out of a
// world-readable file would make the 0600 the provider writes meaningless.
await expect(ctx.plugin(CredentialsLocal, { path, watch: false }))
.rejects.toThrow(/readable beyond its owner \(mode 644\)/)
})
it('propagates a permission check that fails for a reason other than absence', async () => {
const dir = await tempDir()
const notADirectory = join(dir, 'occupied')
await writeFile(notADirectory, 'a regular file\n')
// An absent document is an empty store, but a path that cannot be
// reached at all is a misconfiguration: the parent is a file, so the
// check fails with ENOTDIR rather than concluding "no credentials yet".
const ctx = new Context()
await expect(ctx.plugin(CredentialsLocal, { path: join(notADirectory, '.credentials.yaml'), watch: false }))
.rejects.toThrow(/ENOTDIR/)
})
it('propagates a read that fails for a reason other than absence', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
// Owner-only, so the permission check passes, and unreadable as a file:
// the store is present but cannot be parsed, which must fail the launch
// rather than silently serve nothing.
await mkdir(path, { mode: 0o700 })
const ctx = new Context()
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(/EISDIR/)
})
it('lets only the inherited environment shadow the store, read-only', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
const ctx = await bootLayered(path, [
{ source: 'process', values: { DSH_CRED_TEST: 'from-shell' } },
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
])
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-shell', source: 'env' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/launching environment/)
})
})
describe('document validation', () => {
// Every rejection below is a boot failure rather than a skipped entry: this
// document holds nothing but credentials, so an ignored key would read as
// "the secret I stored has no effect".
it.each([
['a non-mapping root', 'just a string\n', /must be a mapping/],
['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/],
['a key that is not a POSIX identifier', 'not-a-ref: value\n', /credential ref/],
['a non-string value', 'DSH_CRED_TEST: 123\n', /must be a string/],
['an empty value', 'DSH_CRED_TEST: ""\n', /is empty/],
['duplicate keys', 'DSH_CRED_TEST: one\nDSH_CRED_TEST: two\n', /invalid document/],
['malformed yaml', 'DSH_CRED_TEST: "unterminated\n', /invalid document/],
])('fails boot on %s', async (_case, text, message) => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, text)
const ctx = new Context()
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message)
})
it('never puts a credential value in a diagnostic', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
const secret = 'sk-live-DO-NOT-LOG-abcdef123456'
// The yaml parser's own message quotes the offending source line, which in
// this document is the secret itself. Boot stderr and the watcher's logger
// both receive whatever this throws.
await writeCredentials(path, `DSH_CRED_TEST: "${secret}\n`)
let failure: unknown
try {
await new Context().plugin(CredentialsLocal, { path, watch: false })
} catch (error) {
failure = error
}
expect(String(failure)).toMatch(/invalid document/)
// The position survives; the line's contents do not.
expect(String(failure)).toMatch(/line 2, column 1/)
expect(String(failure)).not.toContain(secret)
expect((failure as Error).stack ?? '').not.toContain(secret)
})
it('reads an empty document as an empty store', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, '# nothing stored yet\n')
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
})
})
describe('document writes', () => {
it('adds a missing key to a fresh 0600 document and emits the commit', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
const seen = updates(ctx)
await ctx.credentials.set(KEY, 'sk-fresh')
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n')
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n')
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' })
expect(seen).toEqual([KEY])
})
it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => {
it('patches one entry, preserving comments and every untouched entry', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n')
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(KEY, 'new value!')
expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n')
expect(await readFile(path, 'utf8')).toBe(
'# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: new value!\n',
)
})
it('quotes hostile values so they round-trip through a fresh provider', async () => {
it('round-trips values no dotenv line could represent', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
const singleQuoted = 'with "quote", back\\slash and space'
const doubleQuoted = "it's got an apostrophe"
await ctx.credentials.set(KEY, singleQuoted)
await ctx.credentials.set(OTHER, doubleQuoted)
const multiLine = 'line one\nline two'
const mixedQuotes = 'both \' and "'
await ctx.credentials.set(KEY, multiLine)
await ctx.credentials.set(OTHER, mixedQuotes)
const reread = await boot({ path, watch: false })
expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' })
expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' })
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 })
})
it('fails loud on values no .env quoting style reads back verbatim', async () => {
it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/)
await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
})
it('unsets only the owning line and keeps an absent unset silent', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n')
const path = join(dir, '.credentials.yaml')
// Comments above an entry are that entry's annotation and go with it when
// it is removed — including anything above the document's first entry.
// Every other entry keeps its own comments.
await writeCredentials(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n')
const ctx = await boot({ path, watch: false })
const seen = updates(ctx)
await ctx.credentials.unset(KEY)
expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n')
expect(await readFile(path, 'utf8')).toBe('# about the survivor\nDSH_CRED_OTHER: stays\n')
await ctx.credentials.unset(KEY)
expect(seen).toEqual([KEY])
})
it('rejects empty values, shadowed writes, and multi-line entries', async () => {
it('rejects empty values and writes the environment would shadow', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
const ctx = await boot({ path, watch: false })
await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/)
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/)
vi.stubEnv('DSH_CRED_TEST', 'shadowing')
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/)
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/)
})
it('leaves an empty document after unsetting the only entry', async () => {
it('leaves an empty mapping after unsetting the only entry', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=only\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_TEST: only\n')
const ctx = await boot({ path, watch: false })
await ctx.credentials.unset(KEY)
expect(await readFile(path, 'utf8')).toBe('')
expect(await readFile(path, 'utf8')).toBe('{}\n')
// The emptied document still reloads as an empty store, not a parse error.
const reread = await boot({ path, watch: false })
expect(await reread.credentials.resolve(KEY)).toBeUndefined()
})
it('fails a write loud when the on-disk document became invalid', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
// An external editor left the document unparsable: the read-modify-write
// must refuse rather than overwrite content it cannot understand.
await writeCredentials(path, 'DSH_CRED_TEST: "unterminated\n')
await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/)
})
it('chains past a rejected write so one bad value cannot poison the queue', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
const bad = expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
const good = ctx.credentials.set(OTHER, 'lands')
await bad
await good
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n')
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER: lands\n')
})
it('serializes concurrent writes so both land in the one document', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
await Promise.all([
ctx.credentials.set(KEY, 'one'),
ctx.credentials.set(OTHER, 'two'),
])
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n')
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: one\nDSH_CRED_OTHER: two\n')
})
it('refuses writes after disposal', async () => {
const dir = await tempDir()
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await fiber
// Capture the handle first: disposal also removes the ctx.credentials service.
const service = ctx.credentials
@@ -215,20 +391,20 @@ describe('line-editing writes', () => {
describe('real hot reload', () => {
it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
// Watching starts on an existing document: creation racing watcher setup
// is a chokidar readiness gap, not the reload contract under test.
await writeFile(path, 'DSH_CRED_TEST=boot\n')
await writeCredentials(path, 'DSH_CRED_TEST: boot\n')
const ctx = await boot({ path, debounceMs: 10 })
const seen = updates(ctx)
await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n')
await writeCredentials(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
})
// Wholesale replacement: an entry deleted on disk never lingers in memory.
await writeFile(path, 'DSH_CRED_TEST=live\n')
await writeCredentials(path, 'DSH_CRED_TEST: live\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
})

View File

@@ -1,7 +1,7 @@
// Third-review behaviors: read-modify-write under the writer lock (external
// edits survive an API write), the contained credentials/updated fan-out (a
// broken observer never fails a committed write), and the physical-line
// editor's multi-line and CRLF discipline.
// broken observer never fails a committed write), and the YAML document
// editor's isolation between entries.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
@@ -10,6 +10,11 @@ import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
function writeCredentials(file: string, text: string): Promise<void> {
return writeFile(file, text, { mode: 0o600 })
}
const ALPHA = credentialRef('DSH_REVIEW_ALPHA')
const BETA = credentialRef('DSH_REVIEW_BETA')
const INNER = credentialRef('DSH_REVIEW_INNER')
@@ -37,18 +42,18 @@ async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]):
describe('read-modify-write', () => {
it('folds an unobserved external edit into a write instead of overwriting it', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => { seen.push(ref) })
await ctx.credentials.set(ALPHA, 'one')
// The external edit has landed on disk but no watcher reported it (watch
// is off — the same blind spot as a debounce window or a missed event).
await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`)
await writeCredentials(path, `${ALPHA}: one\n${BETA}: external\n`)
await ctx.credentials.set(ALPHA, 'two')
const text = await readFile(path, 'utf8')
expect(text).toContain(`${BETA}=external`)
expect(text).toContain(`${ALPHA}=two`)
expect(text).toContain(`${BETA}: external`)
expect(text).toContain(`${ALPHA}: two`)
// The fold published the unobserved entry before the write's own commit.
expect(seen).toEqual([ALPHA, BETA, ALPHA])
expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' })
@@ -56,7 +61,7 @@ describe('read-modify-write', () => {
it('keeps both refs when two providers write the same document concurrently', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const first = await boot({ path, watch: false })
const second = await boot({ path, watch: false })
await Promise.all([
@@ -71,7 +76,7 @@ describe('read-modify-write', () => {
it('creates the credentials directory owner-only', async () => {
const dir = await tempDir()
const home = join(dir, 'home')
const ctx = await boot({ path: join(home, '.env'), watch: false })
const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false })
await ctx.credentials.set(ALPHA, 'one')
expect((await stat(home)).mode & 0o777).toBe(0o700)
})
@@ -80,7 +85,7 @@ describe('read-modify-write', () => {
describe('contained update fan-out', () => {
it('does not fail a committed set when a listener throws, and later listeners still run', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
ctx.on('credentials/updated', () => {
throw new Error('observer boom')
})
@@ -93,7 +98,7 @@ describe('contained update fan-out', () => {
it('contains an async listener rejection', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
// An unknown-returning function keeps the typed surface legal while the
// runtime value is still the rejected promise the containment must handle.
const boom = (): unknown => Promise.reject(new Error('async observer boom'))
@@ -104,7 +109,7 @@ describe('contained update fan-out', () => {
it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
ctx.on('credentials/updated', () => {
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
@@ -114,78 +119,33 @@ describe('contained update fan-out', () => {
await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/)
// Harness-fatal by design — but the write itself committed first.
expect(second).toHaveBeenCalledWith(ALPHA)
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`)
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}: one`)
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
})
})
describe('physical-line editor', () => {
it('never mistakes a quoted multi-line continuation for an assignment', async () => {
describe('document editor', () => {
it('leaves a sibling multi-line value untouched while patching one entry', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n`
await writeFile(path, wrapped)
const path = join(dir, '.credentials.yaml')
const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: a\n`
await writeCredentials(path, wrapped)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
// The wrapped value survives byte-for-byte; only ALPHA's line changed.
const afterAlpha = await readFile(path, 'utf8')
expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`)
// Setting the inner-looking ref appends a real assignment; the
// continuation line inside the quoted value stays untouched.
await ctx.credentials.set(INNER, 'real')
const afterInner = await readFile(path, 'utf8')
expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`)
expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' })
expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`)
expect(await ctx.credentials.resolve(credentialRef('DSH_REVIEW_WRAPPED')))
.toEqual({ value: 'line1\nline2', source: 'file' })
})
it('preserves CRLF line endings on untouched and edited lines', async () => {
it('stores a value that looks like another entry without creating one', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`)
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`)
await ctx.credentials.set(INNER, 'new')
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`)
})
it('terminates a final unterminated line before appending', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}=a`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(BETA, 'b')
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`)
})
it('rewrites a final unterminated assignment in the dominant ending style', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}=a`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`)
})
it('tracks a single-quoted multi-line value through its continuation', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'x')
expect(await readFile(path, 'utf8'))
.toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`)
})
it('reports a multi-line entry as unwritable and refuses to edit it', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}="line1\nline2"\n`)
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false })
await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/)
await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/)
// Resolution still serves the multi-line value.
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' })
// The stored text must stay a value: a quoted-scalar write that leaked its
// own structure would silently mint a credential nobody stored.
await ctx.credentials.set(ALPHA, `${INNER}: injected`)
const reread = await boot({ path, watch: false })
expect(await reread.credentials.resolve(ALPHA)).toEqual({ value: `${INNER}: injected`, source: 'file' })
expect(await reread.credentials.resolve(INNER)).toBeUndefined()
})
})

View File

@@ -6,6 +6,11 @@ import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
function writeCredentials(file: string, text: string): Promise<void> {
return writeFile(file, text, { mode: 0o600 })
}
// chokidar is the nondeterministic OS boundary: faking it lets these tests
// drive the event pipeline (error events, races with unreadable files)
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
@@ -66,21 +71,21 @@ async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]):
describe('watcher pipeline', () => {
it('clamps the write-settle poll interval for a zero debounce', async () => {
const dir = await tempDir()
await boot({ path: join(dir, '.env'), debounceMs: 0 })
await boot({ path: join(dir, '.credentials.yaml'), debounceMs: 0 })
const [instance] = await fakeInstances()
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
})
it('survives a watcher error and keeps publishing later edits', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
await writeFile(path, 'DSH_CRED_PIPE=arrived\n')
await writeCredentials(path, 'DSH_CRED_PIPE: arrived\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
@@ -89,8 +94,8 @@ describe('watcher pipeline', () => {
it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=good\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_PIPE: good\n')
const ctx = await boot({ path, debounceMs: 5 })
await chmod(path, 0o000)
@@ -104,7 +109,7 @@ describe('watcher pipeline', () => {
it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, debounceMs: 5 })
let arm = true
ctx.on('credentials/updated', () => {
@@ -113,7 +118,7 @@ describe('watcher pipeline', () => {
})
const [instance] = await fakeInstances()
await writeFile(path, 'DSH_CRED_PIPE=first\n')
await writeCredentials(path, 'DSH_CRED_PIPE: first\n')
instance!.watcher.emit('all', 'change', path)
// The snapshot commits before the fan-out, so the value lands even though
// the listener threw out of the refresh.
@@ -122,7 +127,7 @@ describe('watcher pipeline', () => {
})
arm = false
await writeFile(path, 'DSH_CRED_PIPE=second\n')
await writeCredentials(path, 'DSH_CRED_PIPE: second\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
@@ -131,8 +136,8 @@ describe('watcher pipeline', () => {
it('quiesces the refresh pipeline before dispose completes', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=initial\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_PIPE: initial\n')
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
await fiber
@@ -142,7 +147,7 @@ describe('watcher pipeline', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'DSH_CRED_PIPE=changed\n')
await writeCredentials(path, 'DSH_CRED_PIPE: changed\n')
const [instance] = await fakeInstances()
// Two queued refreshes: dispose interrupts one mid-flight and the other
// before it starts, so both closed guards must hold.
@@ -158,8 +163,8 @@ describe('watcher pipeline', () => {
it('empties the snapshot when the document is deleted and emits the removals', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=doomed\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_PIPE: doomed\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
@@ -175,30 +180,39 @@ describe('watcher pipeline', () => {
expect(seen).toEqual([KEY])
})
it('publishes only seam-addressable keys and preserves the rest untouched', async () => {
it('keeps the last good snapshot when an external edit makes the document invalid', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n')
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_PIPE: a\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
seen.push(ref)
})
await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n')
// A key the seam cannot address is a rejection, not preserved content:
// this document holds nothing but credentials. A live reload must warn
// and keep serving the last good snapshot rather than take the process
// down or silently drop the entry it could not validate.
await writeCredentials(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'a', source: 'file' })
expect(seen).toEqual([])
// Repairing the document resumes publishing.
await writeCredentials(path, 'DSH_CRED_PIPE: b\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
})
// The dash-named key is preserved file content the seam cannot address:
// its change publishes nothing and breaks nothing.
expect(seen).toEqual([KEY])
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
@@ -208,12 +222,12 @@ describe('watcher pipeline', () => {
it('reconciles at watcher ready so a change during setup is not missed', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${KEY}=a\n`)
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, `${KEY}: a\n`)
const ctx = await boot({ path, debounceMs: 5 })
// Written after the initial load but before the watcher became active:
// no 'all' event will ever fire for it.
await writeFile(path, `${KEY}=written-before-ready\n`)
await writeCredentials(path, `${KEY}: written-before-ready\n`)
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(async () => {

View File

@@ -20,6 +20,9 @@
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/environment"
},
{
"path": "../../util/paths"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/credentials/README.md
README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc
README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6
README.md: 95ef76d145727340d8135bf1d48babd6d8adb882
README.zh.md: b3404858025d4ec53a76548c78c1808d2c858844

View File

@@ -31,7 +31,7 @@ The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only so
## 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.
[`dsh-credentials-local`](../credentials-local/README.md) layers the inherited process environment over its managed `$DSH_HOME/.credentials.yaml` document, with the launcher's project and user `.env` layers as fallbacks. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets.
## Model Experience

View File

@@ -31,7 +31,7 @@ await ctx.credentials.unset(ref) // no-op when absent; s
## Providers
[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env`之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。
[`dsh-credentials-local`](../credentials-local/README.md) 把继承的进程环境叠加在其受管 `$DSH_HOME/.credentials.yaml`之上,并以启动器的项目和用户 `.env` 层作为后备。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。
## Model Experience

View File

@@ -32,7 +32,7 @@ export function credentialRef(value: string): CredentialRef {
export interface ResolvedCredential {
/** The non-empty secret value. */
value: string
/** Provider-defined source layer id (the local provider uses `env` and `file`). */
/** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */
source: string
}

View File

@@ -33,8 +33,6 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
const CORDIS_YML = `
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash

View File

@@ -24,7 +24,7 @@
"path": "../../../vendor/schemastery"
},
{
"path": "../../api/remotes"
"path": "../../api/remotes/tsconfig.host.json"
},
{
"path": "../../util/brand"

View File

@@ -3,18 +3,21 @@ import { clientBundle } from '../../client/tsdown.client.ts'
// The Win32 dialog worker builds as its own CJS entry (mirroring
// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining
// the dialog logic while koffi stays an external native require.
export default [
...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']),
export default clientBundle(
'@deepseek-ai/dsh-host-directory-picker-native',
['lib/types/index.js', 'lib/types/invariant.js'],
{
// The artifact is lib/worker.cjs (the ./worker export the workspace
// constraint keys on), bundled from the descriptive source entry.
entry: { worker: 'lib/types/win32-dialog-worker.js' },
outDir: 'lib',
format: ['cjs'] as ['cjs'],
platform: 'node' as const,
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
companions: [{
// The artifact is lib/worker.cjs (the ./worker export the workspace
// constraint keys on), bundled from the descriptive source entry.
entry: { worker: 'lib/types/win32-dialog-worker.js' },
outDir: 'lib',
format: ['cjs'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
}],
},
]
)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: c6435d0bdfbb9758b6f86ef38e94159a9ccdc36d
README.zh.md: f37286ade023ceecf6bfc87eb08fd4d80a5a3912
README.md: 6ad674ebdf8da4fd927a9499e80e06462d3c0dfb
README.zh.md: 02a192e38aa19b24a34e83b3ddb77d786bbace0b

View File

@@ -15,7 +15,6 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
@@ -53,7 +52,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every key is format-checked before use — a literal at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy.
@@ -108,7 +107,6 @@ Loop-retained response blocks append to the next request and preserve its earlie
## Known Limitations and Deferred Work
- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
- **`Config.apiKey` is redacted on the wire but still a stored literal** — `describe({ redactSecrets: true })` strips it and reports the slot, so a configuration UI never receives the value; the key is nonetheless stored in the settings document rather than the credential store, so prefer `apiKeyEnv`.
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.

View File

@@ -15,7 +15,6 @@ harness LLM大语言模型seam 的 DeepSeek chat-completions 适配器:
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
@@ -53,7 +52,7 @@ harness LLM大语言模型seam 的 DeepSeek chat-completions 适配器:
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking推理强度组合则保留最后可用事实并记录失败entry 配置本身仍会使插件加载失败。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被校验格式——字面量在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败并点名每个配置入口同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败并点名每个配置入口同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
@@ -108,7 +107,6 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
## 已知限制与暂缓事项
- **settings 的 `models` 列表会整体替换组合列表**settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。
- **`Config.apiKey` 在协议上已脱敏,但仍是一个已存的字面值**`describe({ redactSecrets: true })` 会把它剥离并报告该槽位,配置 UI 因此永远收不到该值;但这个密钥仍存放在 settings 文档而非凭据存储中,所以请优先使用 `apiKeyEnv`
- **未映射 `tool_choice`**它不属于核心词汇MVP 取舍,与 pi-ai twin 共享)。
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy拦截配置采用暂缓到第二个适配器需要该功能时`TODO(http)`)。
- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。

View File

@@ -26,6 +26,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
@@ -38,6 +39,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",

View File

@@ -49,12 +49,11 @@ export interface DeepSeekConnectionOptions {
/** Endpoint base; `/chat/completions` is appended. */
baseURL: string
/**
* Literal API key of this same resolution, when the configuration carried
* one. Travelling with the endpoint is the point: a request can never pair
* one generation's URL with another generation's secret.
* Credential reference of this same resolution, resolved per request.
* Travelling with the endpoint is the point: a request can never pair one
* generation's URL with another generation's secret. Configuration carries
* only this name — a literal key is not a configuration value.
*/
apiKey?: string
/** Credential reference of this same resolution, resolved per request when no literal key exists. */
apiKeyEnv: CredentialRef
/** Request defaults applied to every call (thinking mode, effort). */
defaults: RequestDefaults

View File

@@ -13,9 +13,10 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
@@ -58,17 +59,9 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
* reasoning effort resolves to `high`.
*/
export interface Config {
/**
* Trimmed literal API key; whitespace-only is absent, so it resolves through
* {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep
* secrets out of configuration files. {@link resolveAdapterOptions} also
* format-checks what remains: a value no HTTP header can carry fails there
* rather than inside `fetch`.
*/
apiKey?: string
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
baseURL?: string
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
@@ -95,7 +88,6 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
})
export const Config: z<Config> = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
@@ -110,6 +102,9 @@ export const Config: z<Config> = z.object({
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
/** Environment variable naming this provider's endpoint, honored only from trusted layers. */
const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'
/**
* One resolution's complete request facts. Connection and credential facts
* are one value on purpose: a snapshot the resolver rejects keeps the whole
@@ -156,9 +151,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
* every default and bound is re-judged here — for the composition entry at
* load (fail loud) and for each settings snapshot at its first use.
* @param config - raw plugin config or resolved settings snapshot.
* @param environment - this run's environment layers, or `undefined` outside
* the product CLI. Every layer may supply an endpoint: the product trusts the
* project it is launched in, so a checkout can point its own agent at the
* gateway that checkout is meant to use.
* @returns validated connection facts plus the credential reference.
*/
export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions {
if (config.thinking === 'disabled'
&& config.reasoningEffort !== undefined
&& config.reasoningEffort !== 'off') {
@@ -180,27 +179,11 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
// An absent apiKey is not a failure: it falls through to apiKeyEnv below.
// A supplied one must be usable, so a malformed literal fails here beside
// the other beyond-schema bounds instead of inside `fetch`.
// Absence is not a failure, and a blank literal is absence: both resolve
// through apiKeyEnv below, which is this adapter's defined fallback. (The
// pi-ai adapter refuses a blank one instead, because there absence selects a
// different authentication mode rather than a different source for the same
// key.) What a literal cannot be is unusable: a value no HTTP header can
// carry fails here beside the other beyond-schema bounds, not inside `fetch`.
let apiKey: string | undefined
if (config.apiKey !== undefined) {
const checked = normalizeApiKey(config.apiKey)
if (!checked.ok && checked.reason === 'illegalCharacters') {
throw new Error('llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only')
}
apiKey = checked.ok ? checked.value : undefined
}
return {
...apiKey === undefined ? {} : { apiKey },
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
baseURL: config.baseURL
?? environment?.get(BASE_URL_ENV)?.value
?? PUBLIC_BASE_URL,
defaults: {
thinking: config.thinking,
reasoningEffort: config.reasoningEffort,
@@ -221,7 +204,7 @@ export function apply(ctx: Context, config: Config): void {
const raw = current()
if (raw === lastRaw && lastGood !== undefined) return lastGood
try {
const next = resolveAdapterOptions(raw)
const next = resolveAdapterOptions(raw, environmentOf(ctx))
lastRaw = raw
lastGood = next
return next
@@ -241,22 +224,22 @@ export function apply(ctx: Context, config: Config): void {
const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
// Every credential fact comes from the caller's snapshot, so a rejected
// settings generation cannot leak its key onto the previous endpoint.
if (connection.apiKey !== undefined) return connection.apiKey
const ref = connection.apiKeyEnv
const credentials = ctx.get('credentials')
if (credentials !== undefined) {
const hit = await credentials.resolve(ref)
if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
} else {
// Without the seam, keep the historical ambient fallback so a plain
// cordis.yml composition works from the environment alone.
const ambient = process.env[ref]
if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref)
// Without the seam there is no managed store to rank against, so the
// environment is the whole credential plane.
const ambient = environmentOf(ctx).get(ref)
if (ambient !== undefined && ambient.value.length > 0) {
return assertUsableApiKey(ambient.value, 'llm-deepseek', ref)
}
}
throw new LlmError(
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
+ ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a`
+ ' last resort — set a literal "apiKey" in the llm-deepseek settings section',
+ ` service (the web Models page writes it), or export ${ref} in the launching environment`,
'MISSING_CREDENTIAL',
)
}

View File

@@ -62,14 +62,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-'))
try {
await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 })
// JSON.stringify quotes the value: YAML is a JSON superset, so a real
// key survives whatever characters it happens to carry.
await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 })
// Scrub the ambient variable so only the credential seam can supply the
// key: this request proves the per-request resolution path end to end.
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await ctx.plugin(LlmDeepSeek, {})
const result = await assemble(ctx, {

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import LlmService, { createUserMessage,
CONTEXT_WINDOW_EXCEEDED_CODE,
ProviderRequestId,
@@ -23,9 +24,12 @@ afterEach(async () => {
})
async function harness(baseURL: string, config: object = {}) {
// Configuration carries only the reference; the key comes from the
// environment, which is the whole credential plane without a mounted seam.
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
await ctx.plugin(LlmDeepSeek, { baseURL, ...config })
return ctx
}
@@ -565,7 +569,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: server.url,
})
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
@@ -584,7 +587,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
retryPolicy: {
mode: 'always',
@@ -603,7 +605,7 @@ describe('plugin registration and config', () => {
it('owns the deepseek provider and advertises the default models', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
@@ -631,7 +633,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
reasoningEffort: effort,
})
@@ -652,7 +653,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
thinking: 'disabled',
reasoningEffort: 'off',
@@ -672,7 +672,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
thinking: 'disabled',
reasoningEffort,
@@ -699,17 +698,10 @@ describe('plugin registration and config', () => {
})
})
it('normalizes a literal API key and treats whitespace as absent', () => {
expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key')
const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' })
expect(whitespace.apiKey).toBeUndefined()
expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY')
})
it('uses the default model catalog when apply is called directly', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
@@ -720,7 +712,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [
{ id: 'private-fast', contextWindow: 32_000 },
@@ -754,7 +745,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaultContextWindow: 256_000,
models: [
@@ -775,7 +765,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [],
})
@@ -792,7 +781,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [...models],
})).rejects.toThrow(message)
@@ -824,7 +812,6 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmService)
expect(() => {
LlmDeepSeek.apply(ctx, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [{ id: 'invalid-context', contextWindow: 0 }],
})
@@ -841,7 +828,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaultContextWindow,
})).rejects.toThrow(/defaultContextWindow/)
@@ -858,7 +844,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
maxTokens,
})).rejects.toThrow(/maxTokens/)
@@ -886,13 +871,14 @@ describe('plugin registration and config', () => {
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
// The guidance leads with the credential store — the path that keeps the
// secret out of configuration files — and mentions a literal key last.
// The guidance leads with the managed credential store.
const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(second.finish.kind).toBe('error')
if (second.finish.kind !== 'error') throw new Error('expected an error finish')
// The guidance names both places a credential can come from, and nothing
// else: configuration carries the reference, never a literal key.
expect(second.finish.failure.message)
.toMatch(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
.toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s)
})
it('reads the ambient variable when no credentials seam is mounted', async () => {
@@ -928,13 +914,33 @@ describe('plugin registration and config', () => {
it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
await ctx.plugin(LlmDeepSeek, {})
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1)
})
it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => {
const trusted = createEnvironmentSnapshot([
{ source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } },
])
expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example')
// The product trusts the project it is launched in, so a checkout can
// point its own agent at the gateway that checkout is meant to use.
const project = createEnvironmentSnapshot([
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } },
])
expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example')
// An explicitly configured endpoint outranks every environment layer, so a
// stale shell value cannot rewrite a deployment's own gateway.
const shell = createEnvironmentSnapshot([
{ source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } },
])
expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal')
})
it('defaults to the public base URL without config or env', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
@@ -975,12 +981,10 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: 0,
})).rejects.toThrow(/streamIdleTimeoutMs/)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
})).rejects.toThrow(/streamIdleTimeoutMs/)
@@ -991,45 +995,9 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
retryPolicy: { mode: 'normal', maxRetries: -1 },
})).rejects.toThrow(/retryPolicy/)
expect(ctx.llm.listProviders()).toEqual([])
})
})
describe('API key format', () => {
it('trims a padded literal apiKey', () => {
expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc')
})
it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => {
expect(resolveAdapterOptions({}).apiKey).toBeUndefined()
})
it('treats a whitespace-only literal apiKey as absent, not as a failure', () => {
// This adapter's absence has a defined fallback, so a blank literal
// resolves through apiKeyEnv like an omitted one. (llm-pi-ai refuses a
// blank one instead: there, absence selects provider-native or OAuth
// authentication rather than a different source for the same key.)
const resolved = resolveAdapterOptions({ apiKey: ' ', apiKeyEnv: 'CUSTOM_API_KEY' })
expect(resolved.apiKey).toBeUndefined()
expect(resolved.apiKeyEnv).toBe('CUSTOM_API_KEY')
})
it('rejects a literal apiKey no header can carry', () => {
expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' }))
.toThrow(/no HTTP header can carry/)
})
it('never echoes the key in the rejection', () => {
const secret = 'sk-\u{1F600}supersecret'
expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow()
try {
resolveAdapterOptions({ apiKey: secret })
} catch (error) {
expect((error as Error).message).not.toContain('supersecret')
}
})
})

View File

@@ -48,7 +48,7 @@ async function boot(dir: string, config: object): Promise<Harness> {
await ctx.plugin(LlmService)
const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
await settingsFiber
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await ctx.plugin(LlmDeepSeek, config)
return { ctx, settingsFiber }
}
@@ -61,7 +61,7 @@ describe('request-level dynamic configuration', () => {
it('routes the next request with the freshly resolved base URL and credential', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=first-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: serverA.url })
@@ -78,18 +78,6 @@ describe('request-level dynamic configuration', () => {
expect(serverB.headers[0]?.authorization).toBe('Bearer second-key')
})
it('prefers a literal settings apiKey over the credential layers', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: server.url })
await ctx.settings.update(NS, { apiKey: 'literal-key' })
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer literal-key')
})
it('starts keyless and serves the next request once the key arrives', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
@@ -124,7 +112,7 @@ describe('request-level dynamic configuration', () => {
it('advertises a live settings catalog without re-registration', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
@@ -135,7 +123,7 @@ describe('request-level dynamic configuration', () => {
it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
// Observing the topology event, not just the end state: disposing and
// re-registering also lands on the right final registry, but publishes an
@@ -160,7 +148,7 @@ describe('request-level dynamic configuration', () => {
it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
// Schema-valid but resolver-invalid: duplicate catalog ids pass the array
// schema and fail the explicit resolve step.
@@ -172,17 +160,16 @@ describe('request-level dynamic configuration', () => {
])
})
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => {
const dir = await home()
const good = await mockServer([{ kind: 'sse', events: textEvents }])
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
vi.stubEnv('DEEPSEEK_API_KEY', 'good-key')
const { ctx } = await boot(dir, { baseURL: good.url })
// One snapshot moves the endpoint AND the literal key, and fails the
// resolve step beyond the schema (duplicate catalog ids).
// One snapshot moves the endpoint and fails the resolve step beyond the
// schema (duplicate catalog ids).
await ctx.settings.update(NS, {
apiKey: 'rejected-key',
baseURL: rejected.url,
models: [{ id: 'dup' }, { id: 'dup' }],
})
@@ -198,7 +185,7 @@ describe('request-level dynamic configuration', () => {
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })

View File

@@ -2,7 +2,7 @@
* Real-composition guard for the dynamic-configuration chain: LlmService,
* settings-local, credentials-local, and llm-deepseek boot from a test-only
* cordis.yml through the actual Loader + Include path, external edits of
* settings.yaml and .env hot-publish through their providers, and the very
* settings.yaml and the credentials document hot-publish through their providers, and the very
* next request carries the fresh base URL and credential. The same adapter
* composition without settings or credentials entries keeps entry-config
* behavior — the documented optional-inject fallback.
@@ -42,16 +42,16 @@ afterEach(async () => {
async function loadComposition(
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
): Promise<{ ctx: Context; settingsPath: string; credentialsPath: string }> {
// A reused root is the restart case: the same harness home, its documents
// exactly as the previous process left them.
const fresh = options.reuseRoot === undefined
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
const settingsPath = join(root, 'settings.yaml')
const envPath = join(root, '.env')
const credentialsPath = join(root, '.credentials.yaml')
if (options.withDynamic && fresh) {
await writeFile(settingsPath, '# personal settings\n')
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
}
const configPath = join(root, 'cordis.yml')
@@ -68,7 +68,7 @@ async function loadComposition(
'- id: credentials',
" name: '@deepseek-ai/dsh-credentials-local'",
' config:',
` path: ${JSON.stringify(envPath)}`,
` path: ${JSON.stringify(credentialsPath)}`,
' debounceMs: 10',
]
: [],
@@ -76,7 +76,6 @@ async function loadComposition(
" name: '@deepseek-ai/dsh-llm-deepseek'",
' config:',
` baseURL: ${JSON.stringify(options.baseURL)}`,
...options.withDynamic ? [] : [' apiKey: entry-key'],
'',
].join('\n'))
@@ -103,15 +102,15 @@ async function loadComposition(
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, settingsPath, envPath }
return { ctx, settingsPath, credentialsPath }
}
describe('llm-deepseek real dynamic composition', () => {
it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => {
it('boots from cordis.yml and routes the next request after external settings and credential edits', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url })
const { ctx, settingsPath, credentialsPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url })
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS])
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
@@ -122,7 +121,7 @@ describe('llm-deepseek real dynamic composition', () => {
await vi.waitFor(() => {
expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
}, { timeout: 5000 })
await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n')
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
await vi.waitFor(async () => {
expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
}, { timeout: 5000 })
@@ -134,7 +133,7 @@ describe('llm-deepseek real dynamic composition', () => {
it('keeps a stored key writable and rotatable across a real restart', async () => {
// No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
// $DSH_HOME/.env into process.env, so a stored key must stay file-sourced.
// the credentials document into process.env, so a stored key must stay file-sourced.
vi.stubEnv('DEEPSEEK_API_KEY', '')
const first = await mockServer([{ kind: 'sse', events: textEvents }])
const second = await mockServer([{ kind: 'sse', events: textEvents }])
@@ -161,8 +160,10 @@ describe('llm-deepseek real dynamic composition', () => {
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
})
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => {
// No settings and no credentials provider: configuration carries only the
// reference, so the environment is the whole credential plane here.
vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })

View File

@@ -23,6 +23,9 @@
{
"path": "../../credentials/credentials"
},
{
"path": "../../util/environment"
},
{
"path": "../../settings/settings"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
README.md: 97bd629adedda9d63fee730bc31129b0c22cc704
README.zh.md: 71d45b590f48f4b8162ae329b58b5ff4a9eb13b1
README.md: d0968a7366943a36ba428355058ad14169884c0a
README.zh.md: d4c401698071bd494b95fc993b42de8b3197edc4

View File

@@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support
## Config
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
```yaml
- id: llm
@@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route
The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged.
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
Credentials resolve per stream call through `apiKeyEnv` and the optional `ctx.credentials` seam; without that seam, the adapter reads exactly the referenced environment variable. A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every resolved key is trimmed and format-checked before use, so a value no HTTP header can carry is refused instead of surfacing as an opaque `fetch` `TypeError`; the refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own.
@@ -75,7 +75,7 @@ A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThi
A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`.
Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
@@ -85,7 +85,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ
A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand.
A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint.
A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation resolves that route's `apiKeyEnv` rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint.
Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments.
@@ -155,7 +155,7 @@ Recorded response content appends to the next request and does not invalidate it
- **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work).
- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one.
- **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround.
- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`.
- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`.
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes.

View File

@@ -8,7 +8,7 @@
## 配置
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。`apiKeyEnv`按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。
```yaml
- id: llm
@@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
适配器经由一个 thunk **每操作读取一次** profile而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy全部在下一次请求生效无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。
凭据每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
凭据每次 stream 调用时通过 `apiKeyEnv`可选的 `ctx.credentials` seam 解析;未挂载 seam 时,适配器只读取该引用指向的环境变量。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个解析出的密钥都会在使用前去除首尾空白并校验格式因此 HTTP 标头无法承载的值会被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;这种拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。
@@ -75,7 +75,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`
受支持的 profile 字段是 `apiKey``apiKeyEnv``displayName``api``baseURL``models``defaultContextWindow``defaultMaxTokens``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
受支持的 profile 字段是 `apiKeyEnv``displayName``api``baseURL``models``defaultContextWindow``defaultMaxTokens``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries``maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent智能体级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`
@@ -85,7 +85,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答完全不联网pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。
草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。
草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会解析该路由的 `apiKeyEnv`而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。
询问只读 `openai-completions``openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。
@@ -155,7 +155,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
- **`headers` 可能承载一条脱敏器看不见的凭据**profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization``api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。
- **路由的 catalog 不会自我刷新**catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。
- **每条路由只有一种协议格式**`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog无法承载另一种协议的模型向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。
- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。
- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个 `apiKeyEnv` 引用的占位凭据,或在 `headers` 中给出 `Authorization` 条目。
- **不支持 `GenerateOptions.stop`**pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence因此适配器会拒绝该字段。
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
- **无法获取提供方 HTTP 状态**pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。

View File

@@ -26,6 +26,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
@@ -38,6 +39,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",

View File

@@ -19,7 +19,7 @@ import z from 'schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { resolveRouteModels } from './catalog.ts'
import type { PiAiModelProfile } from './catalog.ts'
@@ -38,12 +38,6 @@ export type { PiAiModelProfile } from './catalog.ts'
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
export interface PiAiProviderProfile {
/**
* Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its
* provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a
* value no HTTP header can carry fails there rather than inside `fetch`.
*/
apiKey?: string
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
apiKeyEnv?: string
/** Name shown by configuration surfaces; defaults to the route key. */
@@ -147,7 +141,6 @@ const modelProfile: z<PiAiModelProfile> = z.object({
})
const profile = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref'),
displayName: z.string(),
api: z.union(supportedProtocols()),
@@ -224,19 +217,6 @@ export function resolveProfiles(
for (const [provider, source] of entries) {
rejectRemovedFields(provider, source)
if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
// Omission selects the installed provider's own auth — ambient discovery
// or OAuth — so only a supplied key is judged.
let apiKey: string | undefined
if (source.apiKey !== undefined) {
const checked = normalizeApiKey(source.apiKey)
if (!checked.ok) {
throw new Error(checked.reason === 'empty'
? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`
: `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;`
+ ' paste the raw key only')
}
apiKey = checked.value
}
if (source.baseURL !== undefined && source.baseURL.length === 0) {
throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
}
@@ -266,7 +246,6 @@ export function resolveProfiles(
const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
resolved.set(provider, {
...rest,
...apiKey === undefined ? {} : { apiKey },
provider,
displayName,
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
@@ -281,7 +260,7 @@ export function resolveProfiles(
...source.api === undefined ? {} : { api: source.api },
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
models: catalog.models,
namesCredential: source.apiKey !== undefined || apiKeyEnv !== undefined,
namesCredential: apiKeyEnv !== undefined,
}),
})
}

View File

@@ -43,6 +43,7 @@
*/
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -141,7 +142,6 @@ export function apply(ctx: Context, config: Config): void {
provider: string,
profile: ResolvedPiAiProviderProfile,
): Promise<string | undefined> => {
if (profile.apiKey !== undefined) return profile.apiKey
const ref = profile.apiKeyEnv
// Only a profile that names no credential at all defers to pi-ai's
// provider-native discovery. Once one is named, a miss must fail loud:
@@ -152,9 +152,8 @@ export function apply(ctx: Context, config: Config): void {
const credentials = ctx.get('credentials')
const hit = credentials !== undefined
? (await credentials.resolve(ref))?.value
// Without the seam, read exactly the named variable so a plain
// cordis.yml composition works from the environment alone.
: process.env[ref]
// Without the seam the environment is the whole credential plane.
: environmentOf(ctx).get(ref)?.value
if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)
throw new LlmError(
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`

View File

@@ -97,10 +97,11 @@ export interface ProviderSpec {
/** The route's materialized models, in configuration order. */
models: readonly Model<Api>[]
/**
* Whether the profile names a credential — a literal key or a reference.
* Only that decides whether {@link routeAuth} adds the harness's own api-key
* method to a catalog provider that offers none; the key itself still arrives
* per request, never at construction.
* Whether the profile names a credential, which it does through `apiKeyEnv`
* alone: configuration carries the reference, never the secret. Only that
* decides whether {@link routeAuth} adds the harness's own api-key method to
* a catalog provider that offers none; the key itself still arrives per
* request, never at construction.
*/
namesCredential: boolean
}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
@@ -15,22 +15,32 @@ afterEach(async () => {
})
async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> {
vi.stubEnv('PI_TEST_KEY', 'test-key')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } },
providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL, ...overrides } },
})
return ctx
}
/** Direct adapter over the real profile resolver, with literal-key resolution. */
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
/** Direct adapter over the real profile resolver, with a fixed key per call. */
function adapterOf(
providers: Record<string, LlmPiAi.PiAiProviderProfile>,
apiKey: string | undefined = 'test-key',
): PiAiAdapter {
return new PiAiAdapter({
profiles: () => resolveProfiles(providers),
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
resolveApiKey: () => Promise.resolve(apiKey),
})
}
beforeEach(() => {
// Configuration carries only the reference; these mounts resolve it from
// the environment, which is the whole credential plane without a seam.
vi.stubEnv('PI_TEST_KEY', 'test-key')
})
describe('PiAiAdapter provider routing', () => {
it('resolves a catalog model dynamically and uses a private endpoint', async () => {
const server = await mockServer([{ events: textEvents }])
@@ -121,7 +131,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['deepseek'], adapterOf({
deepseek: { apiKey: 'test-key', baseURL: server.url },
deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url },
}))
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
@@ -131,7 +141,6 @@ describe('PiAiAdapter provider routing', () => {
it('names a route by its displayName, and by its own key once the profiles drop it', () => {
const adapter = adapterOf({ 'acme-gateway': {
apiKey: 'k',
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://acme.test/v1',
@@ -167,7 +176,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(result.finish.kind).toBe('error')
@@ -187,7 +196,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
@@ -203,7 +212,7 @@ describe('PiAiAdapter provider routing', () => {
await ctx.plugin(LlmPiAi, {
providers: {
openai: {
apiKey: 'test-key',
apiKeyEnv: 'PI_TEST_KEY',
baseURL: `${server.url}/api/projects/openai/openai/v1`,
headers: { 'api-key': 'test-key', Authorization: '' },
},
@@ -403,7 +412,9 @@ describe('provider profile lifecycle', () => {
it('accepts absent credentials for pi-ai ambient authentication', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { apiKey: undefined })
// A profile that names no reference at all is the one case that defers to
// pi-ai's own provider-native discovery.
const ctx = await harness(server.url, { apiKeyEnv: undefined })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
})
@@ -445,8 +456,6 @@ describe('provider profile lifecycle', () => {
// loud with migration directions instead of half-working.
expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/)
expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/)
expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/)
expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/)
expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/)
expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/)
})
@@ -521,7 +530,7 @@ describe('abort wiring', () => {
const message = Object.defineProperty({}, 'role', {
get() { throw original },
})
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const adapter = adapterOf({ deepseek: {} })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek',
@@ -542,7 +551,7 @@ describe('abort wiring', () => {
throw original
},
})
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const adapter = adapterOf({ deepseek: {} })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek',
@@ -556,7 +565,7 @@ describe('abort wiring', () => {
})
it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const adapter = adapterOf({ deepseek: {} })
const controller = new AbortController()
controller.abort('already stopped')
const chunks = []

View File

@@ -1,7 +1,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -19,7 +19,17 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
const homes: string[] = []
// Routes name their credential by reference; the value lives in the
// environment, which is the layer the adapter falls back to without a
// mounted credentials seam.
const KEY_ENV = 'PI_TEST_KEY'
beforeEach(() => {
vi.stubEnv(KEY_ENV, 'test-key')
})
afterEach(async () => {
vi.unstubAllEnvs()
await closeMockServers()
await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
@@ -46,7 +56,7 @@ function gateway(baseURL: string, overrides: Record<string, unknown> = {}): LlmP
return {
providers: {
'acme-gateway': {
apiKey: 'gw-key',
apiKeyEnv: KEY_ENV,
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL,
@@ -81,7 +91,8 @@ describe('hand-declared providers', () => {
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(result.finish).toEqual({ kind: 'stop' })
expect(server.paths).toEqual(['/v1/chat/completions'])
expect(server.headers[0]?.authorization).toBe('Bearer gw-key')
// The reference resolved through the environment and reached the wire.
expect(server.headers[0]?.authorization).toBe('Bearer test-key')
})
it('lists and resolves the declared models rather than a catalog', async () => {
@@ -114,7 +125,7 @@ describe('hand-declared providers', () => {
// A catalog route is unaffected: its models carry the metadata that makes
// `off` actually disable thinking.
const withCatalog = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
const withCatalog = await harness({ providers: { deepseek: { baseURL: server.url } } })
const [catalogModel] = getBuiltinModels('deepseek')
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id))
@@ -288,7 +299,7 @@ describe('hand-declared providers', () => {
describe('catalog routes with per-model configuration', () => {
it('serves the installed catalog untouched when the profile lists no models', async () => {
const server = await mockServer([])
const ctx = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
const ctx = await harness({ providers: { deepseek: { baseURL: server.url } } })
const listed = await ctx.llm.listModels('deepseek')
expect(listed.map(model => model.id).sort())
@@ -302,7 +313,6 @@ describe('catalog routes with per-model configuration', () => {
const ctx = await harness({
providers: {
deepseek: {
apiKey: 'k',
baseURL: server.url,
models: [{ id: catalogModel.id, contextWindow: 4096 }],
},
@@ -327,7 +337,6 @@ describe('catalog routes with per-model configuration', () => {
const ctx = await harness({
providers: {
deepseek: {
apiKey: 'k',
baseURL: server.url,
models: [{ id: catalogModel.id, maxTokens: 4096 }],
},
@@ -344,7 +353,7 @@ describe('catalog routes with per-model configuration', () => {
const ctx = await harness({
providers: {
deepseek: {
apiKey: 'k',
apiKeyEnv: KEY_ENV,
baseURL: `${server.url}/v1`,
models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }],
},
@@ -362,7 +371,7 @@ describe('catalog routes with per-model configuration', () => {
const server = await mockServer([])
const ctx = await harness({
providers: {
deepseek: { apiKey: 'k', baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
deepseek: { baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
},
})
@@ -390,7 +399,7 @@ describe('catalog routes with per-model configuration', () => {
it('delegates both stream methods back to the reused catalog provider', async () => {
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const resolved = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
const resolved = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
const built = resolved.get('deepseek')?.piProvider
if (built === undefined) throw new Error('the deepseek route built no provider')
const [model] = built.getModels()
@@ -431,7 +440,7 @@ describe('catalog routes with per-model configuration', () => {
// openai's catalog models speak the Responses API; naming the protocol
// explicitly moves the whole route onto Chat Completions.
openai: {
apiKey: 'k',
apiKeyEnv: KEY_ENV,
api: 'openai-completions',
baseURL: `${server.url}/v1`,
models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }],
@@ -456,7 +465,7 @@ describe('catalog routes with per-model configuration', () => {
// declares an api-key method. `openai-codex` ships OAuth alone, so without
// the harness method beside it the route refuses its own configured key as
// `Provider is not configured` before any request goes out.
const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } })
const resolved = resolveProfiles({ 'openai-codex': { apiKeyEnv: 'CODEX_TOKEN' } })
const provider = resolved.get('openai-codex')?.piProvider
expect(provider?.auth.oauth).toBeDefined()
const models = createModels()
@@ -478,7 +487,7 @@ describe('catalog routes with per-model configuration', () => {
describe('resolution snapshots', () => {
it('finishes an in-flight request under the configuration it started with', async () => {
const server = await mockServer([{ events: textEvents }])
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
let current = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
let release: () => void = () => {}
const held = new Promise<void>((resolve) => { release = resolve })
const adapter = new PiAiAdapter({
@@ -499,7 +508,7 @@ describe('resolution snapshots', () => {
// The route set changes while the request waits, and something else reads
// the adapter meanwhile, which is what would rebuild a shared collection.
current = resolveProfiles({ openai: { apiKey: 'k', baseURL: `${server.url}/v1` } })
current = resolveProfiles({ openai: { baseURL: `${server.url}/v1` } })
await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0)
release()
await inFlight
@@ -513,7 +522,7 @@ describe('resolution snapshots', () => {
it('serves the next request from the new configuration', async () => {
const first = await mockServer([{ events: textEvents }])
const second = await mockServer([{ events: textEvents }])
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${first.url}/v1` } })
let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } })
const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
@@ -522,7 +531,7 @@ describe('resolution snapshots', () => {
}
await drain()
current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${second.url}/v1` } })
current = resolveProfiles({ deepseek: { baseURL: `${second.url}/v1` } })
await drain()
expect(first.paths).toHaveLength(1)
@@ -544,7 +553,6 @@ describe('configurable-provider directory', () => {
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'deepseek-official': {
apiKey: 'k',
api: 'openai-completions',
baseURL: 'https://acme.test/v1',
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
@@ -567,7 +575,6 @@ describe('configurable-provider directory', () => {
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'acme-gateway': {
apiKey: 'k',
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://acme.test/v1',

View File

@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveProfiles } from '../src/config.ts'
describe('API key format', () => {
it('trims a padded literal apiKey into the resolved profile', () => {
const resolved = resolveProfiles({ openai: { apiKey: ' sk-abc ', baseURL: 'https://acme.test' } })
expect(resolved.get('openai')?.apiKey).toBe('sk-abc')
})
it('keeps an omitted apiKey absent so ambient authentication still applies', () => {
const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } })
expect(resolved.get('openai')?.apiKey).toBeUndefined()
})
it('still tells an empty apiKey to omit itself', () => {
expect(() => resolveProfiles({ openai: { apiKey: ' ', baseURL: 'https://acme.test' } }))
.toThrow(/omit it to use ambient authentication/)
})
it('rejects an apiKey no header can carry', () => {
expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } }))
.toThrow(/no HTTP header can carry/)
})
})

View File

@@ -44,7 +44,7 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
})
await ctx.plugin(LlmService)
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await ctx.plugin(LlmPiAi, config)
return ctx
}
@@ -53,7 +53,11 @@ describe('request-level dynamic profiles', () => {
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n')
await writeFile(
join(dir, '.credentials.yaml'),
'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }])
// The exact product posture: `- id: llm-pi-ai` with no config at all.
const ctx = await boot(dir, {})
@@ -87,14 +91,19 @@ describe('request-level dynamic profiles', () => {
it('adds a provider route from settings and drops it when the user layer resets', async () => {
const dir = await home()
await writeFile(
join(dir, '.credentials.yaml'),
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }])
const ctx = await boot(dir, {
providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } },
providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: 'http://127.0.0.1:1/v1' } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
await ctx.settings.update(NS, {
providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } },
providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
@@ -113,7 +122,7 @@ describe('request-level dynamic profiles', () => {
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n')
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
@@ -162,15 +171,20 @@ describe('request-level dynamic profiles', () => {
it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
const dir = await home()
await writeFile(
join(dir, '.credentials.yaml'),
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } })
const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` } } })
// Another adapter owns `anthropic`; the registry must refuse to hand it over.
ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
await ctx.settings.update(NS, {
providers: {
openai: { apiKey: 'pk', baseURL: `${server.url}/v1` },
anthropic: { apiKey: 'other' },
openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` },
anthropic: { apiKeyEnv: 'PI_OTHER_KEY' },
},
})

View File

@@ -3,7 +3,7 @@
* settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a
* test-only cordis.yml through the actual Loader + Include path, an external
* edit of settings.yaml registers the route live, and the next request
* carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot
* carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot
* catch Loader export-shape failures, which is why the twin adapter has the
* same guard.
*/
@@ -40,7 +40,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, '# personal settings\n')
await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n')
await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 })
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
@@ -54,7 +54,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
'- id: credentials',
" name: '@deepseek-ai/dsh-credentials-local'",
' config:',
` path: ${JSON.stringify(join(root, '.env'))}`,
` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`,
' debounceMs: 10',
'- id: llm-pi-ai',
" name: '@deepseek-ai/dsh-llm-pi-ai'",

View File

@@ -21,7 +21,6 @@ function gatewayAdapter(): PiAiAdapter {
return new PiAiAdapter({
profiles: () => resolveProfiles({
'local-gateway': {
apiKey: 'test-key',
api: 'openai-completions',
baseURL: 'http://127.0.0.1:9/v1',
models: [{ id: 'local-model', contextWindow: 8192, maxTokens: 1024 }],

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md
README.md: 23b55a30989cc51d4dd9076b61b6595452b0abd0
README.zh.md: 267ef12a87561fd8effef726a781e505225baf03
README.md: e6e56ec44032d714393c6fcc1c42d7271017a294
README.zh.md: b7ce8bee4acd2c4f7c88870745dff96ec5695435

View File

@@ -15,7 +15,7 @@ The separately published `./invariant` companion checks that every retry record
```yaml
- name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
apiKeyEnv: DEEPSEEK_API_KEY
retryPolicy:
mode: always
backoff:

View File

@@ -15,7 +15,7 @@
```yaml
- name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
apiKeyEnv: DEEPSEEK_API_KEY
retryPolicy:
mode: always
backoff:

View File

@@ -1,7 +1,7 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -34,10 +34,10 @@ async function harness(
baseURL: string,
options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {},
): Promise<Context> {
vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key')
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'mock-key',
baseURL,
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
retryPolicy: {

View File

@@ -381,7 +381,7 @@ describe('CreateWizard and scaffolder', () => {
}).run()
await scaffoldProject(resolved.directory, resolved.request)
expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe(
'# Required before start; an empty value makes provider startup fail.\nDEEPSEEK_API_KEY=\n',
'# Required before the first model request.\nDEEPSEEK_API_KEY=\n',
)
expect(port.requests).toContain('Keep the API key empty and fill .env later?')
})

View File

@@ -4,7 +4,6 @@
* @module @deepseek-ai/dsh-helper/features/builtin/provider
*/
import { JsExpression } from '../../documents/cordis-yaml-file.ts'
import { featureId } from '../../ids.ts'
import type { FeatureSelection, ProjectProfile } from '../../project/types.ts'
import {
@@ -17,7 +16,7 @@ import { npmCordisConfigEntry, environment } from './helpers.ts'
const ID = featureId('provider')
const DEFAULT_MODEL = 'deepseek-v4-flash'
const API_KEY_COMMENT = 'Required before start; an empty value makes provider startup fail.'
const API_KEY_COMMENT = 'Required before the first model request.'
class DeepSeekOption extends FeatureOption {
override readonly id = 'deepseek-official'
@@ -34,8 +33,7 @@ class DeepSeekOption extends FeatureOption {
...npmCordisConfigEntry(ID, {
id: 'llm-deepseek',
name: '@deepseek-ai/dsh-llm-deepseek',
config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') },
}, ['apiKey', 'baseURL', 'models']),
}, ['baseURL', 'models']),
environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT),
])
}
@@ -60,8 +58,7 @@ class CustomOption extends FeatureOption {
...npmCordisConfigEntry(ID, {
id: 'llm-pi-ai',
name: '@deepseek-ai/dsh-llm-pi-ai',
config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') },
}, ['apiKey', 'baseURL', 'models']),
}, ['baseURL', 'models']),
environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT),
])
}

View File

@@ -86,20 +86,20 @@ config:
expect(flow.serialize()).not.toContain('{')
const document = CordisYamlFile.parse(`# lead
- id: provider
name: '@deepseek-ai/dsh-llm-deepseek'
name: 'provider-package'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
endpoint: !!js process.env.PROVIDER_URL
custom: keep
`)
const apiKey = document.entry('provider')?.config?.apiKey
expect(apiKey).toBeInstanceOf(JsExpression)
document.updateOwnedConfig('provider', ['apiKey'], { apiKey: new JsExpression('process.env.NEXT_KEY') })
const endpoint = document.entry('provider')?.config?.endpoint
expect(endpoint).toBeInstanceOf(JsExpression)
document.updateOwnedConfig('provider', ['endpoint'], { endpoint: new JsExpression('process.env.NEXT_URL') })
document.setDisabled('provider', true)
document.addEntry({ id: 'tool', name: 'demo-tool' })
document.validate()
const text = document.serialize()
expect(text).toContain('# lead')
expect(text).toContain('!!js process.env.NEXT_KEY')
expect(text).toContain('!!js process.env.NEXT_URL')
expect(text).toContain('custom: keep')
expect(document.removeEntry('tool')).toBe(true)
expect(document.removeEntry('tool')).toBe(false)

View File

@@ -193,6 +193,7 @@ describe('SdkProject and ProjectEditSession', () => {
expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant')
expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin')
expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' })
expect(project.cordis.entry('llm-deepseek')).not.toHaveProperty('config.apiKey')
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL')
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models')
})
@@ -580,7 +581,7 @@ describe('SdkProject and ProjectEditSession', () => {
await writeFile(join(partialRoot, 'cordis.yml'), `- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: test
apiKeyEnv: DEEPSEEK_API_KEY
`)
const partial = await SdkProject.open(partialRoot)
const installation = createBuiltinRegistry(partial.profile)

View File

@@ -535,7 +535,7 @@ describe('ConfigWorkflow', () => {
]), outputBuffer().stream, async () => {})
const result = await workflow.run(project, registry)
const provider = result.commit?.project.cordis.entry('llm-pi-ai')
expect(provider?.config?.apiKey).toBeDefined()
expect(provider?.config).not.toHaveProperty('apiKey')
expect(provider?.config?.baseURL).toBe('https://provider.example/v1')
expect(result.commit?.project.cordis.entry('acp')).toBeDefined()
expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined()

View File

@@ -77,7 +77,8 @@ describe('ConsentResolver cordis.yml state', () => {
'- id: llm',
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
' config:',
' apiKey: !!js process.env.DEEPSEEK_API_KEY',
' apiKeyEnv: DEEPSEEK_API_KEY',
' model: !!js process.env.DEEPSEEK_MODEL',
'',
].join('\n')
expect(await resolver.resolve(await projectDir(yml)))

View File

@@ -272,10 +272,17 @@ export class SettingsLocal extends Settings {
private parse(text: string): Record<string, unknown> {
let root: unknown
if (this.spec.format === 'yaml') {
// `prettyErrors` is on only for `linePos`; `error.message` is never
// used, because the parser quotes the offending source line and a
// settings document can hold a `role('secret')` value.
const document = parseDocument(text, { prettyErrors: true })
if (document.errors.length > 0) {
throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${
document.errors.map(error => error.message).join('; ')}`)
document.errors.map((error) => {
const at = error.linePos?.[0]
/* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}`
}).join('; ')}`)
}
root = document.toJS() ?? {}
} else {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md
README.md: ff8b89437703e0d63542f2a004f9b0929171010e
README.zh.md: 582285363f6556fcc68d783e2ceedc7eb007dafd
README.md: 0b935ef60c33fd24660d8ecf2497f5506157c724
README.zh.md: 91be3c97bcb67ce10c61513113f683e741bc762f

View File

@@ -8,8 +8,8 @@ Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → one canonical `{{cwd}}`, including an already-tokenized macOS `/private` alias; authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, noncanonical macOS-prefixed cwd tokens, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → one canonical `{{cwd}}`, including an already-tokenized macOS `/private` alias; authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)), and `stabilizeFixtureMessageIds` (committed UUIDs carried into unchanged, mutually unique messages by structurally rewriting only complete surface and durable-inbox message ID fields across any recorder's fixture-ready parent/child logs).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, noncanonical macOS-prefixed cwd tokens, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID only when both its ID and identity-free fingerprint are unique across the scenario's fixture-ready parent/child logs; the session package's authoritative surface-type predicate selects surface carriers, correlated `agent/inbox/spliced` copies join the same mapping, and only validated `id` fields in those carriers are rewritten. New, changed, malformed, and graph-ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; complete message IDs in surface or inbox carriers are excluded because the later structural pass owns them, ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge.
@@ -59,7 +59,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the Web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the JSON-RPC and Web snapshot recorders. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
## Model Experience

View File

@@ -8,8 +8,8 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[
- **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent或在普通 Node 下启动已构建 `lib` agent通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。
- **`runScenario`harness**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript``configPath``tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。
- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`JSON-RPC id → 首次出现序列UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token按最长优先根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`;手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`schema bulk → `{{tools}}``scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin由可独立共享的 `system-prompt.expected.md``tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`JSON-RPC id → 首次出现序列UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token按最长优先根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`;手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`schema bulk → `{{tools}}``scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)`stabilizeFixtureMessageIds`(针对任意录制器已准备写入 fixture 的父级/子级日志,通过结构化方式仅改写 surface 和持久 inbox 中完整消息的 ID 字段,将已提交 UUID 带入未变化且双向唯一匹配的消息)
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin由可独立共享的 `system-prompt.expected.md``tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header以及格式错误的 pin header。在录制或刷新写入 fixture 前,仅当一条未变化完整消息的 ID 及其去除身份后的指纹在场景可写入 fixture 的父级/子级日志中均唯一时,该消息才会保留已提交的 UUID会话包的权威 surface 类型谓词负责选择 surface 载体,与其关联的 `agent/inbox/spliced` 副本也纳入同一映射,且仅改写这些载体中通过验证的 `id` 字段。新增、发生变化、格式错误以及图关系存在歧义的消息保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用归一化后等价的叶值;surface 或 inbox 载体中的完整消息 ID 不参与此路径,因为后续结构化处理负责这些 ID有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。
@@ -59,7 +59,7 @@ defineAcpSnapshotSuite({
示例还发布 `cordis.snapshot.yml` 回放 overlay位于 `cordis.yml` 旁边bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM并重写已记录场景的模型 fixture`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay并从已提交模型脚本重写 stdout、可比较会话日志预期输出以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
约束:`suite.ts``harness.ts` 导入 vitestharness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 Web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once``reject_once` 等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
约束:`suite.ts``harness.ts` 导入 vitestharness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 JSON-RPC 和 Web 快照录制器消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once``reject_once` 等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
## 模型体验

View File

@@ -31,10 +31,12 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -46,6 +46,7 @@ export {
export {
defineAcpSnapshotSuite,
refreshFixtureReplacements,
stabilizeFixtureMessageIds,
stabilizeRefreshLog,
type Scenario,
type SnapshotSuiteOptions,

View File

@@ -20,6 +20,7 @@
import { readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import { describe, expect, it } from 'vitest'
import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts'
import {
@@ -53,6 +54,9 @@ const TOOLS_TOKEN = '{{tools}}'
const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
/** Canonical UUID spelling minted for ordinary message identities. */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** A snapshot scenario and how its fixtures are produced. */
export interface Scenario {
name: string
@@ -509,11 +513,11 @@ export function headerChangeCount(rawLog: string): number {
.length
}
/** A literal string replacement used to carry an existing fixture's volatile value into a refreshed log. */
/** A literal replacement from a fresh replay-run volatile to its existing fixture value. */
export interface FixtureReplacement {
/** The fresh replay-run value to replace. */
/** The fresh replay run's volatile value. */
from: string
/** The existing fixture value to keep. */
/** The existing fixture value retained during write-back. */
to: string
}
@@ -523,6 +527,143 @@ function parseJsonlRecords(text: string): Record<string, unknown>[] {
.map(line => JSON.parse(line) as Record<string, unknown>)
}
/** Narrow one parsed value to the complete identified-message shape retained by fixtures. */
function completeMessage(value: unknown): Record<string, unknown> | undefined {
if (
!isRecord(value)
|| typeof value.id !== 'string'
|| !UUID_RE.test(value.id)
|| typeof value.role !== 'string'
|| !Array.isArray(value.content)
|| !isRecord(value.source)
) return undefined
return value
}
/** Return the complete identified message carried by one surface event. */
function surfaceEventMessage(record: Record<string, unknown>): Record<string, unknown> | undefined {
const type = record.type
if (typeof type !== 'string' || !isSurfaceEligibleType(type)) return undefined
const data = record.data
if (!isRecord(data)) return undefined
let message: unknown
switch (type) {
case 'user/message':
message = data
break
case 'assistant/message':
case 'tool/result':
message = data.message
break
/* v8 ignore next -- the authoritative predicate must fail loud when a new surface shape lands. */
default: throw new Error(`acp-snapshot: unsupported surface event type "${type}"`)
}
return completeMessage(message)
}
/** Return complete message identities structurally owned by one durable record. */
function recordMessages(record: Record<string, unknown>): Record<string, unknown>[] {
const surfaceMessage = surfaceEventMessage(record)
if (surfaceMessage !== undefined) return [surfaceMessage]
if (record.type !== 'agent/inbox/spliced' || !isRecord(record.data) || !Array.isArray(record.data.inserted)) {
return []
}
return record.data.inserted.flatMap((value) => {
const message = completeMessage(value)
return message === undefined ? [] : [message]
})
}
/** Serialize parsed JSON by value rather than insertion order. */
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
if (isRecord(value)) {
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`
}
return JSON.stringify(value)
}
/** Index identity-free message values whose ID and fingerprint are mutually unique. */
function uniqueMessageIds(logs: readonly string[]): Map<string, string> {
const fingerprintsById = new Map<string, Set<string>>()
const idsByFingerprint = new Map<string, Set<string>>()
for (const log of logs) {
for (const record of parseJsonlRecords(log)) {
for (const message of recordMessages(record)) {
const { id, ...withoutId } = message
const messageId = id as string
const fingerprint = canonicalJson(withoutId)
const fingerprints = fingerprintsById.get(messageId)
if (fingerprints === undefined) fingerprintsById.set(messageId, new Set([fingerprint]))
else fingerprints.add(fingerprint)
const ids = idsByFingerprint.get(fingerprint)
if (ids === undefined) idsByFingerprint.set(fingerprint, new Set([messageId]))
else ids.add(messageId)
}
}
}
const unique = new Map<string, string>()
for (const [id, fingerprints] of fingerprintsById) {
if (fingerprints.size !== 1) continue
const fingerprint = fingerprints.values().next().value as string
if (idsByFingerprint.get(fingerprint)?.size !== 1) continue
unique.set(fingerprint, id)
}
return unique
}
/**
* Match unchanged complete messages across a scenario's fresh and existing logs.
* New, changed, duplicate-content, or otherwise ambiguous messages keep their fresh ids.
*/
function fixtureMessageIdReplacements(logs: readonly string[], fixtures: readonly string[]): Map<string, string> {
const freshIds = uniqueMessageIds(logs)
const existingIds = uniqueMessageIds(fixtures)
const replacements = new Map<string, string>()
for (const [fingerprint, fresh] of freshIds) {
const existing = existingIds.get(fingerprint)
if (existing === undefined || fresh === existing) continue
replacements.set(fresh, existing)
}
return replacements
}
/** Apply literal fixture replacements without changing any other fresh value. */
function applyFixtureReplacements(content: string, replacements: readonly FixtureReplacement[]): string {
let stable = content
for (const { from, to } of replacements) stable = stable.split(from).join(to)
return stable
}
/** Rewrite only validated durable-message ID fields, leaving every other occurrence untouched. */
function applyFixtureMessageIds(content: string, replacements: ReadonlyMap<string, string>): string {
return content.split('\n').map((line) => {
if (line.trim().length === 0) return line
const record = JSON.parse(line) as Record<string, unknown>
let changed = false
for (const message of recordMessages(record)) {
const replacement = replacements.get(message.id as string)
if (replacement === undefined) continue
message.id = replacement
changed = true
}
return changed ? JSON.stringify(record) : line
}).join('\n')
}
/**
* Carry committed UUIDs into unchanged, unambiguous messages in fresh session fixtures.
*
* @param logs Fresh fixture-ready session JSONL contents for one scenario.
* @param fixtures Existing fixture contents in matching order; missing fixtures may be empty strings.
* @returns The fresh contents with only reusable message UUIDs replaced.
*/
export function stabilizeFixtureMessageIds(logs: readonly string[], fixtures: readonly string[]): string[] {
const replacements = fixtureMessageIdReplacements(logs, fixtures)
return logs.map(log => applyFixtureMessageIds(log, replacements))
}
/** One packed row's member times, or `undefined` for an ordinary record. */
function packedTimes(record: Record<string, unknown>): number[] | undefined {
if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined
@@ -568,11 +709,12 @@ export function unknownToolCallIds(rawLog: string): string[] {
}
/**
* Build the cross-log id/cwd/spill-path replacements used by refresh write-back.
* Build refresh write-back replacements for per-log session ids, cwd values,
* and spill paths. Durable message ids have a later structural owner.
*
* @param logs The freshly harvested logs, in fixture order.
* @param fixtures The existing fixture contents, in matching order.
* @returns Literal replacements from fresh volatile values to the fixture's old values.
* @returns Literal replacements from fresh values to the fixture's existing values.
*/
export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] {
const replacements: FixtureReplacement[] = []
@@ -726,6 +868,7 @@ function collectNormalizedStringMappings(
existing: unknown,
normalizedFresh: unknown,
normalizedExisting: unknown,
excludedStrings: ReadonlySet<string>,
forward: Map<string, string>,
reverse: Map<string, string>,
): boolean {
@@ -745,6 +888,7 @@ function collectNormalizedStringMappings(
existing[index],
normalizedFresh[index],
normalizedExisting[index],
excludedStrings,
forward,
reverse,
))
@@ -764,6 +908,7 @@ function collectNormalizedStringMappings(
existing[key],
normalizedFresh[key],
normalizedExisting[key],
excludedStrings,
forward,
reverse,
))
@@ -774,6 +919,8 @@ function collectNormalizedStringMappings(
|| typeof normalizedFresh !== 'string'
|| normalizedFresh !== normalizedExisting
|| fresh === existing
|| excludedStrings.has(fresh)
|| excludedStrings.has(existing)
) return true
const freshKey = JSON.stringify([normalizedFresh, fresh])
const existingKey = JSON.stringify([normalizedFresh, existing])
@@ -799,6 +946,10 @@ function normalizedStringMappings(
freshContext: NormalizeContext,
existingContext: NormalizeContext,
): Map<string, string> | undefined {
const excludedStrings = new Set<string>()
for (const record of [...freshRecords, ...existingRecords]) {
for (const message of recordMessages(record)) excludedStrings.add(message.id as string)
}
const forward = new Map<string, string>()
const reverse = new Map<string, string>()
let existingIndex = 0
@@ -820,6 +971,7 @@ function normalizedStringMappings(
existingRecord,
normalizedRefreshRecord(freshRecords[recordIndex] as Record<string, unknown>, freshContext),
normalizedRefreshRecord(existingRecord, existingContext),
excludedStrings,
forward,
reverse,
)) return undefined
@@ -832,11 +984,13 @@ function normalizedStringMappings(
/**
* Rewrite a fresh replay-produced log so repeated refreshes do not churn
* volatile fixture fields. Meaningful event payloads come from `fresh`; the
* existing fixture lends normalized-equivalent values, including ids, paths,
* existing fixture lends normalized-equivalent values, including non-message ids, paths,
* creation/event times, spill locators, and hook durations, only when the
* complete record layout aligns and volatile strings form a consistent
* bijection. Ambiguous layouts or mappings keep fresh strings. Packed timing
* envelopes expand for alignment, so packing does not shift later records;
* bijection. Complete durable-message ids are excluded because the later
* fixture-ready structural pass owns them. Ambiguous layouts or mappings
* keep fresh strings. Packed timing envelopes expand for alignment, so
* packing does not shift later records;
* fresh semantic values and fragment arrays remain authoritative.
*
* @param fresh The newly harvested session JSONL.
@@ -852,8 +1006,7 @@ export function stabilizeRefreshLog(
freshContext: NormalizeContext,
): string {
const freshRecords = parseJsonlRecords(fresh)
let stable = fresh
for (const { from, to } of replacements) stable = stable.split(from).join(to)
const stable = applyFixtureReplacements(fresh, replacements)
const existingRecords = logicalRecords(parseJsonlRecords(existing))
const records = parseJsonlRecords(stable)
const existingContext = fixtureContext(existing)
@@ -1048,10 +1201,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const portableFixture = scenario.workspaceParent === undefined
? tokenizeSessionFixtureCwd
: (log: string): string => log
const existingFixtures = REFRESHING
? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8')))
: []
const replacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) : []
const writesSessionFixtures = (RECORDING && scenario.recorded && scenario.hasModelTurn)
|| (REFRESHING && comparesLog)
if (writesSessionFixtures) {
@@ -1064,16 +1213,24 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
'session.jsonl',
...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`),
]
const primary = (result.sessionLogs[0] as HarvestedLog).content
await writeFile(join(dir, outputFixtureFiles[0] as string), scrub(portableFixture(
REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) : primary,
)))
for (let i = 1; i < result.sessionLogs.length; i++) {
const child = (result.sessionLogs[i] as HarvestedLog).content
await writeFile(join(dir, outputFixtureFiles[i] as string), scrub(portableFixture(
REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) : child,
)))
}
const existingFixtures = await Promise.all(outputFixtureFiles.map(async (file) => {
const path = join(dir, file)
return existsSync(path) ? readFile(path, 'utf8') : ''
}))
const refreshReplacements = REFRESHING
? refreshFixtureReplacements(result.sessionLogs, existingFixtures)
: []
const freshFixtures = REFRESHING
? result.sessionLogs.map((log, index) => scrub(portableFixture(stabilizeRefreshLog(
log.content,
existingFixtures[index] as string,
refreshReplacements,
ctx,
))))
: result.sessionLogs.map(log => scrub(portableFixture(log.content)))
const outputFixtures = stabilizeFixtureMessageIds(freshFixtures, existingFixtures)
await Promise.all(outputFixtures.map((fixture, index) =>
writeFile(join(dir, outputFixtureFiles[index] as string), fixture)))
if (RECORDING) {
const outputNames = new Set(outputFixtureFiles)
const entries = await readdir(dir, { withFileTypes: true })

View File

@@ -3,11 +3,13 @@
"logs": [
{ "file": "b/parent/session.jsonl", "lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}", "delegationDepth": 0 },
{ "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
{ "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
{ "type": "user/message", "seq": 1, "time": 5, "data": { "role": "user", "content": [{ "type": "text", "text": "same inherited message" }], "source": { "kind": "user" }, "id": "11111111-1111-4111-8111-111111111111" }, "surfaceOp": "append" }
]},
{ "file": "b/child/session.jsonl", "lines": [
{ "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 },
{ "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
{ "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
{ "type": "user/message", "seq": 1, "time": 5, "data": { "role": "user", "content": [{ "type": "text", "text": "same inherited message" }], "source": { "kind": "user" }, "id": "11111111-1111-4111-8111-111111111111" }, "surfaceOp": "append" }
]}
]
}

View File

@@ -1,2 +1,3 @@
{"type":"session","id":"abababab-cdcd-4efe-8ada-badabadabada","createdAt":800,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","parentSession":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","delegationDepth":1}
{"type":"request/header","seq":0,"time":2,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"user/message","seq":1,"time":5,"data":{"role":"user","content":[{"type":"text","text":"same inherited message"}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}

View File

@@ -1,2 +1,3 @@
{"type":"session","id":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","createdAt":700,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","delegationDepth":0}
{"type":"request/header","seq":0,"time":3,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"user/message","seq":1,"time":5,"data":{"role":"user","content":[{"type":"text","text":"same inherited message"}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}

View File

@@ -4,7 +4,13 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts'
import {
defineAcpSnapshotSuite,
stabilizeFixtureMessageIds,
tokenizeSessionFixtureCwd,
type HarvestedLog,
type Scenario,
} from '../src/index.ts'
import {
assertUniqueSnapshotContents,
claimSharedSnapshot,
@@ -200,6 +206,18 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => {
expect(readFileSync(join(recordDir, 'rec-child', 'tool-schemas.1.expected.json'), 'utf8'))
.toContain('"name": "t1"')
})
it('retains an unchanged message id across the recorded parent and child fixtures', () => {
const existingMessageId = '22222222-2222-4222-8222-222222222222'
const freshMessageId = '11111111-1111-4111-8111-111111111111'
const fixtures = ['session.jsonl', 'session.1.jsonl']
.map(file => readFileSync(join(recordDir, 'rec-child', file), 'utf8'))
for (const fixture of fixtures) {
expect(fixture).toContain(`"id":"${existingMessageId}"`)
expect(fixture).not.toContain(freshMessageId)
}
})
})
describe('defineAcpSnapshotSuite: registration contract', () => {
@@ -638,6 +656,139 @@ describe('unknownToolCallIds', () => {
})
})
describe('stabilizeFixtureMessageIds', () => {
it('reuses one committed message UUID across fixture-ready parent and child logs', () => {
const freshId = '11111111-1111-4111-8111-111111111111'
const existingId = '22222222-2222-4222-8222-222222222222'
const log = (session: string, id: string): string => [
JSON.stringify({ type: 'session', id: session, cwd: '{{cwd}}' }),
JSON.stringify({
type: 'user/message',
data: { role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' }, id },
}),
'',
].join('\n')
const fresh = [log('fresh-parent', freshId), log('fresh-child', freshId)]
const existing = [log('old-parent', existingId), log('old-child', existingId)]
const stable = stabilizeFixtureMessageIds(fresh, existing)
expect(stable).toHaveLength(2)
for (const fixture of stable) {
expect(fixture).toContain(`"id":"${existingId}"`)
expect(fixture).not.toContain(freshId)
}
})
it('rewrites only complete messages carried by surface events or durable inbox splices', () => {
const ids = {
freshUser: '11111111-1111-4111-8111-111111111111',
oldUser: '22222222-2222-4222-8222-222222222222',
freshAssistant: '33333333-3333-4333-8333-333333333333',
oldAssistant: '44444444-4444-4444-8444-444444444444',
freshTool: '55555555-5555-4555-8555-555555555555',
oldTool: '66666666-6666-4666-8666-666666666666',
oldMalformed: '77777777-7777-4777-8777-777777777777',
} as const
const message = (id: string, role: string, text: string): Record<string, unknown> => ({
id,
role,
content: [{ type: 'text', text }],
source: { kind: role === 'user' ? 'user' : 'model' },
})
const log = (userId: string, assistantId: string, toolId: string, malformedId: string): string => [
JSON.stringify({ type: 'session', id: 'same', cwd: '{{cwd}}' }),
JSON.stringify({
type: 'agent/inbox/spliced',
data: {
inserted: [
message(userId, 'user', 'user'),
{ ...message(userId, 'user', 'malformed inbox'), source: null },
],
},
}),
JSON.stringify({ type: 'user/message', data: message(userId, 'user', 'user') }),
JSON.stringify({ type: 'assistant/message', data: { message: message(assistantId, 'assistant', 'assistant') } }),
JSON.stringify({ type: 'tool/result', data: { message: message(toolId, 'tool', 'tool') } }),
JSON.stringify({ type: 'turn/start', data: { id: userId } }),
JSON.stringify({ type: 'steering/message', data: message(userId, 'user', 'obsolete') }),
JSON.stringify({ type: 'user/message', data: { ...message(userId, 'user', 'malformed'), source: null } }),
JSON.stringify({ type: 'user/message', data: message(malformedId, 'user', 'non-UUID') }),
JSON.stringify({ type: 'assistant/message', data: null }),
JSON.stringify({ type: 42, data: message(userId, 'user', 'non-string type') }),
'',
].join('\n')
const stable = stabilizeFixtureMessageIds(
[log(ids.freshUser, ids.freshAssistant, ids.freshTool, 'not-a-uuid')],
[log(ids.oldUser, ids.oldAssistant, ids.oldTool, ids.oldMalformed)],
)[0] as string
const records = stable.trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
const inserted = ((records[1]?.data as { inserted: Array<{ id: string }> }).inserted)
expect(inserted[0]?.id).toBe(ids.oldUser)
expect(inserted[1]?.id).toBe(ids.freshUser)
expect((records[2]?.data as { id: string }).id).toBe(ids.oldUser)
expect((records[3]?.data as { message: { id: string } }).message.id).toBe(ids.oldAssistant)
expect((records[4]?.data as { message: { id: string } }).message.id).toBe(ids.oldTool)
expect((records[5]?.data as { id: string }).id).toBe(ids.freshUser)
expect((records[6]?.data as { id: string }).id).toBe(ids.freshUser)
expect((records[7]?.data as { id: string }).id).toBe(ids.freshUser)
expect((records[8]?.data as { id: string }).id).toBe('not-a-uuid')
})
it('matches cwd-bearing messages only after the fresh log reaches fixture-ready form', () => {
const freshId = '11111111-1111-4111-8111-111111111111'
const existingId = '22222222-2222-4222-8222-222222222222'
const freshCwd = '/tmp/acp-snapshot-fresh-cwd'
const message = (id: string, path: string): Record<string, unknown> => ({
type: 'user/message',
data: {
id,
role: 'user',
content: [{ type: 'text', text: `read ${path}/input.txt` }],
source: { kind: 'user' },
},
})
const fresh = tokenizeSessionFixtureCwd([
JSON.stringify({ type: 'session', id: 'fresh', cwd: freshCwd }),
JSON.stringify(message(freshId, freshCwd)),
'',
].join('\n'))
const existing = [
JSON.stringify({ type: 'session', id: 'old', cwd: '{{cwd}}' }),
JSON.stringify(message(existingId, '{{cwd}}')),
'',
].join('\n')
expect(stabilizeFixtureMessageIds([fresh], [existing])[0]).toContain(`"id":"${existingId}"`)
})
it('rejects a fingerprint connected to an id that also identifies different content', () => {
const freshId = '11111111-1111-4111-8111-111111111111'
const conflictingId = '22222222-2222-4222-8222-222222222222'
const competingId = '33333333-3333-4333-8333-333333333333'
const message = (id: string, text: string): string => JSON.stringify({
type: 'user/message',
data: { id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } },
})
const fresh = `${message(freshId, 'shared')}\n`
const existing = [
message(conflictingId, 'shared'),
message(conflictingId, 'different'),
message(competingId, 'shared'),
'',
].join('\n')
expect(stabilizeFixtureMessageIds([fresh], [existing])).toEqual([fresh])
})
it('leaves fresh fixtures unchanged when no committed counterpart exists', () => {
const fresh = '{"type":"session","id":"new"}\n'
expect(stabilizeFixtureMessageIds([fresh], [''])).toEqual([fresh])
})
})
describe('refreshFixtureReplacements', () => {
it('maps fresh ids and cwd values to the existing fixture values, skipping non-replacements', () => {
const log = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content })
@@ -678,6 +829,30 @@ describe('refreshFixtureReplacements', () => {
{ from: freshBash, to: oldBash },
])
})
it('leaves complete message ids out of the literal refresh replacement list', () => {
const freshMessageId = '11111111-1111-4111-8111-111111111111'
const existingMessageId = '22222222-2222-4222-8222-222222222222'
const log = (sessionId: string, messageId: string): string => [
JSON.stringify({ type: 'session', id: sessionId, cwd: '/same' }),
JSON.stringify({
type: 'user/message',
data: {
id: messageId,
role: 'user',
content: [{ type: 'text', text: 'same' }],
source: { kind: 'user' },
},
}),
'',
].join('\n')
const replacements = refreshFixtureReplacements(
[{ id: 'diagnostic', createdAt: 1, content: log('fresh', freshMessageId) }],
[log('old', existingMessageId)],
)
expect(replacements).toEqual([{ from: 'fresh', to: 'old' }])
})
})
describe('stabilizeRefreshLog', () => {
@@ -780,6 +955,75 @@ describe('stabilizeRefreshLog', () => {
].join('\n'))
})
it('retains unchanged message ids across an unrelated inserted event', () => {
const freshUserId = '11111111-1111-4111-8111-111111111111'
const existingUserId = '22222222-2222-4222-8222-222222222222'
const freshAssistantId = '33333333-3333-4333-8333-333333333333'
const existingAssistantId = '44444444-4444-4444-8444-444444444444'
const user = (id: string): Record<string, unknown> => ({
type: 'user/message',
data: { role: 'user', content: [{ type: 'text', text: 'same user' }], source: { kind: 'user' }, id },
})
const assistant = (id: string): Record<string, unknown> => ({
type: 'assistant/message',
data: {
turn: 1,
step: 1,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'same assistant' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
id,
},
},
})
const lines = (records: Record<string, unknown>[]): string => [
JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }),
...records.map(record => JSON.stringify(record)),
'',
].join('\n')
const fresh = lines([
user(freshUserId),
{ type: 'session/inherited', data: {} },
assistant(freshAssistantId),
])
const existing = lines([user(existingUserId), assistant(existingAssistantId)])
const replacements = refreshFixtureReplacements(
[{ id: 'diagnostic', createdAt: 1, content: fresh }],
[existing],
)
const refreshed = stabilize(fresh, existing, replacements)
const intermediate = refreshed.trim().split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>)
expect((intermediate[1]?.data as { id: string }).id).toBe(freshUserId)
expect(((intermediate[3]?.data as { message: { id: string } }).message).id).toBe(freshAssistantId)
const output = (stabilizeFixtureMessageIds([refreshed], [existing])[0] as string).trim().split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>)
expect((output[1]?.data as { id: string }).id).toBe(existingUserId)
expect(((output[3]?.data as { message: { id: string } }).message).id).toBe(existingAssistantId)
})
it('leaves an aligned complete message id to the fixture-ready structural pass', () => {
const freshId = '11111111-1111-4111-8111-111111111111'
const existingId = '22222222-2222-4222-8222-222222222222'
const log = (id: string): string => [
JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }),
JSON.stringify({
type: 'user/message',
data: { id, role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' } },
}),
'',
].join('\n')
const fresh = log(freshId)
const existing = log(existingId)
const refreshed = stabilize(fresh, existing)
expect(refreshed).toContain(`"id":"${freshId}"`)
expect(stabilizeFixtureMessageIds([refreshed], [existing])[0]).toContain(`"id":"${existingId}"`)
})
it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => {
const fresh = [
'{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}',

View File

@@ -13,6 +13,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/support/llm-replay/README.md
README.md: ee062d0c2804905f33f1ff476d12bb6dd57666e5
README.zh.md: ab3420d9500a6ca77f04a2ad96095f8883aeb874
README.md: 46d391970f320708914d11f0868cbbc5361ae196
README.zh.md: a67b078a1396968dc3ddecb0e616a832c4faaf3a

View File

@@ -8,7 +8,9 @@ Its consumers are the ACP and headless `stream-json` snapshot suites plus the We
## How the fixture works
The fixture IS the persisted session log (`<scenario>/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` events and the line-0 session header.
The fixture IS the persisted session log (`<scenario>/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each agent-loop `stream()` call's chunk sequence. A successful compaction summarizer is logged differently: when `compact/summary` carries its complete `rawOutput`, replay reconstructs a canonical successful stream at that event's position using one `block-start`/`block-end` pair per block, the recorded usage when present, and a terminal `stop`. Exact provider delta partitioning is not part of the durable compaction result. A summary without `rawOutput` does not imply an LLM call because template and remote summarizers may produce it without the local adapter.
Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` and `compact/summary` events plus the line-0 session header.
Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`<scenario>/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update.
@@ -57,7 +59,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars.
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing).
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and complete compaction outputs in a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
- Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`.
## Plugin export shape
@@ -74,5 +76,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently (or a compaction summarize call landing mid-run) would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`).
- **Only chunk-producing calls are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs.
- **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`).
- **Only ordinary loop chunks and completed compaction outputs are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs.

View File

@@ -8,7 +8,9 @@
## fixture 的工作方式
fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 `stream()` 调用的分片序列(每个循环步骤调用一次模型)。因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk` 事件和第 0 行的会话 header
fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 agent-loop `stream()` 调用的分片序列。压缩compaction摘要器成功时日志记录方式有所不同`compact/summary` 携带完整的 `rawOutput` 时,回放会在该事件的位置重建一条规范成功流,其中每个块各使用一对 `block-start`/`block-end`,带上已记录的 usage如有并以 `stop` 终止。提供方增量的精确切分不属于持久压缩结果。不带 `rawOutput` 的摘要并不意味着发生了 LLM 调用,因为模板摘要器和远程摘要器可能不经本地适配器生成该摘要
因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk``compact/summary` 事件以及第 0 行的会话 header。
有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`<scenario>/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。
@@ -57,7 +59,7 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
- `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR热模块替换安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。
- `loadSessionScripts(config)`:解析场景中有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。
- `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生fixture 缺失时明确报错)。
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志中的普通 loop 分片和完整压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。
- 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`
## 插件导出形态
@@ -74,5 +76,5 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
## 已知限制与暂缓事项
- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut或运行中发生的上下文压缩context compaction摘要调用会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。
- **只有会产生分片的调用才能派生**:在产生分片前直接抛出异常或取消/挂起的场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。
- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut 会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。
- **只有普通 loop 分片和已完成的压缩输出才能派生**:在产生分片前直接抛出异常或取消/挂起的场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。

View File

@@ -25,12 +25,14 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

Some files were not shown because too many files have changed in this diff Show More