From b0a2011d95277d4cbacfe2a05082a4f51c5db9f0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:20:06 +0800 Subject: [PATCH] docs: bilingual credentials/settings-consumer documentation, catalogs, and gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New credentials data-structure page (type-equiv manifested), group README, rewritten llm-deepseek/llm-pi-ai READMEs (dynamic configuration, dict profiles, credential chain), capability-seams/service-role registration, Agent Note (bilingual), demo compositions mounting settings-local + credentials-local with no inline key plumbing, installSettingsSection consumer helper on the settings seam (deduplicating both adapters' wiring), jscpd symmetry markers for the provider twins, runtime-closure additions for python/sdk-runtime, and doc-budget ceilings AGENTS.md 1750→1755 / packages/README.md 850→865 for the structural one-line group rows. --- ...est-level-llm-config-credentials.i18n.yaml | 6 +++ ...29-request-level-llm-config-credentials.md | 29 ++++++++++ ...request-level-llm-config-credentials.zh.md | 29 ++++++++++ AGENTS.md | 1 + docs/capability-seams.md | 12 ++++- docs/config-catalog.md | 53 +++++++++++++------ docs/cordis-catalog/events.md | 21 ++++++++ docs/cordis-catalog/services.md | 46 ++++++++++++++++ docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + .../credentials.i18n.yaml | 6 +++ docs/core-data-structures/credentials.md | 50 +++++++++++++++++ docs/core-data-structures/credentials.zh.md | 50 +++++++++++++++++ docs/event-producer-consumer.md | 1 + examples/headless-agent/composition.md | 6 +++ examples/headless-agent/cordis.yml | 25 ++++++--- .../credentials.cordis.snapshot.yml | 15 ++---- examples/package.json | 2 +- examples/tui-agent/composition.md | 6 +++ examples/tui-agent/cordis.yml | 14 ++++- packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 41 ++++++++++++++ packages/credentials/README.i18n.yaml | 6 +++ packages/credentials/README.md | 14 +++++ packages/credentials/README.zh.md | 14 +++++ .../credentials-local/README.i18n.yaml | 6 +++ .../credentials/credentials-local/README.md | 2 +- .../credentials-local/README.zh.md | 16 +++--- .../credentials-local/src/index.ts | 8 +++ .../credentials/credentials/README.i18n.yaml | 6 +++ packages/credentials/credentials/README.md | 5 +- packages/credentials/credentials/README.zh.md | 29 +++++----- .../credentials/credentials/tests/memory.ts | 2 - packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 18 +++++-- packages/llm/llm-deepseek/README.zh.md | 18 +++++-- packages/llm/llm-deepseek/src/index.ts | 20 +++---- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 30 +++++++---- packages/llm/llm-pi-ai/README.zh.md | 30 +++++++---- packages/llm/llm-pi-ai/src/index.ts | 20 +++---- packages/settings/settings/src/index.ts | 50 +++++++++++++++++ .../settings/settings/tests/settings.spec.ts | 45 +++++++++++++++- packages/util/README.i18n.yaml | 6 +-- packages/util/README.md | 1 + packages/util/README.zh.md | 1 + packages/util/atomic-write/README.i18n.yaml | 6 +++ packages/util/atomic-write/README.md | 6 +++ packages/util/atomic-write/README.zh.md | 18 ++++--- pnpm-lock.yaml | 9 ++++ python/sdk-runtime/package.json | 25 +++++---- scripts/doc-budgets.manifest.json | 4 +- scripts/gen-cordis-catalog.ts | 3 ++ scripts/gen-doc-graphs.ts | 13 ++++- scripts/project-doc-site.spec.ts | 2 +- scripts/type-equiv.manifest.json | 15 ++++++ .../verify-package-readme-model-experience.ts | 3 ++ website/docs.ts | 1 + 61 files changed, 732 insertions(+), 153 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md create mode 100644 docs/core-data-structures/credentials.i18n.yaml create mode 100644 docs/core-data-structures/credentials.md create mode 100644 docs/core-data-structures/credentials.zh.md create mode 100644 packages/credentials/README.i18n.yaml create mode 100644 packages/credentials/README.md create mode 100644 packages/credentials/README.zh.md create mode 100644 packages/credentials/credentials-local/README.i18n.yaml create mode 100644 packages/credentials/credentials/README.i18n.yaml create mode 100644 packages/util/atomic-write/README.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml new file mode 100644 index 0000000000..f54a4bdbe6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +2026-07-29-request-level-llm-config-credentials.md: 13fefff9fe2646a9ef8e7200bd908d8764e006ea +2026-07-29-request-level-llm-config-credentials.zh.md: 5e29946ade3d8b2b8ae51075c29c14867fe29f71 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md new file mode 100644 index 0000000000..13fefff9fe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -0,0 +1,29 @@ +# Agent Note: request-level LLM configuration and the credential seam + +Status: implemented + +English | [中文](2026-07-29-request-level-llm-config-credentials.zh.md) + +> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins), the new `packages/credentials/` capability family, and the `packages/util/atomic-write` extraction. The follow-up wire surface (`settings.*`/`credentials.*` RPC, secret-role masking, the web settings form) is a separate PR and not part of this note's shipped scope. + +## Problem + +The [settings seam](2026-07-28-user-settings-seam.md) shipped without a production consumer, and the LLM adapters were the motivating one: both froze `apiKey`/`baseURL`/catalog into adapter instances at plugin load, so a changed key or endpoint needed a process restart, and a missing key failed plugin load — the worst possible first-run posture for a personal config page ("store a key, then restart"). Secrets were also headed the wrong way: the natural move (put `apiKey` in the settings document) would have forced masking, server-side backfill on `replace`, and dotfiles-sync warnings, a mitigation stack for a problem peer products simply do not have — Codex (`env_key` + auth.json), Reasonix (`api_key_env` + home `.env`), OpenCode/Pi (`auth.json`), Claude Code (`apiKeyHelper`) all keep secrets out of configuration files. + +## Decision + +**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. + +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. + +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions. + +## Alternatives considered + +- **A bridge plugin (`dsh-llm-models`) owning one unified `models` dict** — with per-plugin namespaces there is nothing left to bridge, and the adapter-mapping rules it needed were pure invented indirection. +- **Secrets in settings.yaml under `role('secret')` masking** — deleting the problem (references) beats mitigating it (mask + backfill + sync warnings); the coding-agent cohort is unanimous. +- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; re-registering the route in place keeps that contract and stays observable. + +## Consequences + +Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md new file mode 100644 index 0000000000..5e29946ade --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -0,0 +1,29 @@ +# Agent Note:请求级 LLM 配置与凭据 seam + +Status: implemented + +[English](2026-07-29-request-level-llm-config-credentials.md) | 中文 + +> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)、新增的 `packages/credentials/` 能力族,以及 `packages/util/atomic-write` 的抽取。后续的 wire 面(`settings.*`/`credentials.*` RPC、secret 角色脱敏、web 设置表单)是单独的 PR,不在本 note 已交付范围内。 + +## 问题 + +[settings seam](2026-07-28-user-settings-seam.md) 落地时没有生产消费方,而 LLM 适配器正是当初驱动该 seam 的那个消费方:两个适配器都在插件加载时把 `apiKey`/`baseURL`/catalog 冻结进适配器实例,改密钥或端点就要重启进程,密钥缺失则直接使插件加载失败——对个人配置页而言,这是最糟糕的首次运行姿态(「先存密钥,再重启」)。机密的走向也不对:顺理成章的做法(把 `apiKey` 放进设置文档)会被迫引入脱敏、`replace` 时的服务端回填与 dotfiles 同步告警,为一个同类产品根本没有的问题堆起一整摞缓解措施——Codex(`env_key` + auth.json)、Reasonix(`api_key_env` + 家目录 `.env`)、OpenCode/Pi(`auth.json`)、Claude Code(`apiKeyHelper`)全都把机密挡在配置文件之外。 + +## 决策 + +**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 + +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 + +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引。 + +## 曾考虑的替代方案 + +- **由桥接插件(`dsh-llm-models`)持有统一的 `models` 字典**——有了按插件划分的 namespace,就没有什么可桥接的了;它所需的适配器映射规则纯属凭空发明的间接层。 +- **把机密放进 settings.yaml 并靠 `role('secret')` 脱敏**——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);编码 agent 同类产品在这一点上口径一致。 +- **注册表级的实时重试策略**——让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;原地重新注册路由既保住该契约,又保持可观察。 + +## 后果 + +上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。 diff --git a/AGENTS.md b/AGENTS.md index 4fb65caa11..959013e110 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// hooks/ Claude Code/Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends settings/ user-settings seam + file-backed provider + credentials/ credential-reference seam + env-over-.env provider acp/ automation-only Agent Client Protocol server ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6d324f222a..8dbd7b964f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -38,6 +38,9 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_local["settings-local"] + pkg_credentials["credentials"] + svc_credentials["ctx.credentials
Credential seam"] + pkg_credentials_local["credentials-local"] pkg_session_telemetry["session-telemetry"] svc_telemetry["ctx.telemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -167,6 +170,8 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_credentials --> svc_credentials + pkg_credentials_local --> svc_credentials pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -247,6 +252,8 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_credentials --> pkg_llm_deepseek + svc_credentials --> pkg_llm_pi_ai svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection svc_httpServer --> pkg_hmr @@ -284,6 +291,8 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_inprocess + svc_settings --> pkg_llm_deepseek + svc_settings --> pkg_llm_pi_ai svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain @@ -332,7 +341,8 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3dfe2c95f6..d3844268ac 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -379,6 +379,24 @@ export interface ToolResultPruneConfig { Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts) +## `@deepseek-ai/dsh-credentials-local` + +```ts config-catalog +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Credentials document path; defaults to `.env` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} +``` + +Source: [`packages/credentials/credentials-local/src/index.ts:24`](../packages/credentials/credentials-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -562,15 +580,18 @@ Requires: `llm` ```ts config-catalog /** - * Plugin config, validated by the same-named schemastery schema. Every field - * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), omitted thinking - * mode uses the provider default, and omitted reasoning effort resolves to - * `high`. + * Plugin config, validated by the same-named schemastery schema and doubling + * as the `llm-deepseek` settings-section shape. Every field is optional in + * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each + * request (a request without any key fails with `MISSING_CREDENTIAL`, not at + * plugin load), omitted thinking mode uses the provider default, and omitted + * reasoning effort resolves to `high`. */ export interface Config { - /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ + /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ 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. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ @@ -602,25 +623,25 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` Requires: `llm` ```ts config-catalog -/** Plugin configuration: the non-empty provider profiles this instance owns. */ +/** Plugin configuration: the non-empty provider routes this instance owns. */ export interface Config { - /** Non-empty set of pi-ai provider routes this adapter instance owns. */ - providers: PiAiProviderProfile[] + /** Non-empty dict of pi-ai provider routes, keyed by provider. */ + providers: Record } -/** Configuration for one pi-ai provider route. */ +/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** pi-ai provider catalog name and Harness route key. */ - provider: string - /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */ + /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ + apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ @@ -646,7 +667,7 @@ export interface PiAiProviderProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:62`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -2232,6 +2253,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) +- `@deepseek-ai/dsh-credentials` — abstract `Credentials` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) @@ -2250,6 +2272,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) +- `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c7587e3932..e38a806254 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -422,6 +422,27 @@ A command was registered or unregistered. This is an unfiltered registry notific Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) +## `credentials/*` + +### `credentials/updated` — emit + +Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. + +```ts cordis-catalog +/** + * Committed change to a provider-managed credential source: a `set`, an + * `unset`, or an external edit observed in storage. Ambient + * process-environment changes are not observable and never emit. + * @param ref - the reference whose stored value changed. + * @mode emit + */ +'credentials/updated'(ref: CredentialRef): void +``` + +Types: [CredentialRef](../core-data-structures/credentials.md) + +Source: [`packages/credentials/credentials/src/index.ts:62`](../../packages/credentials/credentials/src/index.ts) + ## `domain/*` ### `domain/changed` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 35459c471b..d4b9c54c4b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -488,6 +488,52 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.credentials` — `Credentials` (abstract seam) + +Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. + +```ts cordis-catalog +/** + * Resolve one reference to its current value. Resolution is per call: + * consumers re-resolve at each operation and must not cache across + * operations — that per-operation read is what makes a changed credential + * reach the next operation without a restart. + * @param ref - the reference to resolve. + * @returns the value and its source, or `undefined` while unconfigured. + */ +abstract resolve(ref: CredentialRef): Promise + +/** + * Describe one reference for configuration surfaces without exposing the + * value. + * @param ref - the reference to describe. + * @returns configured state, supplying source, and writability. + */ +abstract describe(ref: CredentialRef): Promise + +/** + * Durably store one value in the provider-managed writable source. Rejects + * while a read-only source shadows the reference — the write would appear + * to succeed while resolution keeps returning the shadowing value — and + * rejects an empty value (use {@link unset}). + * @param ref - the reference to store. + * @param value - the non-empty secret value. + */ +abstract set(ref: CredentialRef, value: string): Promise + +/** + * Remove one reference from the provider-managed writable source; removing + * an absent reference is a no-op. Rejects while a read-only source shadows + * the reference, like {@link set}. + * @param ref - the reference to remove. + */ +abstract unset(ref: CredentialRef): Promise +``` + +Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md) + +Source: [`packages/credentials/credentials/src/index.ts:72`](../../packages/credentials/credentials/src/index.ts) + ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b583b53f2a..0663e38f9e 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: ca178edc941903f0432ed9b340db963f1a95f223 -core.zh.md: d325cfefca60f5247492b64d5ceecdb552e57efb +core.md: e2ba74e5922f55c71ebc9f08691659603ef1fa6a +core.zh.md: 3ce9212f35e9c8367f462d6ab0cac695f745c3b0 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ca178edc94..e2ba74e592 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -25,6 +25,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | +| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d325cfefca..3ce9212f35 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -25,6 +25,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | +| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml new file mode 100644 index 0000000000..23bb940afe --- /dev/null +++ b/docs/core-data-structures/credentials.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 docs/core-data-structures/credentials.md +credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 +credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md new file mode 100644 index 0000000000..3f6fcd127d --- /dev/null +++ b/docs/core-data-structures/credentials.md @@ -0,0 +1,50 @@ +# User Credentials + +English | [中文](credentials.zh.md) + +The credential seam of [dsh-credentials](../../packages/credentials/credentials) keeps secrets out of configuration: settings sections and `cordis.yml` entries carry *references* (environment-variable names), providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) own the values, and consumers resolve a reference once per operation — the LLM adapters resolve once per model request, so a rotated credential reaches the very next request without any restart. One seam-wide rule binds every provider: an empty stored value is absent everywhere. + +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + +## Identity + +A reference names one credential as a POSIX-style environment-variable name. The brand keeps references from mixing with other cross-boundary strings; construction validates the shell-identifier shape. + +```ts type-equiv +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +type CredentialRef = Branded<'CredentialRef'> +``` + +## Resolution + +`resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. + +```ts type-equiv +/** One resolved credential value and the source layer that supplied it. */ +interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} +``` + +## Description + +`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. + +```ts type-equiv +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} +``` + +## Change commits + +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge. diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md new file mode 100644 index 0000000000..b5d2d9e164 --- /dev/null +++ b/docs/core-data-structures/credentials.zh.md @@ -0,0 +1,50 @@ +# 用户凭据 + +[English](credentials.md) | 中文 + +[dsh-credentials](../../packages/credentials/credentials) 的凭据 seam 把机密挡在配置之外:settings 分节与 `cordis.yml` 条目携带的是*引用*(环境变量名),值归 [dsh-credentials-local](../../packages/credentials/credentials-local) 这类 provider 所有,消费方每个操作解析一次引用——LLM 适配器每次模型请求解析一次,因此轮换后的凭据无需任何重启即可作用于紧随其后的下一次请求。一条 seam 级规则约束每个 provider:空的存储值在任何地方都视为不存在。 + +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + +## 标识 + +引用以 POSIX 风格环境变量名命名一条凭据。brand 使引用不与其他跨边界字符串混用;构造时校验 shell 标识符形态。 + +```ts type-equiv +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +type CredentialRef = Branded<'CredentialRef'> +``` + +## 解析 + +`resolve(ref)` 返回值,连同供出该值、由 provider 定义的来源层;未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这次按操作进行的读取正是热更新机制。 + +```ts type-equiv +/** One resolved credential value and the source layer that supplied it. */ +interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} +``` + +## 描述 + +`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地 provider 把由活跃进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 + +```ts type-equiv +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} +``` + +## 变更提交 + +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 90f830408a..e1120093e6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,6 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:62`](../packages/credentials/credentials/src/index.ts) | [`credentials-local`](../packages/credentials/credentials-local) (`emit`) | [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 53a01260e1..38774195e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -8,6 +8,10 @@ The headless demo combines the real DeepSeek adapter and coding capabilities wit ```mermaid flowchart LR cfg["examples/headless-agent
cordis.yml"] + plugin_headless_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_headless_settings + plugin_headless_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_headless_credentials plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -55,6 +59,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | +| `settings` | `@deepseek-ai/dsh-settings-local` | +| `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 896c73469b..3fc363ea0f 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -1,16 +1,27 @@ # One-shot coding agent with format-pure stdout. The app bin loads the -# gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. +# gitignored root `.env` into the process environment; entry configs here are +# the composition base, while user-plane values resolve per request through +# the two providers below. + +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a +# `llm-deepseek:` section there overrides the adapter entry below without a +# restart. +- id: settings + name: '@deepseek-ai/dsh-settings-local' + +# Credential store: the live process environment over `$DSH_HOME/.env` +# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` +# through it at each request, so no key is inlined in this file. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed -# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). -# Shipped default: full thinking at max effort on every request (wire-only -# defaults; they never enter the request header). +# twin (a `providers` dict keyed by route; `reasoning: high` replaces +# thinking/reasoningEffort). Shipped default: full thinking at max effort on +# every request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max models: diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml index 7e85b90df7..10bc2591c8 100644 --- a/examples/headless-agent/credentials.cordis.snapshot.yml +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -1,6 +1,6 @@ -# Keyless dynamic-configuration composition: the settings and credentials -# providers live under the run cwd, no API key exists anywhere, and the -# deepseek route still registers — so the prompt fails with the actionable +# Keyless dynamic-configuration composition: the base settings and credentials +# providers see only the isolated run home, no API key exists anywhere, and +# the deepseek route still registers — so the prompt fails with the actionable # MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. - id: base name: '@cordisjs/plugin-include' @@ -11,15 +11,6 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: - - id: settings - name: '@deepseek-ai/dsh-settings-local' - config: - dshHome: ./.dsh - debounceMs: 10 - - id: credentials - name: '@deepseek-ai/dsh-credentials-local' - config: - dshHome: ./.dsh # The endpoint is never dialed: credential resolution fails first. - id: llm-deepseek-keyless name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/examples/package.json b/examples/package.json index 35d9a74e3e..48885a1c35 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index c6fc223113..69cd9704a3 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -10,6 +10,10 @@ flowchart LR cfg["examples/tui-agent
cordis.yml"] plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] cfg --> plugin_tui_hmr + plugin_tui_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_tui_settings + plugin_tui_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_tui_credentials plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -70,6 +74,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | +| `settings` | `@deepseek-ai/dsh-settings-local` | +| `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 7c8b03db05..23f13a0218 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -10,13 +10,23 @@ config: root: ['.'] +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a +# `llm-deepseek:` section there overrides the adapter entry below without a +# restart. +- id: settings + name: '@deepseek-ai/dsh-settings-local' + +# Credential store: the live process environment over `$DSH_HOME/.env` +# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` +# through it at each request, so no key is inlined in this file. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' + # The native DeepSeek adapter. Shipped default: full thinking at max effort on # every request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index e720be54b1..d7bb88710c 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 205fd060de43b33b1e9ddbd72a2a8fd2b526a889 -README.zh.md: 57d15ad3d78e941393059001eafffd7468633696 +README.md: 48fa3272f7e024a295e7beaa68b9365aac379319 +README.zh.md: 686a9123f5f89ac244f8ef880e78609a131eb8b9 diff --git a/packages/README.md b/packages/README.md index 205fd060de..48fa3272f7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -39,6 +39,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface | | [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface | +| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface | | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 57d15ad3d7..686a9123f5 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -39,6 +39,7 @@ | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 | | [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 | +| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 | | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f90451084a..2cdfa4449a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -264,6 +264,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'credentials', + summary: 'Abstract credential service.', + methods: [ + { + signature: 'abstract resolve(ref: CredentialRef): Promise', + jsDoc: '/**\n * Resolve one reference to its current value. Resolution is per call:\n * consumers re-resolve at each operation and must not cache across\n * operations — that per-operation read is what makes a changed credential\n * reach the next operation without a restart.\n * @param ref - the reference to resolve.\n * @returns the value and its source, or `undefined` while unconfigured.\n */', + }, + { + signature: 'abstract describe(ref: CredentialRef): Promise', + jsDoc: '/**\n * Describe one reference for configuration surfaces without exposing the\n * value.\n * @param ref - the reference to describe.\n * @returns configured state, supplying source, and writability.\n */', + }, + { + signature: 'abstract set(ref: CredentialRef, value: string): Promise', + jsDoc: '/**\n * Durably store one value in the provider-managed writable source. Rejects\n * while a read-only source shadows the reference — the write would appear\n * to succeed while resolution keeps returning the shadowing value — and\n * rejects an empty value (use {@link unset}).\n * @param ref - the reference to store.\n * @param value - the non-empty secret value.\n */', + }, + { + signature: 'abstract unset(ref: CredentialRef): Promise', + jsDoc: '/**\n * Remove one reference from the provider-managed writable source; removing\n * an absent reference is a no-op. Rejects while a read-only source shadows\n * the reference, like {@link set}.\n * @param ref - the reference to remove.\n */', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1208,6 +1230,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, + { + name: 'credentials/updated', + mode: 'emit', + signature: '\'credentials/updated\'(ref: CredentialRef): void', + jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', + summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.', + }, { name: 'domain/changed', mode: 'emit', @@ -1674,6 +1703,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', }, + { + name: 'CredentialInfo', + declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}', + }, + { + name: 'CredentialRef', + declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;', + }, { name: 'DiffCallView', declaration: 'export interface DiffCallView {\n card: \'diff\';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n}', @@ -2058,6 +2095,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedAlwaysRetryPolicy', declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', }, + { + name: 'ResolvedCredential', + declaration: 'export interface ResolvedCredential {\n value: string;\n source: string;\n}', + }, { name: 'ResolvedNormalRetryPolicy', declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml new file mode 100644 index 0000000000..e8b35ba48e --- /dev/null +++ b/packages/credentials/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/README.md +README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 +README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b diff --git a/packages/credentials/README.md b/packages/credentials/README.md new file mode 100644 index 0000000000..1d450cbeef --- /dev/null +++ b/packages/credentials/README.md @@ -0,0 +1,14 @@ +# credentials/ + +English | [中文](README.zh.md) + +The credential capability seam, as three-package shape dictates (interface / implementation / consumers): + +| Package | Role | +|---|---| +| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event | +| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) | + +Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything. + +The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md new file mode 100644 index 0000000000..843230c3ce --- /dev/null +++ b/packages/credentials/README.zh.md @@ -0,0 +1,14 @@ +# credentials/ + +[English](README.md) | 中文 + +凭据能力 seam,按三包形态的要求组织(接口/实现/消费方): + +| 包 | 角色 | +|---|---| +| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | +| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | + +配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 + +seam 形状为 keyring、辅助命令与 KMS 后端的 provider 留有余地。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml new file mode 100644 index 0000000000..23cf5bb09b --- /dev/null +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 277c7db02836819e34a5c2db8aaf542c8eeec162 +README.zh.md: af1b840142d214b8cd8cf59690cb5773fae2e867 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 55b73c2ac6..277c7db028 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -32,7 +32,7 @@ External edits publish `credentials/updated` per changed reference after the sna ## Model Experience -Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. +Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface. #### KV Cache effect diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 2563c973a7..af1b840142 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,14 +2,14 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.zh.md) provider:两层来源,一条诚实的优先级。 +文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| | 活跃进程环境 | `env` | 否 | 恒定优先 | | `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | -环境优先,因为启动时注入(`DEEPSEEK_API_KEY=… dsh`、CI secrets、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须**可见地**只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 +环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 ## 配置 @@ -18,25 +18,25 @@ | `path` | `/.env` | 凭据文档位置。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | | `watch` | `true` | 热发布外部编辑。 | -| `debounceMs` | `100` | watcher 写入沉降窗口。 | +| `debounceMs` | `100` | watcher 写入稳定窗口。 | ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.zh.md),权限 `0600`。 +dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.md),权限 `0600`。 -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值、以及已经跨越多个物理行的条目,响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 +值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后一份好快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 ## Model Experience -Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. +经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect -No direct invalidation; credentials never enter a request prefix. +无直接失效;凭据绝不进入请求前缀。 ## Known Limitations and Deferred Work diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 853a1ce015..c1fcd37f16 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -118,6 +118,9 @@ function upsertLine(text: string | undefined, ref: CredentialRef, line: string | /** File-backed credentials provider (`$DSH_HOME/.env`). */ 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 + shape would couple the two providers' teardown semantics across packages. */ static Config: z = z.object({ path: z.string(), dshHome: z.string(), @@ -145,6 +148,7 @@ export class CredentialsLocal extends Credentials { private isClosed(): boolean { return this.closed } + /* jscpd:ignore-end */ constructor(ctx: Context, public config: Config) { super(ctx) @@ -162,6 +166,9 @@ export class CredentialsLocal extends Credentials { } await this.loadInitial() if (!this.spec.watch) return + /* jscpd:ignore-start -- same watcher discipline as settings-local by design: + the serialized-refresh and quiesce-on-dispose shape is the reviewed + lifecycle contract, not accidental repetition. */ const watcher = chokidarWatch(this.spec.filename, { ignoreInitial: true, awaitWriteFinish: { @@ -183,6 +190,7 @@ export class CredentialsLocal extends Credentials { this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) this.ctx.logger.warn(error) }) + /* jscpd:ignore-end */ yield async () => { // Quiesce: stop accepting events, close the watcher, then wait out any // queued or in-flight refresh so nothing publishes after disposal. diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml new file mode 100644 index 0000000000..10fe5f0ffe --- /dev/null +++ b/packages/credentials/credentials/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 48b7b0f952..1c18c47623 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -13,8 +13,11 @@ Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: ## Surface ```ts +import type { Context } from 'cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' +declare const ctx: Context + const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value @@ -32,7 +35,7 @@ The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only so ## Model Experience -Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. +Indirectly, through the consuming LLM adapters: a resolved value authorizes their provider requests, and the adapter owns every model-visible surface. #### KV Cache effect diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index ef9e32ebad..751fb7c1e8 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -4,42 +4,45 @@ 抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: -**配置只携带对秘密的引用,绝不携带秘密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答"配置了吗、来自哪层、能否写入";轮换秘密不触碰任何配置文件。 +**配置只携带对机密的引用,绝不携带机密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答「配置了吗、来自哪层、能否写入」;轮换机密不触碰任何配置文件。 -**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM adapter 每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 +**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM 适配器每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 -**空的存储值等于不存在。** 处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的秘密。 +**空的存储值等于不存在。**处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。 ## 接口面 ```ts +import type { Context } from 'cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' -const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell 标识符,品牌类型 +declare const ctx: Context + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } —— 绝不含值 -await ctx.credentials.set(ref, 'sk-…') // 被只读来源遮蔽时拒绝 -await ctx.credentials.unset(ref) // 不存在时为 no-op;同样的遮蔽规则 +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref +await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule ``` -`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新"已配置"徽标。 +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 -`set`/`unset` 的遮蔽规则是刻意的 fail-loud:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 +`set`/`unset` 的遮蔽规则是刻意的响亮失败:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 ## 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/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 ## Model Experience -Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. +经由消费它的 LLM 适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect -No direct invalidation; credentials never enter a request prefix. +无直接失效;凭据绝不进入请求前缀。 ## Known Limitations and Deferred Work -- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费者。 +- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方。 - **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 - **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts index c562d8ab0a..dc1ed77a06 100644 --- a/packages/credentials/credentials/tests/memory.ts +++ b/packages/credentials/credentials/tests/memory.ts @@ -47,5 +47,3 @@ export class MemoryCredentials extends Credentials { return Promise.resolve() } } - -export default MemoryCredentials diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 7f7619b003..48dac1d70f 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 7a2314ea2ca0fb6606310a4961fcbc658240a7b8 -README.zh.md: 523bbbfd29b4598c024a1fff4a7121a7cb88bf41 +README.md: 88f4fd7c017a5dbb070bdaf8ee47bb5610b23303 +README.zh.md: 5331a4d44c08e2fc4a5f8486128079d9b01e8454 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7a2314ea2c..88f4fd7c01 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -14,8 +14,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback - baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + 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 streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default @@ -44,6 +45,15 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und `streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +## Dynamic configuration (settings + credentials) + +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: a 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. 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')` always reports the current policy. + ## App attribution Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. @@ -62,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. ## Model Experience @@ -96,6 +106,8 @@ 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 schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. - **`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)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 523bbbfd29..5331a4d44c 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -14,8 +14,9 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback - baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + 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 streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default @@ -44,6 +45,15 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE `streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久 agent 步骤边界单独执行该策略。 +## 动态配置(settings + credentials) + +连接事实不在加载时冻结。`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 时——读取原始环境变量。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 + +唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。 + ## 应用归因 每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 @@ -62,7 +72,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -96,6 +106,8 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 +- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 +- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 跨越协议。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 054f1984b0..bb2ccdaa11 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -17,7 +17,7 @@ import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/ds import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' -import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' @@ -231,18 +231,10 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek"; requests will fail until one is configured') }) - ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(NS, Config, { base: config }) - current = () => scope.get() - sctx.effect(() => () => { - // Settings detached (provider disposed or reloading): fall back to the - // composition entry so the plugin keeps working exactly as configured. - current = () => config - ensureRegistrationFacts() - }) - ensureRegistrationFacts() - scope.watch(() => { - ensureRegistrationFacts() - }) + installSettingsSection(ctx, NS, Config, config, { + setSource: (source) => { + current = source + }, + onChange: ensureRegistrationFacts, }) } diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 38b9c093f6..98e7579f80 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: ac47cf6a21285fc887948a5a7798a9f1cb9157b0 -README.zh.md: a3d864ed9068d8bdaff4c5b73a4b7b339802ae05 +README.md: 21e1f6f11777d9de230f26c00046248e111cf0b0 +README.zh.md: 4f8423bd9a5b812f97a3360218ed1a35a9e58dae diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index ac47cf6a21..21e1f6f117 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,21 +2,21 @@ English | [中文](README.zh.md) -Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. ## Config -Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. +Configure credentials 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 delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY + openai: + apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high retryPolicy: @@ -26,22 +26,28 @@ Configure credentials and deployment-specific transport settings per provider. O initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - - provider: openrouter - apiKey: !!js process.env.OPENROUTER_API_KEY + openrouter: + apiKeyEnv: OPENROUTER_API_KEY headers: X-Deployment: production ``` -Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. + +## Dynamic configuration (settings + credentials) + +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; the raw environment variable without a mounted seam), then pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin re-registers the same adapter instance in one synchronous section, so `ctx.llm.listProviders()` and `providerRetryPolicy()` always reflect the current configuration. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, 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, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. 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 any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `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 `apiKey`, `apiKeyEnv`, `baseURL`, `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`. @@ -71,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -105,6 +111,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work +- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. +- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`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. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index a3d864ed90..4f8423bd9a 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -2,21 +2,21 @@ [English](README.md) | 中文 -基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM seam 通用多提供方适配器。一个插件实例拥有显式提供方 profile 列表;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 +基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 包根目录公开 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 ## 配置 -按提供方配置凭证与部署特定传输设置。省略 `apiKey` 会将认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 +按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件;两者都省略则把认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY + openai: + apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high retryPolicy: @@ -26,22 +26,28 @@ initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - - provider: openrouter - apiKey: !!js process.env.OPENROUTER_API_KEY + openrouter: + apiKeyEnv: OPENROUTER_API_KEY headers: X-Deployment: production ``` -每个提供方名称必须存在于 pi-ai 已安装 catalog 中,且在此插件实例中最多出现一次。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 + +## 动态配置(settings + credentials) + +适配器经由一个 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 时为原始环境变量),最后是 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会在一个同步区段内重新注册同一适配器实例,因此 `ctx.llm.listProviders()` 与 `providerRetryPolicy()` 始终反映当前配置。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 `reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型协议拼写仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的 normal 默认值。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的 normal 默认值。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent 级重试预算。Idle 过期会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -71,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用一次协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。真实 API 覆盖仍位于由 key 调节的 `pnpm run test:e2e` 下。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用一次协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。真实 API 覆盖仍位于由 key 调节的 `pnpm run test:e2e` 下。 ## 模型体验 @@ -105,6 +111,8 @@ pi-ai 事件会变为 harness reasoning、文本、工具调用、usage 与 fini ## 已知限制与暂缓事项 +- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 +- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 4856dbe7e5..dd79113da1 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -30,7 +30,7 @@ import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-llm' -import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' @@ -105,18 +105,10 @@ export function apply(ctx: Context, config: Config): void { registeredFacts = facts } - ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(NS, Config, { base: config }) - current = () => scope.get() - sctx.effect(() => () => { - // Settings detached (provider disposed or reloading): fall back to the - // composition entry so the plugin keeps working exactly as configured. - current = () => config - ensureRegistrationFacts() - }) - ensureRegistrationFacts() - scope.watch(() => { - ensureRegistrationFacts() - }) + installSettingsSection(ctx, NS, Config, config, { + setSource: (source) => { + current = source + }, + onChange: ensureRegistrationFacts, }) } diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index a7e9366048..2df73528e8 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -442,4 +442,54 @@ export abstract class Settings extends Service { } } +/** Hooks a consumer hands to {@link installSettingsSection}. */ +export interface SettingsSectionHooks { + /** + * Receive the active configuration source: the resolved settings scope + * while one is attached, the composition entry otherwise. Called before + * the matching `onChange` at attach and at detach. + * @param current - thunk returning the currently authoritative value. + */ + setSource(current: () => T): void + /** + * Re-judge anything derived from the source — registration-level facts, + * memoized resolutions — after an attach, a detach, or a committed change. + */ + onChange(): void +} + +/** + * Install the canonical optional-settings consumer wiring: while a settings + * service exists, register `ns` with the consumer's composition entry as the + * `base` layer and point the source thunk at the resolved scope; when the + * service goes away (disposal, provider reload), fall back to the entry so + * the consumer keeps working exactly as composed. The registration rides the + * scoped fiber, so no settings service ever mounted means none of this runs. + * @param ctx - consumer plugin context owning the wiring. + * @param ns - the consumer-owned settings namespace. + * @param schema - schema resolving the namespace (typically the plugin Config). + * @param entry - the consumer's composition entry config, used as `base`. + * @param hooks - source sink and change notification. + */ +export function installSettingsSection( + ctx: Context, + ns: SettingsNamespace, + schema: z, + entry: T, + hooks: SettingsSectionHooks, +): void { + ctx.inject(['settings'], (sctx) => { + const scope = sctx.settings.register(ns, schema, { base: entry }) + hooks.setSource(() => scope.get()) + sctx.effect(() => () => { + hooks.setSource(() => entry) + hooks.onChange() + }) + hooks.onChange() + scope.watch(() => { + hooks.onChange() + }) + }) +} + export default Settings diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index a989d9a5cc..6ad290779f 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { Settings, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' import { MemorySettings } from './memory.ts' /** A provider implementing only the three primitives: the seam owns init. */ @@ -558,3 +558,46 @@ describe('watch', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) }) }) + +describe('installSettingsSection', () => { + const HelperSchema: z<{ theme: string }> = z.object({ + theme: z.string().default('default'), + }) + + it('drives the source through attach, live commits, and detach', async () => { + const ctx = new Context() + const entry = { theme: 'entry' } + let current: () => { theme: string } = () => entry + let changes = 0 + installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, { + setSource: (source) => { + current = source + }, + onChange: () => { + changes += 1 + }, + }) + // No settings service mounted: nothing ran, the entry stays authoritative. + expect(current()).toEqual({ theme: 'entry' }) + expect(changes).toBe(0) + + const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } }) + await fiber + await vi.waitFor(() => { + expect(current()).toEqual({ theme: 'user' }) + }) + expect(changes).toBe(1) + + await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' }) + await vi.waitFor(() => { + expect(changes).toBe(2) + }) + expect(current()).toEqual({ theme: 'live' }) + + await fiber.dispose() + await vi.waitFor(() => { + expect(changes).toBe(3) + }) + expect(current()).toEqual({ theme: 'entry' }) + }) +}) diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 1fc811bc8b..1662cd7149 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 140df90571d84320fb4eb888508c67e60aa29a22 -README.zh.md: 4c16df2a56476c0a7c965a037389fa5ba231e273 +# pnpm run verify-translation-pairing --write packages/util/README.md +README.md: 3c7fd29e40c07cb25dc6ad040f86c4e31cd41931 +README.zh.md: 3b4626c7bac8c294dcbdf52ef3b0da46d39a03c8 diff --git a/packages/util/README.md b/packages/util/README.md index 140df90571..3c7fd29e40 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | +| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 4c16df2a56..3b4626c7ba 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -10,6 +10,7 @@ | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | +| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 | `dsh-brand` 是规范示例:它只负责 `Branded` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml new file mode 100644 index 0000000000..e1f2ea37f5 --- /dev/null +++ b/packages/util/atomic-write/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/util/atomic-write/README.md +README.md: 2cd57a0fa42601e393a41de68af3f9b1e2f033b5 +README.zh.md: e8f18a8ec6ef6077f15cebed0062fabc0638ee0e diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 42c65c820a..2cd57a0fa4 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -9,6 +9,8 @@ Zero-dependency atomic file replacement shared by file-backed stores that must n ```ts import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +declare const text: string + await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) ``` @@ -24,6 +26,10 @@ One export. The contract, in the order failures would exploit it: None, as this is a pure filesystem primitive; nothing here reaches a model request. +#### KV Cache effect + +None; nothing here enters a request prefix. + ## Known Limitations and Deferred Work - **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index 4a59eaea9d..e8f18a8ec6 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -2,29 +2,35 @@ [English](README.md) | 中文 -零依赖的原子文件替换,供绝不允许在磁盘上留下半截内容、被符号链接劫持或权限过宽内容的文件型存储共用——用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 +零依赖的原子文件替换,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的文件型存储共用:用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 ## 接口面 ```ts import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +declare const text: string + await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) ``` -仅一个导出。契约按攻击面利用顺序列出: +仅一个导出。契约按故障利用它的先后顺序列出: - **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 - **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 - **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。 - **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 -- 自动创建父目录;任何失败都会清理临时文件并重新抛出;读者只会看到旧内容或完整的新内容。 +- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 ## Model Experience -None, as this is a pure filesystem primitive; nothing here reaches a model request. +无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 + +#### KV Cache effect + +无;此处没有任何内容会进入请求前缀。 ## Known Limitations and Deferred Work -- **原子但不保证落盘持久**——不对文件或目录做 `fsync`,崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,持久化策略留给调用方。 -- **仅支持字符串内容**——在出现真实消费者之前不提供 `Buffer` 或流式形态。 +- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。 +- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de87482bb1..8a6c4b7544 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5472,6 +5472,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../packages/credentials/credentials '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -5574,6 +5577,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../packages/settings/settings '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -5688,6 +5694,9 @@ importers: cordis: specifier: workspace:^ version: link:../../vendor/cordis + schemastery: + specifier: workspace:^ + version: link:../../vendor/schemastery vendor/cordis: dependencies: diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a1d8728d4c..976ea1146d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -10,8 +10,8 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", @@ -22,6 +22,8 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -32,34 +34,31 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jsonrpc": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -67,11 +66,14 @@ "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", @@ -91,9 +93,10 @@ "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "workspace:^" + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "cordis": "workspace:^", + "schemastery": "workspace:^" } } diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 48a570a20d..d569811b56 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1750, + "AGENTS.md": 1755, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 850 + "packages/README.md": 865 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0654cd2277..59a39eae1a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -194,6 +194,9 @@ export const LINK_MAP: Record = { SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', SettingsUpdateSource: 'settings.md', + CredentialRef: 'credentials.md', + CredentialInfo: 'credentials.md', + ResolvedCredential: 'credentials.md', AskUserQuestionAnswer: 'user-interaction.md', AskUserQuestionRequest: 'user-interaction.md', UserInteractionProvider: 'user-interaction.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index aa599370e1..54467c3710 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -143,8 +143,17 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'User-settings seam', mode: 'seam', implementations: ['settings-local'], - consumers: [], - note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.', + consumers: ['llm-deepseek', 'llm-pi-ai'], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section.', + }, + { + key: 'credentials', + pkg: 'credentials', + title: 'Credential seam', + mode: 'seam', + implementations: ['credentials-local'], + consumers: ['llm-deepseek', 'llm-pi-ai'], + note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request.', }, { key: 'telemetry', diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 28f854fe9b..3892a0e5f3 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -197,7 +197,7 @@ describe('docsPages locale routes', () => { const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') - expect(translated).toHaveLength(19) + expect(translated).toHaveLength(20) expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) expect(fallbacks.map(page => page.source).sort()).toEqual([ 'docs/core-data-structures/commands.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5b35d7427e..7ecdcea55c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1328,6 +1328,21 @@ "doc": "docs/core-data-structures/settings.md", "symbol": "SettingsUpdateSource", "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "CredentialRef", + "source": "packages/credentials/credentials/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "ResolvedCredential", + "source": "packages/credentials/credentials/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "CredentialInfo", + "source": "packages/credentials/credentials/src/index.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index c3c9cba8a1..f558046e51 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -97,6 +97,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' }, 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, + 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' }, + 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' }, + 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/website/docs.ts b/website/docs.ts index cbc8d9e627..a640d01e18 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -257,6 +257,7 @@ const coreDataReference = pairedPages(([ ['web.md', 'Web 访问', 'Web access', 19], ['persistence.md', '会话持久化', 'Session persistence', 20], ['settings.md', '用户设置', 'User settings', 21], + ['credentials.md', '用户凭据', 'User credentials', 22], ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ source: `docs/core-data-structures/${file}`, route: `reference/core-data-structures/${file}`,