diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 340f07b15c..ef95863be1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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 .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: f397ca6a25cffa0ba709d0a8e84717a1c228b951 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: e1cde78bad16d618913e52894eff1271dac74be1 +2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7a175b44fa8806b4b73eca538f32f16aa778fdee diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index f397ca6a25..eb30ba84ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index e1cde78bad..7a175b44fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -18,7 +18,7 @@ Status: implemented **每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index c14fca60a6..61e684cafe 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.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 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 69ddfdcf3cd6c15218c672d43cc5aadfd48a0223 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: d234a073467c743aa90e8e60f1f96f4d288fd651 +2026-07-30-credential-boundaries-and-atomic-registration.md: 95c16ef2bf3d82d7b8b53e3975ece3f0c63402f3 +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 56ce0d8cbfc0f9023aca406ad43797e7347fdc77 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 69ddfdcf3c..95c16ef2bf 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -8,7 +8,7 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m ## Problem -The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. +The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/reference-updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. @@ -22,7 +22,7 @@ Two request-path defects sat beside them. DeepSeek resolved connection and crede **Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change. -**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown. +**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/reference-updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index d234a07346..56ce0d8cbf 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -10,7 +10,7 @@ Status: implemented 凭据路径越过它自己划下的边界发生了泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`,于是下一次运行时,`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖:`describe()` 报告 `source: 'env'` 且 `writable: false`,`set`/`unset` 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。 -存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true` 而 `set` 总是抛错,`credentials/updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。 +存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true` 而 `set` 总是抛错,`credentials/reference-updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。 在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。 @@ -26,7 +26,7 @@ Status: implemented **路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。 -**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。 +**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/reference-updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 452705c2ba..92b54628b1 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.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 .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 9c2b5087772a5a343514d1766a14e90edb261813 -2026-08-10-remote-event-delivery.zh.md: 213715f5d9efcc11290059e5c5b0c06bbd7e255d +2026-08-10-remote-event-delivery.md: ee3d9884b53f5fa5d0b0072660888c5f4d283b1b +2026-08-10-remote-event-delivery.zh.md: a51a0ade174e70177f438a4bbc6bcd452807f436 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 9c2b508777..ee3d9884b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -8,7 +8,7 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md) [Typert Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy. -The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge. +The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge. That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol. @@ -75,7 +75,7 @@ Delivery shares no implementation with the cordis event system: one-way only, no export const API_REMOTE_FORWARDED_EVENTS = [ 'agent-preset/selected', 'commands/change', - 'credentials/updated', + 'credentials/reference-updated', 'llm/adapters-updated', 'settings/document-updated', ] as const diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index 213715f5d9..a51a0ade17 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -8,7 +8,7 @@ Status: implemented [Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。 -Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。 +Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。 那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。 @@ -75,7 +75,7 @@ $dispatch(event: string, args: readonly unknown[]): void export const API_REMOTE_FORWARDED_EVENTS = [ 'agent-preset/selected', 'commands/change', - 'credentials/updated', + 'credentials/reference-updated', 'llm/adapters-updated', 'settings/document-updated', ] as const diff --git a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.i18n.yaml new file mode 100644 index 0000000000..980da73e47 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.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-08-13-credential-records-and-authorization-flows.md +2026-08-13-credential-records-and-authorization-flows.md: a52d85854c8c660b4965d07a13a2dbfefbac5723 +2026-08-13-credential-records-and-authorization-flows.zh.md: 1b6960cd9cff5e7320816a1528e3c621fea41691 diff --git a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md new file mode 100644 index 0000000000..a52d85854c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md @@ -0,0 +1,68 @@ +# Agent Note: Credential records and authorization flows + +Status: implemented + +English | [中文](2026-08-13-credential-records-and-authorization-flows.zh.md) + +## Problem + +The harness credential plane could only express one kind of secret: a value behind an environment-variable name. `CredentialRef` is a POSIX identifier, resolution layers the process environment over a managed file and `.env` fallbacks, and every consumer reads it per operation. That covers an API key exactly and covers nothing else. + +Some credentials are not values a deployment can be told to store. They are obtained — by a conversation with a human who opens a page, approves an account, and pastes a code back — and what comes out is a token document with a refresh half that rotates behind the user's back. pi-ai models this directly (`Credential = ApiKeyCredential | OAuthCredential`, an app-owned `CredentialStore`, `Models.login()`), and the harness had nowhere to put any of it. `PiAiAdapter` built its collection with `createModels()` and no options, so the store was pi-ai's in-memory default: empty at every boot, discarded on every configuration change. `openai-codex`, whose only method is OAuth, therefore failed every request with `Provider is not configured` — [withheld from the directory](../bug-fix/2026-08-13-oauth-only-providers-withheld.md) as a release fix, which removed the broken offer without adding the capability. + +Two further gaps followed from the same missing plane. A provider's own ambient discovery ran against the raw process environment, so a key held by the credential seam was invisible to it and a local credential file was never even looked for. And a login had no surface to run from, because nothing in the harness could ask a human a question on a plugin's behalf. + +## Decision + +Three seams, each owning one question, and every pi-ai concept behind an adapter inside `llm-pi-ai`. + +**`dsh-credentials` grows a second key space.** A `CredentialRef` answers *what is behind this environment-variable name*; a `CredentialKey` answers *what credential does this plugin hold for this id*. The record union is `{ kind: 'api-key', key?, env? } | { kind: 'grant', payload }` — the api-key half structural because the seam can describe it, the grant half opaque because a library that owns a token format keeps owning it. The only constraint on a payload is that it survives a JSON round trip, enforced on the way in and on the way out. + +The key is `/` where the scope is the **owning plugin's registered name**, not the provider's. A user knows `openai-codex`; which adapter family answers for the bytes inside that record is exactly what a bare provider name loses. Two plugins serving the same provider name would read each other's payload, and a record left by an uninstalled plugin could not be told from a live one. The `/` also keeps the two grammars disjoint, so the key spaces cannot collide. This assumes one adapter registers a given provider route, which the LLM registry already enforces. + +Records do not layer. There is no environment an authorization grant could be read from, so presence of the record is the whole fact, and the empty-value rule that governs references does not apply: an `api-key` record carrying neither a key nor env states that its owner confirmed ambient authentication, which is configured. + +**`dsh-authorization` owns the conversation, never the protocol.** A plugin that knows how to obtain its own credential registers a flow under the `CredentialKey` that flow writes. The seam runs one attempt per key, routes a neutral vocabulary of notices and prompts, and settles. A second authorization protocol arrives as another flow rather than as another seam, and a surface that renders one flow renders all of them. + +Two choices carry the weight: + +- **The flow owns the write.** `run()` resolving means the record is already committed through `ctx.credentials`; the seam confirms a commit it observed during the attempt — presence alone would let a re-authorization pass a stale record off as fresh — and refuses a flow that resolved without one. This is what lets `Models.login()` — which persists through the store adapter as part of logging in — stay the single writer, instead of the credential being copied back out and written a second time. +- **The interaction travels with the request, not a registry.** Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the page that asked, a headless caller supplies an interaction that declines, and there is no ambient provider to be absent or ambiguous between two open tabs. + +**`llm-pi-ai` holds all three translations.** `credentialStoreFrom` maps pi-ai's `CredentialStore` onto records; `authContextFrom` answers pi-ai's ambient questions from the credential seam, then the launch environment, with file existence checked against the host process's filesystem; `registerPiAiFlows` restates pi-ai's `AuthEvent`/`AuthPrompt` in the neutral vocabulary and runs `Models.login()`. Every collection is built with the first two, which is what keeps a signed-in provider signed in across the collection rebuild a configuration change causes. With a posture that works, the directory stops withholding OAuth-only routes and `openai-codex` is offered again. + +The credential plane stays optional, as it already was for reference resolution. Reads answer "nothing stored" without a credentials service, because such a composition genuinely holds no credential; writes refuse by name, because a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam through `ctx.inject`, so a headless or ACP composition mounts with no sign-in and nothing else changed. + +### Two mechanisms the seams needed underneath + +`withFileLock` takes a per-call wait limit. pi-ai runs an OAuth refresh *inside* `credentials.modify()`, so the record write path holds the lock across a network round trip; the 2s default was chosen for a render-and-rename and would fail every other writer of the document. The retry cadence stays fixed — that is a protocol constant — while the wait is sized by the longest holder a contender can meet: refs and records share one file and one lock, so every writer of the document (`DOCUMENT_LOCK_WAIT_MS`, reference writes and record deletes included) waits an OAuth refresh out, not only the mutation that runs one. + +The seam's edges get the same discipline as its write path. A prompt decline is an outcome, not a breakage — an interaction rejects with `AuthorizationDeclinedError` and the attempt settles `cancelled` — while a notice a surface cannot render is logged and lost rather than failing the flow, and `authorization/settled` fans out with contained listener failures on the credentials seam's terms. On the store side, an api-key record is admitted before it is rendered (what `parseRecord` refuses at the next boot is refused at the write), and `llm-pi-ai` asks `isCredentialKeySegment` before addressing a record, so an arbitrary hand-declared route key reads as "nothing stored" instead of throwing mid-resolution. + +Withdrawal settles an attempt whether or not its flow reacts to the signal. A flow is supposed to stop when its signal fires, but one that does not would hold its key for the life of the process, and a wedged key is indistinguishable from a busy one from outside. The orphaned run is left to finish on its own. + +## Alternatives considered + +- **Putting the pi-ai `CredentialStore` shape into the seam itself.** It is the shape that works and it is already designed. It also names `api_key`/`oauth` as the world's two credential kinds and keys by provider id, which is the ownership loss above; a second adapter family would have to pretend to be pi-ai to participate. The record union is deliberately one step more abstract in exactly two places — the key, and the opacity of a grant. +- **A dedicated login-interaction seam beside `user-questions`.** Authorization prompts look like questions, and reusing `ctx.userQuestions` was tempting. But that seam is built for a model's tool call to pause on an agent's behalf: it validates the calling agent, refuses a delegated caller, and has one ambient UI provider. An authorization prompt has no agent, must reach the configuration page that started it, and can be withdrawn per prompt by a browser callback winning a race. The vocabularies overlap; the lifecycles do not. +- **Reading `~/.codex/auth.json` into a store.** It makes Codex work without any of this, and pi-ai would own the refresh. It also binds the harness to another tool's private file format for one provider, and leaves every other login unbuilt. +- **Joining a second `begin()` to the attempt already running.** Friendlier than refusing, until two humans are answering the same flow's questions. Refusal with `inFlight` on the entry lets a surface disable the button rather than discover the state by error. +- **Keeping the OAuth-only withholding as a safety net.** It would now hide a provider that works. The predicate is deleted rather than left inert; `docs/subsystems/credentials.md` and the package READMEs carry what replaced it. + +## Consequences + +`.credentials.yaml` gains a version and two sections. A boot upgrades the recognized pre-release flat layout in place — an all-string flat mapping nests verbatim under `refs:` under the writer lock — because a key stored through the Models page by an earlier internal build must survive the layout change without a hand edit and without its model requests failing. Any flat shape the recognizer cannot prove it understands keeps the by-name refusal with the hand migration stated in the message; the parser itself still reads exactly one layout, and the migration step retires with the pre-release stance at the first tagged release. Every fixture in the repo that wrote the flat document was rewritten; the llm suites' fixtures were missed by the record change itself and fixed here. + +`openai-codex` returns to the provider picker and to the Models page directory. Signing in is offered for every installed provider that ships a login, which today is all 38 — 31 collect a key through pi-ai's own prompt, six offer that beside a subscription login, and Codex offers only the subscription login. + +What this does not yet include is the surface: the wire contract that carries notices and prompts to the browser, and the Models-page control that starts a login. Until that lands, the flows are reachable only in-process, and a deployment still configures a key by typing it into the settings form. + +Two limits are recorded in the package READMEs rather than fixed. An attempt is not durable, so reloading the page mid-login abandons it. And signing out is `deleteRecord`, which forgets the record locally without telling the issuer; a provider needing a server-side revoke has nowhere to declare it. + +## Testing + +The seam's suite pins the lifecycle it owns: single-flight refusal and release, withdrawal before the flow starts and during it, a flow that ignores its signal, the commit confirmation, and the settlement event including the `failed` case a caller sees as a thrown error. The invariant companion pins that a settled key is a free key, because a wedged one is otherwise invisible. + +`llm-pi-ai` covers the three translations against a real `$DSH_HOME` document — an api-key credential field by field, an OAuth credential verbatim including its refresh half, a foreign plugin's record skipped by scope, and the write refusal without a credentials service — plus every `AuthEvent` and `AuthPrompt` member restated, with `Models.login()` mocked at the collection boundary since a real one opens a browser. Two real-composition tests boot the plugin with and without the authorization seam. + +The `models-settings` and `onboarding-usable-provider` web e2e goldens regain exactly the `openai-codex` option line they lost when it was withheld — the whole assembled-application difference this change makes today, because the Models page has no login control yet to record. diff --git a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md new file mode 100644 index 0000000000..1b6960cd9c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md @@ -0,0 +1,68 @@ +# Agent Note: 凭据记录与授权 flow + +Status: implemented + +[English](2026-08-13-credential-records-and-authorization-flows.md) | 中文 + +## Problem + +harness 的凭据平面只能表达一种机密:藏在某个环境变量名之后的值。`CredentialRef` 是一个 POSIX 标识符,解析时按进程环境、受管文件、`.env` 回退分层,每个消费方按操作读取。这恰好覆盖 API key,此外什么都不覆盖。 + +有些凭据不是"可以让部署方去存"的值。它们是被**取得**的——与人对话:对方打开页面、批准账号、把码粘回来——产出的是一份带 refresh 半边、会在用户背后轮换的 token 文档。pi-ai 直接建模了这一点(`Credential = ApiKeyCredential | OAuthCredential`、由应用拥有的 `CredentialStore`、`Models.login()`),而 harness 无处安放其中任何一项。`PiAiAdapter` 用不带参数的 `createModels()` 构造集合,于是那个 store 就是 pi-ai 的内存默认实现:每次启动为空,每次配置变更被丢弃。只以 OAuth 认证的 `openai-codex` 因此每个请求都以 `Provider is not configured` 失败——[被目录withheld](../bug-fix/2026-08-13-oauth-only-providers-withheld.md) 作为发布前修复,它移除了错误的供给,但没有补上能力。 + +同一处缺失还带来另外两个缺口。提供方自带的凭据发现是对着裸进程环境跑的,因此凭据 seam 保管的密钥对它不可见,本地凭据文件更是从未被查找过。而登录没有任何界面可以发起,因为 harness 里没有任何东西能代替插件向人发问。 + +## Decision + +三个 seam,各自拥有一个问题;所有 pi-ai 概念都藏在 `llm-pi-ai` 内部的适配器背后。 + +**`dsh-credentials` 长出第二个键空间。** `CredentialRef` 回答*这个环境变量名背后是什么*;`CredentialKey` 回答*这个插件为这个 id 持有什么凭据*。记录联合体是 `{ kind: 'api-key', key?, env? } | { kind: 'grant', payload }`——api-key 那半是结构化的,因为 seam 能描述它;grant 那半是不透明的,因为拥有 token 格式的库应当继续拥有它。对 payload 的唯一约束是它能原样通过一次 JSON 往返,读写两个方向都会校验。 + +键的形式是 `/`,其中 scope 是**拥有该记录的插件的注册名**,不是提供方名。用户知道的是 `openai-codex`;究竟哪个 adapter 家族为记录里的字节负责,恰恰是裸提供方名会丢掉的信息。服务同一个提供方名的两个插件会互相读到对方的 payload,已卸载插件留下的记录也无法与仍在使用的区分开。`/` 同时让两种文法互斥,两个键空间因此不可能相撞。这以"同一个 provider 路由只由一个 adapter 注册"为前提,而 LLM 注册表本就强制了这一点。 + +记录不分层。授权 grant 没有任何"环境"可供读取,因此记录是否存在就是全部事实,管辖引用的空值规则在此不适用:一条既无 key 也无 env 的 `api-key` 记录,陈述的是其拥有者确认了环境认证可用,这属于已配置。 + +**`dsh-authorization` 拥有对话,从不拥有协议。** 知道如何取得自己那份凭据的插件,以该 flow 写入的 `CredentialKey` 注册。seam 对每个键同时只跑一次尝试,路由一套中立的 notice/prompt 词汇,然后结算。第二种授权协议以另一个 flow 的形式到来,而不是另一个 seam;能渲染一个 flow 的界面就能渲染全部 flow。 + +两个选择承担了主要分量: + +- **写入由 flow 拥有。** `run()` 返回即表示记录已通过 `ctx.credentials` 提交;seam 核实的是本次尝试期间观察到的提交——只看记录存在与否,会让重新授权把陈旧记录冒充成新鲜的——并拒绝返回时没提交记录的 flow。正是这一点让 `Models.login()`——它把持久化当作登录的一部分,经由 store 适配器完成——保持为唯一写入方,而不是把凭据复制出来再写第二遍。 +- **交互随请求传入,而非注册表。** 发起授权的一方才是能与人对话的一方,因此提示恰好抵达发问的那个页面,无头调用方传入一个直接拒绝的交互实现,也不存在"环境提供方缺席"或"该归两个已打开标签页中哪一个"的问题。 + +**三处翻译全都留在 `llm-pi-ai`。** `credentialStoreFrom` 把 pi-ai 的 `CredentialStore` 映射到记录;`authContextFrom` 先查凭据 seam 再查启动环境来回答 pi-ai 的环境提问,文件存在性则按宿主进程的文件系统判断;`registerPiAiFlows` 把 pi-ai 的 `AuthEvent`/`AuthPrompt` 重述为中立词汇并运行 `Models.login()`。每个集合都用前两者构造,正是这一点让已登录的提供方在配置变更导致集合重建之后仍然处于登录状态。有了行得通的姿态之后,目录不再扣留仅 OAuth 的路由,`openai-codex` 重新被提供。 + +凭据平面仍是可选的,正如它在引用解析上一贯如此。没有凭据服务时读取回答"未存储",因为这样的组合确实不持有任何凭据;写入则指名拒绝,因为一次 grant 凭空蒸发的登录会先报告成功、再让每个请求失败。flow 注册通过 `ctx.inject` 限定在授权 seam 之下,因此 headless 或 ACP 组合挂载后没有登录能力,其余一切不变。 + +### seam 底下需要的两处机制 + +`withFileLock` 接受按调用声明的等待上限。pi-ai 在 `credentials.modify()` **内部**执行 OAuth 刷新,因此记录写入路径要跨越一次网络往返持锁;2 秒的默认值是按"渲染并 rename"的量级选的,会让该文档的每一个其他写入方失败。重试节奏保持固定——那是协议常量——而等待时长按争用方可能遇到的最长持锁方来定:refs 与 records 共享同一份文件、同一把锁,因此该文档的每一个写入方(`DOCUMENT_LOCK_WAIT_MS`,含引用写入与记录删除)都要等得起一次 OAuth 刷新,而不只是执行刷新的那个 mutation。 + +seam 的边缘与写入路径同一纪律。prompt 被拒是结果而非故障——交互实现以 `AuthorizationDeclinedError` 拒绝,尝试以 `cancelled` 结算;渲染不了 notice 的界面只丢那条 notice、绝不拖垮 flow;`authorization/settled` 按 credentials seam 的条款以遏制方式分发监听器故障。存储侧,api-key 记录在渲染前先行准入(`parseRecord` 下次启动会拒绝的,写入时就拒绝),`llm-pi-ai` 在寻址记录前先问 `isCredentialKeySegment`,任意手写路由键读作「没有存储任何东西」,而不是在解析途中抛错。 + +撤销会结算一次尝试,无论其 flow 是否响应信号。flow 本应在信号触发时停止,但不停止的那个会把键占到进程结束,而被卡住的键从外部看与忙碌中的键无法区分。被遗弃的执行体听任其自行结束。 + +## Alternatives considered + +- **把 pi-ai 的 `CredentialStore` 形状直接放进 seam。** 那是行得通且已经设计好的形状。它同时把 `api_key`/`oauth` 定为世上仅有的两种凭据类别,并以 provider id 为键,也就是上文那种所有权丢失;第二个 adapter 家族将不得不假装自己是 pi-ai 才能参与。记录联合体刻意只在两处更抽象一步——键,以及 grant 的不透明性。 +- **在 `user-questions` 旁再建一个专用登录交互 seam。** 授权提示看起来就像问题,复用 `ctx.userQuestions` 很有诱惑力。但那个 seam 是为"模型的工具调用代表 agent 暂停"而建的:它校验调用方 agent、拒绝被委派的调用方、只有一个环境 UI 提供方。授权提示没有 agent,必须抵达发起它的配置页面,还可能被浏览器回调赢得竞速后按单个提示撤下。词汇重叠,生命周期不重叠。 +- **把 `~/.codex/auth.json` 读进一个 store。** 这能让 Codex 在不做上述任何事的情况下工作,刷新也由 pi-ai 负责。它同时为了一个提供方把 harness 绑死在另一个工具的私有文件格式上,且其余所有登录仍然没有着落。 +- **让第二次 `begin()` 并入已在运行的尝试。** 比拒绝更友好,直到两个人在回答同一个 flow 的问题为止。以 entry 上的 `inFlight` 配合拒绝,界面得以禁用按钮,而不是靠报错才发现状态。 +- **把"仅 OAuth 则扣留"当安全网保留。** 它现在会藏起一个能用的提供方。该判定被删除而不是留成惰性代码;`docs/subsystems/credentials.md` 与包 README 承载了取代它的内容。 + +## Consequences + +`.credentials.yaml` 增加了版本与两个分区。启动时会把能精确识别的发布前扁平布局原地升级——全字符串的扁平 mapping 在写锁下逐字下沉到 `refs:` 之下——因为早期内测构建经模型页面存下的密钥必须在布局变更后继续可用,不能要求手工编辑,也不能让模型请求失败。识别器无法证明自己理解的扁平形态仍被指名拒绝,迁移办法写在报错信息里;解析器本身始终只读一种布局,迁移步骤将随发布前立场在首个正式版本时移除。仓库中所有写扁平文档的 fixture 都已改写;llm 各套件的 fixture 被记录改动本身漏掉了,在此补上。 + +`openai-codex` 回到提供方选择器与 Models 页目录。凡是自带登录的已安装提供方都会得到登录入口,而今天这是全部 38 个——31 个经 pi-ai 自己的提示收取密钥,6 个在此之外还提供订阅登录,Codex 只提供订阅登录。 + +尚未包含的是界面:把 notice 与 prompt 送到浏览器的 wire 契约,以及 Models 页上发起登录的控件。在那之前,flow 只能在进程内触达,部署方仍然通过在设置表单里输入密钥来配置。 + +有两项限制记在包 README 里而非就地修复。一次尝试不可持久,登录途中刷新页面会丢弃它。登出即 `deleteRecord`,它只在本地遗忘而不通知签发方;需要服务端吊销的提供方无处声明这一点。 + +## Testing + +seam 自己的套件钉住它拥有的生命周期:单飞的拒绝与释放、flow 启动前与进行中的撤销、一个忽略自身信号的 flow、提交核实,以及包含"调用方看到的是抛出错误"那种 `failed` 情形的结算事件。invariant companion 钉住"已结算的键就是空闲的键",因为被卡住的键否则不可见。 + +`llm-pi-ai` 针对一份真实的 `$DSH_HOME` 文档覆盖三处翻译——逐字段的 api-key 凭据、连 refresh 半边一起原样保存的 OAuth 凭据、按 scope 跳过的他插件记录,以及没有凭据服务时的写入拒绝——外加每一个 `AuthEvent` 与 `AuthPrompt` 成员的重述;`Models.login()` 在集合边界处被 mock,因为真实登录会打开浏览器。两个真实组合测试分别在挂载与不挂载授权 seam 的情况下启动插件。 + +`models-settings` 与 `onboarding-usable-provider` 两条 web e2e golden 恰好收回了被扣留时失去的那一行 `openai-codex` 选项——这是本次改动今天在装配后的应用上造成的全部差异,因为 Models 页还没有可录制的登录控件。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml new file mode 100644 index 0000000000..90d1f96dff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.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-08-19-web-index-injection-table.md +2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2 +2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md new file mode 100644 index 0000000000..9ed02aa94c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md @@ -0,0 +1,30 @@ +# Agent Note: structured index injection table (webserver/index-inject) + +Status: implemented + +English | [中文](2026-08-19-web-index-injection-table.zh.md) + +## Problem + +The web shell's boot HTML needs three kinds of injection: client-modules' boot protocol (the `__ModuleLoader__` registration queue inline script, the parser-blocking preload `` +})()` const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id)) .filter((entry): entry is WebBootEntry => entry !== undefined) - .map(entry => ``) - .join('') - const script = `${queue}${preload}` - const head = html.indexOf('') - if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` - // Headless fixture pages may lack ; prepending keeps the read-before-shell ordering. - return `${script}${html}` + .map((entry): IndexInjection => ({ kind: 'script-src', placement: 'head', src: entry.url })) + return [ + { kind: 'script', placement: 'head', text: queue }, + ...preload, + { kind: 'global', name: '__DSH_BOOT__', value: graph }, + ] } /** * The web plugin table service: incremental `dsh.client` scan + wire composition - * + bundle route + index tap. Construction runs the activation scan + * + bundle route + index injection rows. Construction runs the activation scan * synchronously — a malformed declaration or missing bundle among the * already-loaded entries aggregates into one loud throw (FAILED fiber; the * boot activation audit reports it). @@ -353,10 +340,9 @@ export class ClientModuleRegistry extends Service { () => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }), 'client-modules: bundle route', ) - ctx.effect( - () => ctx.webServer.tapIndex(html => injectBootManifest(html, this.composed)), - 'client-modules: boot manifest injection', - ) + ctx.on('webserver/index-inject', (table) => { + table.push(...bootInjections(this.composed)) + }) } /** diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 0dc6ea0e3c..8577d2a57d 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url' import { runInNewContext } from 'node:vm' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' -import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver' import * as modulesClient from '../src/client/index.ts' -import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts' +import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts' import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts' const MODULES_ID = '@deepseek-ai/dsh-client-modules' @@ -81,9 +81,12 @@ function construct(packageNames: string[]): ClientModuleRegistry { return constructWithRoute(packageNames).service } -/** Execute the exact first inline script emitted by the Host HTML transform. */ +/** Execute the exact first inline script emitted by the Host boot rows. */ function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } { - const html = injectBootManifest('', graph) + const html = renderIndexInjections( + '', + bootInjections(graph), + ) const source = /') const runtimeAt = html.indexOf('') - const graphAt = html.indexOf('window.__DSH_BOOT__ = ') + const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ') const entryAt = html.indexOf('') expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([ facadeAt, modulesAt, runtimeAt, graphAt, entryAt, diff --git a/packages/client/runtime/tests/wire-events.client.spec.ts b/packages/client/runtime/tests/wire-events.client.spec.ts index 13365c1462..fbc4204b0d 100644 --- a/packages/client/runtime/tests/wire-events.client.spec.ts +++ b/packages/client/runtime/tests/wire-events.client.spec.ts @@ -28,7 +28,7 @@ function forwardedEventContracts(ctx: Context): void { const bare: typeof namespace = 'plain-string' void bare; void namespace; void source }) - ctx.remote.$on('credentials/updated', () => {}) + ctx.remote.$on('credentials/reference-updated', () => {}) ctx.remote.$on('commands/change', () => {}) ctx.remote.$on('llm/adapters-updated', () => {}) ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => { @@ -106,7 +106,7 @@ describe('wire event bridge', () => { }) bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, - payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] }, + payload: { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] }, }) // The carrier does not second-guess the name: selecting what a consumer can // receive is the allowlist's job, and dropping an unsubscribed name is the @@ -118,7 +118,7 @@ describe('wire event bridge', () => { expect(seen).toEqual([ ['settings/document-updated', 'llm-pi-ai', 7], - ['credentials/updated', 'OPENAI_API_KEY'], + ['credentials/reference-updated', 'OPENAI_API_KEY'], ['nobody/listening', 'ignored'], ]) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74a73ffb4a..44c63ba33c 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 71dafd1c4d683f2d6da8fefd88aec81d56149481 -README.zh.md: e4d4ab990c74229bd2e077b575e7c87bb648ade9 +README.md: a90d1d9dd8156fc1063dc7358374f8b1715deb1a +README.zh.md: acab41ad8c95c9e24b39fc875f3047f429f3add2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 71dafd1c4d..a90d1d9dd8 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ Image intake accepts paste and whole-page drop: the bar binds document-level dra The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. -The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. +The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Every non-empty ratio starts with integer rounding. A non-full ratio adds decimal places only while the current precision would round to 100%, stopping at the minimum precision that remains below 100%; only a full cache hit displays 100%, and the precision has no fixed limit. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. `src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e4d4ab990c..acab41ad8c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 +聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。所有非空比率都先按整数舍入。非满命中只有在当前精度会舍入成 100% 时才增加小数位,并在首次得到低于 100% 的结果时停止;只有完整缓存命中才显示 100%,且精度没有固定上限。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 `src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index 8fdba2baa0..c9e8d19d77 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -17,6 +17,28 @@ gap: 16px; } +/* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span + the whole transcript width instead of clipping at the 748px message + column, while the table content keeps starting at its normal x (the lead + padding compensates the negative margin) — deepsuite chat TableWrapper + parity, with 100cqw (the ChatView scroll container) standing in for its + JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero + when the transcript is narrower than the message column, so narrow + viewports keep the plain in-column scroll with no sideways shift. + Percentages resolve against the wrapper's containing block, so a table + indented inside a list still reaches the same transcript edges. */ +.body :global(.md-table-wide) { + --dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2)); + --dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%); + box-sizing: border-box; + width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare)); + /* The base .tableScroll caps at the column (max-width: 100%); the breakout + is exactly the case that must exceed it. */ + max-width: none; + margin-left: calc(-1 * var(--dsh-table-lead)); + padding-left: var(--dsh-table-lead); +} + /* Interrupted-turn terminal marker: quiet inline tag, no animation. */ .stopped { align-self: flex-start; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index ffee12984f..73fa688f95 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -18,6 +18,11 @@ /* Sides = composer clearance + 16px: on narrow viewports the transcript stays exactly 32px narrower than the input card (the shared width rule). */ padding: 16px calc(var(--dsh-composer-side-clearance) + 16px); + /* Inline-size query container: wide markdown tables size their breakout + against this box's content width (100cqw in AssistantMarkdown's + md-table-wide rule) — the CSS stand-in for deepsuite chat's JS-measured + --dsl-virtual-list-width. */ + container-type: inline-size; } :global([data-conversation-scroll]) .root { diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 147d2b7c6c..2d9d14483b 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -101,16 +101,64 @@ export function formatDuration(ms: number): string { return `${Math.floor(whole / 60)}m${whole % 60}s` } +/** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */ +function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number { + const denominatorQuotient = Math.floor(denominator / 200) + const denominatorRemainder = denominator % 200 + let lower = 0 + let upper = 100 + while (lower < upper) { + const candidate = Math.floor((lower + upper + 1) / 2) + const factor = candidate * 2 - 1 + const threshold = factor * denominatorQuotient + + Math.ceil(factor * denominatorRemainder / 200) + if (cacheReadTokens >= threshold) { + lower = candidate + } else { + upper = candidate - 1 + } + } + return lower +} + /** - * Cache-hit share of prompt-side input over the whole durable log. + * Display-ready cache-hit share of prompt-side input over the whole durable log. * @param usage - the session's token-usage projection value. - * @returns rounded integer percent, or null when no input was billed. + * @returns integer text when integer rounding stays below 100, otherwise the + * minimum decimal precision that still rounds below 100; a full hit returns + * 100, and no billed input returns null. */ -export function cacheHitPercent(usage: TokenUsageProjection): number | null { +export function cacheHitPercent(usage: TokenUsageProjection): string | null { const denominator = billedInputTokens(usage) - return denominator === 0 - ? null - : Math.round(usage.cacheReadTokens / denominator * 100) + if (denominator === 0) return null + const missedInputTokens = usage.uncachedInputTokens + usage.cacheWriteTokens + if (missedInputTokens === 0) return '100' + + const integerPercent = roundedIntegerPercent(usage.cacheReadTokens, denominator) + if (integerPercent < 100) return String(integerPercent) + + // At the first distinguishing precision, the rounded result is 100 minus + // one to five units in the final decimal place. Scale only while the next + // multiplication remains at or below the denominator, then derive that + // final digit through exact small-factor comparisons. + let decimalPlaces = 1 + let scaledDoubleGap = missedInputTokens * 200 + const denominatorTens = Math.floor(denominator / 10) + while (scaledDoubleGap <= denominatorTens) { + scaledDoubleGap *= 10 + decimalPlaces += 1 + } + const denominatorOnes = denominator % 10 + let roundedLoss = 5 + for (let loss = 1; loss < 5; loss += 1) { + const factor = loss * 2 + 1 + const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10) + if (scaledDoubleGap <= threshold) { + roundedLoss = loss + break + } + } + return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}` } /** diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index 01be6e0850..be55ee0579 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -191,6 +191,10 @@ describe('StatsLine', () => { return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn } } + function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) { + return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 } + } + it('renders the grouped stats row and hides a brand-new empty session', () => { const { source } = makeSource({ nodes: [assistant(1, 1)] }) const view = render() @@ -205,19 +209,43 @@ describe('StatsLine', () => { expect(emptyView.container.textContent).toBe('') }) + it.each([ + { actual: '98.6%', tokenUsageValue: tokenUsage(986, 14), expected: 'Cache hit 99%' }, + { actual: '99.1%', tokenUsageValue: tokenUsage(991, 9), expected: 'Cache hit 99%' }, + { actual: '99.49%', tokenUsageValue: tokenUsage(9_949, 51), expected: 'Cache hit 99%' }, + { actual: '99.5%', tokenUsageValue: tokenUsage(995, 5), expected: 'Cache hit 99.5%' }, + { actual: '99.94%', tokenUsageValue: tokenUsage(9_994, 6), expected: 'Cache hit 99.9%' }, + { actual: '99.95%', tokenUsageValue: tokenUsage(9_995, 5), expected: 'Cache hit 99.95%' }, + { actual: '99.955%', tokenUsageValue: tokenUsage(19_991, 9), expected: 'Cache hit 99.96%' }, + { actual: '99.985%', tokenUsageValue: tokenUsage(19_997, 3), expected: 'Cache hit 99.99%' }, + { actual: '99.995%', tokenUsageValue: tokenUsage(19_999, 1), expected: 'Cache hit 99.995%' }, + { actual: '99.9975%', tokenUsageValue: tokenUsage(39_999, 1), expected: 'Cache hit 99.998%' }, + { + actual: 'the closest non-full ratio available from safe integer cumulative counts', + tokenUsageValue: tokenUsage(Number.MAX_SAFE_INTEGER - 1, 1), + expected: 'Cache hit 99.99999999999999%', + }, + { actual: '100%', tokenUsageValue: tokenUsage(10_000, 0), expected: 'Cache hit 100%' }, + ])('formats an actual $actual cache-hit ratio as $expected', ({ tokenUsageValue, expected }) => { + const { source } = makeSource({ nodes: [assistant(1, 1)] }) + const view = render() + expect(view.container.textContent).toContain(expected) + }) + it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => { vi.useFakeTimers() // jsdom lays nothing out; fake a row narrower than its content. vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800) vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400) const { source } = makeSource({ nodes: [assistant(1, 1)] }) - const view = render() + const view = render() + expect(view.container.textContent).toContain('Cache hit 99.95%') fireEvent.mouseEnter(view.container.firstElementChild!) act(() => { vi.advanceTimersByTime(499) }) expect(view.container.querySelector('[role="tooltip"]')).toBeNull() act(() => { vi.advanceTimersByTime(1) }) expect(view.container.querySelector('[role="tooltip"]')?.textContent) - .toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok') + .toBe('1 turns · 1 steps | Cache hit 99.95% | Input 10K tok · Output 1 tok') }) it('suppresses the tooltip while the row fits without truncation', () => { @@ -245,9 +273,9 @@ describe('StatsLine', () => { timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 }, } const { source } = makeSource({ nodes: [timed] }) - const view = render() + const view = render() expect(view.container.textContent) - .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok') + .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok') }) it('renders without ResizeObserver support', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 06ca10e568..e6ed8300ac 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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/client/ui-primitives/README.md -README.md: a675b3cd0aa9e09e243b69065110e1d2b67ff1d9 -README.zh.md: aa67993ec879635fc0677501665d2e23de996dcf +README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81 +README.zh.md: 5a78e683f3bb94f8b9d3a64b0d4ba1f3f3df9d85 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a675b3cd0a..7822a5d41e 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index aa67993ec8..5a78e683f3 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -14,7 +14,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index b62e66e86e..f8d45cd4f6 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -177,12 +177,51 @@ overscroll-behavior-x: contain; } +/* Wide tables reveal their horizontal bar on hover (or keyboard focus) + instead of keeping it painted. Chromium never repaints state-conditioned + scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules + nor a :hover `scrollbar-color` change reaches the painted bar), so the + toggle is `overflow-x` itself — a layout change repaints reliably. The + resting padding matches the themed bar's height, so on an overflowing + table the appearing bar exactly replaces it and nothing below shifts. + Wheel and trackpad scrolling need the pointer over the table, which is + already the hover that re-enables `auto`. */ +.tableScroll:global(.md-table-wide) { + overflow-x: hidden; + padding-bottom: var(--dsh-scrollbar-width, 8px); +} + +.tableScroll:global(.md-table-wide):hover, +.tableScroll:global(.md-table-wide):focus-visible { + overflow-x: auto; + padding-bottom: 0; +} + +/* Chromium keyboard-focuses scrollable containers by default; the ring uses + the sheet's link focus color. */ +.tableScroll:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); +} + +/* Many-column tables (the renderer's `md-table-wide` hook) keep their + natural width and scroll inside the wrapper; a hosting layout may widen + the wrapper through the hook (deepsuite chat TableWrapper parity). */ .tableScroll table { border-collapse: collapse; width: max-content; max-width: max-content; } +/* Tables under four columns, and any table inside a blockquote, fill the + column instead: cells wrap down to their minimum readable width, and the + wrapper's scroll only remains for the below-floor case (deepsuite chat + parity: `.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))`). */ +.tableFill table { + width: 100%; + max-width: none; +} + .tableScroll th { text-align: start; padding: 10px 16px; diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 452e0fc475..de713858cf 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -18,6 +18,7 @@ import { Fragment, createElement } from 'react' import type { Key, ReactNode } from 'react' +import clsx from 'clsx' import type * as Md from 'mdast' import type {} from 'mdast-util-math' import { normalizeUri } from 'micromark-util-sanitize-uri' @@ -123,6 +124,8 @@ export interface MarkdownRenderContext { readonly streaming: boolean /** Localized fence copy-button labels. */ readonly codeLabels: MarkdownCodeLabels | undefined + /** Inside a blockquote's children: tables there always fill the quote's width. */ + readonly inBlockquote?: boolean /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ readonly fileMentions: MarkdownFileMentions | undefined /** Inside an anchor's children: interactive mentions must not nest there. */ @@ -213,7 +216,10 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'blockquote': return (
- {wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)} + {wrapBlockChildren( + renderChildren(node.children, { ...context, inBlockquote: true }).filter(child => child !== null), + true, + )}
) case 'thematicBreak': @@ -393,8 +399,23 @@ function renderListItem( function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode { const align = node.align ?? null const [headRow, ...bodyRows] = node.children + const columns = align === null ? headRow?.children.length ?? 0 : align.length + // Four or more columns read as a comparison matrix: the block keeps the + // table at natural width and exposes the stable `md-table-wide` hook so a + // hosting layout (the chat transcript) can widen it past the message + // column. Narrower tables — and any table inside a blockquote — fill the + // column and wrap instead (deepsuite chat TableWrapper parity). + const wide = columns >= 4 && context.inBlockquote !== true return ( -
+ // Wide tables rest with overflow-x hidden (the hover-revealed bar in + // MarkdownText.module.css), which drops Chromium's implicit scroller + // focusability — the explicit tabindex keeps them keyboard-reachable, + // and :focus-visible restores scrolling. +
{headRow !== undefined && {renderTableRow(headRow, 'th', align, 0, context)}} {bodyRows.length > 0 && ( diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt index cf6b2ba4ad..83b008e2d5 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt @@ -22,7 +22,7 @@ #text "Unbalanced errors render the error arm: " #text "\\frac{" -
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt index 639a2fca84..a56b3c338d 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt @@ -3,7 +3,7 @@ #text "Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$."

#text "Unbalanced errors render the error arm: $\\frac{$" -

+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt index 2c2d9d7e0f..ba1552d480 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt index 2c2d9d7e0f..ba1552d480 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt new file mode 100644 index 0000000000..c105766edf --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt @@ -0,0 +1,46 @@ +
+
+
+ + + + +
+ #text "C1" + + #text "C2" + + #text "C3" + + #text "C4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" +
+
+ + + + + +
+ #text "Q1" + + #text "Q2" + + #text "Q3" + + #text "Q4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt new file mode 100644 index 0000000000..c105766edf --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt @@ -0,0 +1,46 @@ +
+
+ + + + + +
+ #text "C1" + + #text "C2" + + #text "C3" + + #text "C4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" +
+
+ + + + + +
+ #text "Q1" + + #text "Q2" + + #text "Q3" + + #text "Q4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt index 6a669ffe2f..99a45c4e77 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt index 6a669ffe2f..99a45c4e77 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx index 70197763da..093b84a133 100644 --- a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx @@ -163,6 +163,17 @@ const CORPUS: Record = { 'after', ].join('\n'), 'table-header-only': '| a | b |\n| --- | --- |\n\nafter', + // Four columns take the md-table-wide hook at top level; the same table + // inside a blockquote falls back to the fill arm. + 'table-wide-and-blockquote': [ + '| C1 | C2 | C3 | C4 |', + '| --- | --- | --- | --- |', + '| a | b | c | d |', + '', + '> | Q1 | Q2 | Q3 | Q4 |', + '> | --- | --- | --- | --- |', + '> | a | b | c | d |', + ].join('\n'), 'inline-code-with-newline': 'Spans `a\nb` across a line.', 'links-and-autolinks': [ '[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).', diff --git a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx index 48f59c7479..dbad9ef164 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx @@ -101,6 +101,14 @@ describe('renderBlocks over hand-built trees', () => { expect(container.querySelector('td')?.textContent).toBe('short') }) + it('renders a rowless align-less table as an empty fill wrapper', () => { + // Zero columns is below the wide threshold, so the fill arm applies. + const container = renderNodes([{ type: 'table', children: [] }]) + const wrapper = container.querySelector('table')?.parentElement + expect(wrapper?.className).not.toContain('md-table-wide') + expect(container.querySelector('table')?.childElementCount).toBe(0) + }) + it('pads rows against the alignment width with empty cells', () => { const container = renderNodes([ { diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 950124960a..5e6da8d9b3 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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/client/ui-settings-models/README.md -README.md: 0d76d5f31585721ad8f5cd76dd223d72353fdd0c -README.zh.md: 8ac336245c9d82420c10132b3b935cf00f5ea889 +README.md: 781d7a8134c8118ddf4a78f3ee153a8043bae0a6 +README.zh.md: a652e9a250d855c0e91f842ec2332f4a49f17856 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 0d76d5f315..781d7a8134 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -10,7 +10,7 @@ The notice step owns its exact copy and version in `src/onboarding-copy.ts`. On After that notice completes, the DeepSeek step projects first-run readiness from the same joined Models snapshot. ANY provider the user can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference and therefore authenticates natively. Only a user with none is asked for the official DeepSeek key. A mounted, active adapter with a missing writable reference renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. Configure later completes only this coordinator pass. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/reference-updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 8ac336245c..a652e9a250 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -10,7 +10,7 @@ 声明完成后,DeepSeek 步骤会从同一个 Models 联接快照得出首次运行就绪状态。只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名引用、因而走原生认证的路由。只有二者皆无的用户才会被要求填写 DeepSeek 官方密钥。适配器已挂载且活跃、引用可写但尚未配置时,既有 `ProviderEditor` 会以仅凭据模式渲染在共用引导弹窗中;`credentials.set` 仍是唯一的 secret 写入,且不会改变提供方设置。「稍后配置」只完成协调器当前这一轮。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤不渲染并直接完成;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/reference-updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 6bb403a90f..90a2aed75a 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -108,7 +108,7 @@ export function apply(ctx: ClientContext): void { const refreshModels = (): void => { refreshIfLoaded(controller) } const disposers = [ ctx.remote.$on('settings/document-updated', () => { refreshModels() }), - ctx.remote.$on('credentials/updated', refreshModels), + ctx.remote.$on('credentials/reference-updated', refreshModels), ctx.remote.$on('llm/adapters-updated', refreshModels), ctx.on('connection/reset', refreshModels), ] diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index f098a9b7e8..18182602d2 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -177,7 +177,7 @@ describe('pushed invalidations', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() // The fake wire face has no methods: a fetch attempt would throw. b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1]) - b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY']) + b.ctx.remote.$dispatch('credentials/reference-updated', ['OPENAI_API_KEY']) b.ctx.remote.$dispatch('llm/adapters-updated', []) b.ctx.emit('connection/reset') }) @@ -210,7 +210,7 @@ describe('pushed invalidations', () => { )() injected.controller.store.update((state) => { state.status = 'ready' }) const load = vi.spyOn(injected.controller, 'load').mockResolvedValue() - b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) + b.ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) expect(load).toHaveBeenCalledTimes(1) }) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index ae8c3a78bf..e8a19821f5 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/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/client/ui-settings-plugins/README.md -README.md: 92ba426b6bee3ae73e184dfcfd418e889bd47086 -README.zh.md: dc319ea9f128016b6df821575cb4d75740f04579 +README.md: 0ee0edbebadad8e8e895002cd9eb60a20f45809d +README.zh.md: 75cc63ccf5280bba1b94bcf8930b9ee9432440e5 diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 92ba426b6b..0ee0edbeba 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -20,7 +20,7 @@ A card stages what the user types and writes it only when they save. Each contro Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct. -A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/updated` event for the reference it watches. +A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index dc319ea9f1..75cc63ccf5 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -20,7 +20,7 @@ 保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。 -密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/updated` 事件报告它所关注的引用时重读。 +密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。 diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 184511ead1..daf7a280dc 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -68,7 +68,7 @@ export function apply(ctx: ClientContext): void { // scope publishes nothing when one is written. This is the only signal that // a key written on another surface reached the Host. ctx.effect( - () => ctx.remote.$on('credentials/updated', (ref) => { webSearch.refreshCredential(ref) }), + () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), 'ui-settings-plugins: credential invalidations', ) diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 23e46e15a5..539096cfcc 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -180,7 +180,7 @@ describe('ui-settings-plugins apply', () => { // A key written on another surface changes no settings section, so this // event is the only thing that reaches the card. - ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) + ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) @@ -192,7 +192,7 @@ describe('ui-settings-plugins apply', () => { await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) describeCredentials.mockClear() - ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY']) + ctx.remote.$dispatch('credentials/reference-updated', ['SOME_OTHER_KEY']) await Promise.resolve() expect(describeCredentials).not.toHaveBeenCalled() diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index eb7d3f176c..dd969c69f2 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -57,6 +57,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -65,10 +66,9 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "react": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^" + "@types/react": "~18.3.1", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-theme/src/boot-theme.ts b/packages/client/ui-theme/src/boot-theme.ts index 89b81b9de1..e6430cc6b9 100644 --- a/packages/client/ui-theme/src/boot-theme.ts +++ b/packages/client/ui-theme/src/boot-theme.ts @@ -1,15 +1,16 @@ /** - * Host-rendered theme bootstrap for the browser's pre-plugin interval. Each - * index response embeds the current durable built-in preference; the browser - * resolves only `system`, then writes the same DOM fields ui-layout's - * ThemePresenter owns after the client plugin tree activates. + * Theme bootstrap row for the browser's pre-plugin interval. Each index + * render embeds the current durable built-in preference; the browser resolves + * only `system`, then writes the same DOM fields ui-layout's ThemePresenter + * owns after the client plugin tree activates. */ +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts' -/** Build the inline script for one schema-validated built-in preference. */ +/** Build the inline script body for one schema-validated built-in preference. */ function bootThemeScript(preference: ThemePreference): string { - return `` +})()` } /** - * Insert the theme bootstrap immediately after the opening body tag, before - * the shell mount and module script. Body-less fragments receive it at the - * end, where the HTML parser has already synthesized a body. - * @param html - Raw application index HTML. + * The theme bootstrap as an injection row: an inline script immediately after + * the opening body tag, before the shell mount and module script. * @param preference - Current Host-backed built-in preference. - * @returns HTML containing the theme bootstrap. + * @returns the body script row. */ -export function injectBootTheme( - html: string, +export function bootThemeInjection( preference: ThemePreference = DEFAULT_PREFERENCE, -): string { - const script = bootThemeScript(preference) - const body = /]*)?>/i.exec(html) - if (body === null) return `${html}${script}` - const at = body.index + body[0].length - return `${html.slice(0, at)}${script}${html.slice(at)}` +): IndexInjection { + return { kind: 'script', placement: 'body', text: bootThemeScript(preference) } } diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index c56c030e17..cc119d1825 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-host-webserver' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { injectBootTheme } from './boot-theme.ts' +import { bootThemeInjection } from './boot-theme.ts' import { DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, type ThemePreference, type ThemeSettings, @@ -26,18 +26,16 @@ function readPreference(ctx: Context): ThemePreference { } /** - * Register the durable theme section and initial-theme index transform when - * their optional Host services are composed. - * @param ctx - Host context that may acquire settings and HTTP services. + * Register the durable theme section when the optional settings service is + * composed, and answer every index injection collection with the current + * theme bootstrap row. + * @param ctx - Host context that may acquire the settings service. */ export function apply(ctx: Context): void { ctx.inject(['settings'], (settingsCtx) => { settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema) }) - ctx.inject(['webServer'], (httpCtx) => { - httpCtx.effect( - () => httpCtx.webServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))), - 'client-ui-theme: initial theme bootstrap', - ) + ctx.on('webserver/index-inject', (table) => { + table.push(bootThemeInjection(readPreference(ctx))) }) } diff --git a/packages/client/ui-theme/tests/boot-theme.client.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts index a8d0dff235..ab737059c1 100644 --- a/packages/client/ui-theme/tests/boot-theme.client.spec.ts +++ b/packages/client/ui-theme/tests/boot-theme.client.spec.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom -/** Host index injection and the resulting pre-plugin browser theme. */ +/** The theme bootstrap injection row and the resulting pre-plugin browser theme. */ import { runInNewContext } from 'node:vm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { injectBootTheme } from '../src/boot-theme.ts' +import { bootThemeInjection } from '../src/boot-theme.ts' import type { ThemePreference } from '../src/theme-settings.ts' const DARK_ATTRIBUTE = 'data-ds-dark-theme' @@ -11,15 +11,10 @@ function mockSystemDark(matches: boolean): void { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) } -function executeBootstrap( - preference?: ThemePreference, - html = '
', -): string { - const injected = injectBootTheme(html, preference) - const source = /` } + } + case 'script': + return { placement: row.placement, markup: `` } + case 'script-src': + return { placement: row.placement, markup: `` } + case 'style': + return { placement: 'head', markup: `` } + case 'html': + return { placement: row.placement, markup: row.html } + default: + return assertNever(row) + } +} + +/** Insert `markup` into `html` at `at`. */ +function splice(html: string, at: number, markup: string): string { + return `${html.slice(0, at)}${markup}${html.slice(at)}` +} + +/** + * Render rows into an index.html body: head rows immediately after the + * opening head tag, body rows immediately after the opening body tag, each + * group in table order. + * @param html - the raw index.html body. + * @param rows - the collected injection table. + * @returns the html with every row rendered. + */ +export function renderIndexInjections(html: string, rows: readonly IndexInjection[]): string { + let head = '' + let body = '' + for (const row of rows) { + const rendered = renderRow(row) + if (rendered.placement === 'head') head += rendered.markup + else body += rendered.markup + } + let out = html + if (head !== '') { + const open = /]*)?>/i.exec(out) + // Headless fixture pages may lack ; prepending keeps the rows ahead + // of every document script. + out = open === null ? `${head}${out}` : splice(out, open.index + open[0].length, head) + } + if (body !== '') { + const open = /]*)?>/i.exec(out) + // Body-less fragments receive the rows at the end, where the HTML parser + // has already synthesized a body. + out = open === null ? `${out}${body}` : splice(out, open.index + open[0].length, body) + } + return out +} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 2cbd285856..ffe5b4648d 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import HttpServer from '../src/index.ts' +import HttpServer, { renderIndexInjections } from '../src/index.ts' let root: string | undefined let context: Context | undefined @@ -200,6 +200,54 @@ describe('real Loader composition', () => { await expect(request(port, '/probe')).rejects.toThrow() }) + it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => { + const loaded = await loadComposition() + const server = loaded.webServer + let flag = 'dark' + loaded.on('webserver/index-inject', (table) => { + table.push( + { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, + { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' }, + { kind: 'global', name: '__DSH_BOOT__', value: { rev: '' } }, + { kind: 'style', text: 'body{margin:0}' }, + { kind: 'html', placement: 'head', html: '' }, + { kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` }, + ) + }) + + const html = server.renderIndex('shell') + // Head rows land right after the opening head tag in table order; the body + // row lands right after the opening body tag. + const order = [ + '', + '', + '', + 'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}', + '', + '', + '', + '', + 'shell', + ].map(part => html.indexOf(part)) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(order.every(at => at !== -1)).toBe(true) + + // Fresh collection per render: the listener reads live state at emit time. + flag = 'light' + expect(server.renderIndex('')).toContain('window.__P__="light"') + + // Raw taps still run, over the already-rendered rows. + const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2')) + expect(server.renderIndex('')).toContain('window.__Q__=2') + untap() + + // Tag-less fragments: head rows prepend, body rows append. + expect(renderIndexInjections('
x
', [ + { kind: 'script', placement: 'head', text: 'H' }, + { kind: 'script', placement: 'body', text: 'B' }, + ])).toBe('
x
') + }) + it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => { const first = await loadComposition() const takenPort = first.webServer.port diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 19ce411406..a50606b568 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () try { // JSON.stringify quotes the value: YAML is a JSON superset, so a real // key survives whatever characters it happens to carry. - await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) // Scrub the ambient variable so only the credential seam can supply the // key: this request proves the per-request resolution path end to end. vi.stubEnv('DEEPSEEK_API_KEY', '') diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index be7d4e3688..d28c853c3a 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -100,7 +100,7 @@ describe('request-level dynamic configuration', () => { it('routes the next request with the freshly resolved base URL and credential', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: serverA.url }) @@ -258,7 +258,7 @@ describe('request-level dynamic configuration', () => { it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 1398d36110..ec83345b61 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -53,7 +53,7 @@ async function loadComposition( const credentialsPath = join(root, '.credentials.yaml') if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') - await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) + await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) } const configPath = join(root, 'cordis.yml') @@ -124,7 +124,7 @@ describe('llm-deepseek real dynamic composition', () => { await vi.waitFor(() => { expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) }, { timeout: 5000 }) - await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) + await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) await vi.waitFor(async () => { expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) }, { timeout: 5000 }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6ea69058b9..4e7379cb05 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: 6d62aabf954a80120bacc9049a7498af269dd067 -README.zh.md: e7e832fa356c48bf14d7aebac7351b259b924214 +README.md: 19dbcfa90dbefbe322800c83da6d75c70c849f05 +README.zh.md: 4066564224035561fab4ecd7f64ddb7265855ae6 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 6d62aabf95..19dbcfa90d 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -140,7 +140,7 @@ Most listings disclose an id and nothing else; `context_window`/`context_length` Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. -Credentials never enter that collection. The harness resolves a route's key through its own seam before the request reaches pi-ai and passes it as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override; `Models` therefore holds no credential store, and the harness keeps its fail-loud reference semantics. A route naming no credential resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives. +A route's `apiKeyEnv` key still resolves through the harness seam before the request reaches pi-ai and rides as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override — the fail-loud reference semantics stay the harness's. Beneath that override the collection carries this plugin's credential store and ambient auth context: a stored sign-in (an OAuth grant, or a key typed into pi-ai's own login prompt) authenticates its route through them and refreshes itself under the store's cross-process lock. The store addresses records as `llm-pi-ai/`, and a hand-declared route key outside that record grammar (uppercase, dots, underscores) reads as holding nothing stored rather than as an addressing error; such a route cannot be signed into — a record write for it refuses with `LlmError('UNSTORABLE_PROVIDER_ID')` — and authenticates through `apiKeyEnv` or ambient provider settings. A route naming no credential at all resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives. The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. @@ -197,8 +197,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work - **`maxRequestImageBytes` counts base64 image payload only** — text, tools, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. Offload is decided at request conversion as a pure function of history and configuration and is not recorded as a session event; per-route capability metadata (image count, per-image size, total request size) driving admission and assembly together is deferred design work. -- **A provider that authenticates through OAuth alone is not offered** — pi-ai resolves OAuth from a *stored* OAuth credential, and this adapter builds its `Models` collection with no credential store and runs no login flow, so every request on such a route fails `Provider is not configured` before it goes out. The configurable-provider directory withholds them; `openai-codex` is the only one the installed catalog ships. A route a settings document already names keeps its entry so a configuration surface can edit or delete it, and `apiKeyEnv` still authenticates it with that key — which for Codex is a token that expires with nothing here to refresh it. -- **Provider-native discovery reads the process environment only** — a route naming no credential defers to the catalog provider's own resolution, which interrogates environment variables (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, and each provider's own set). It reads no local credential directory, so `~/.aws/credentials` without an exported `AWS_PROFILE` resolves as unconfigured, and a value held by the harness credential seam is invisible to it unless the process environment carries it too. +- **A sign-in lives only in the process that started it** — an authorization attempt is not durable, so reloading the page mid-login abandons it and the human starts over. Signing out is `deleteRecord` on the stored record, which forgets it locally without telling the issuer. +- **Provider-native discovery answers through this plugin's ambient context** — a route naming no credential defers to the catalog provider's own resolution, which asks for environment values (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, and each provider's own set) and for local credential files. Both questions are answered here: the credential seam is consulted before the process environment, and file existence is checked against the host process's filesystem with `~` expanded. What it cannot do is *read* a credential file's contents — a provider that parses `~/.aws/credentials` itself does so directly, outside the seam. - **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. - **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. This only triggers when a `cordis.yml` entry config declares per-model reasoning fields for the same model the user layer edits; the supported posture is to leave those to the settings document (the shipped composition mounts the adapter dormant), and a `models` list is an array replacing wholesale, which is the in-band escape. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index e7e832fa35..4066564224 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -141,7 +141,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 -凭据绝不进入该集合。harness 在请求抵达 pi-ai 之前经自身 seam 解析路由密钥,并作为请求的 `apiKey` 选项传入,而 pi-ai 将其视为优先级最高的 auth 覆盖;因此 `Models` 不持有任何凭据存储,harness 也保住了自己明确失败的引用语义。没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。 +路由的 `apiKeyEnv` 密钥仍在请求抵达 pi-ai 之前经 harness 自身 seam 解析,并作为请求的 `apiKey` 选项传入——pi-ai 将其视为优先级最高的 auth 覆盖,harness 因此保住自己明确失败的引用语义。在该覆盖之下,集合携带本插件的凭据存储与 ambient auth context:已存储的登录(OAuth grant,或在 pi-ai 自己的登录提示里键入的密钥)经由它们为路由完成认证,并在存储的跨进程锁下自行刷新。存储以 `llm-pi-ai/` 为记录地址;手写路由键若落在记录文法之外(大写、点、下划线),读取时视为「没有存储任何东西」而不是寻址错误——这样的路由无法登录(对它的记录写入会以 `LlmError('UNSTORABLE_PROVIDER_ID')` 拒绝),只能经 `apiKeyEnv` 或提供方 ambient 设置认证。完全没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。 所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。 @@ -198,8 +198,8 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 - **`maxRequestImageBytes` 只统计 base64 图片载荷**:文本、工具与 JSON 结构不计入上限,因此该值必须低于网关请求体上限并留出余量。offload 在请求转换时决定,是历史与配置的纯函数,不记录为会话事件;由按路由能力元数据(图片数量、单图大小、请求总大小)同时驱动准入与组装的完整设计属于暂缓工作。 -- **仅以 OAuth 认证的提供方不予提供**:pi-ai 的 OAuth 只从*已存储*的 OAuth 凭据解析,而本适配器构造 `Models` 集合时不注入凭据存储、也不运行登录流程,因此这类路由的每个请求都会在发出之前以 `Provider is not configured` 失败。可配置提供方目录因此不列出它们;已安装 catalog 中只有 `openai-codex` 属于此类。settings 文档已经写过的路由仍保留目录条目,配置界面据此可以编辑或删除;`apiKeyEnv` 也仍能用该密钥完成认证——对 Codex 而言那是一个会过期、且这里没有任何环节会去刷新的 token。 -- **提供方自带的凭据发现只读进程环境**:不指定凭据的路由交由 catalog 提供方自行解析,而它探测的是环境变量(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE`、`AWS_ACCESS_KEY_ID` 以及各提供方自己的那一组)。它不读任何本地凭据目录,因此只有 `~/.aws/credentials` 而未导出 `AWS_PROFILE` 会被解析为未配置;由 harness 凭据 seam 保管的值,除非进程环境里也有,否则对它不可见。 +- **一次登录只存活于发起它的进程中**:授权尝试不可持久,登录途中刷新页面会丢弃它,人需要重来。登出即对已存储记录执行 `deleteRecord`,它只在本地遗忘而不通知签发方。 +- **提供方自带的凭据发现经由本插件的 ambient context 作答**:不指定凭据的路由交由 catalog 提供方自行解析,它会询问环境值(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE` 以及各提供方自己的那一组)与本地凭据文件是否存在。两类问题都在这里作答:先查凭据 seam 再查进程环境,文件存在性则按宿主进程的文件系统判断并展开 `~`。它做不到的是*读取*凭据文件的内容——自行解析 `~/.aws/credentials` 的提供方是直接读盘的,不经过 seam。 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。只有 `cordis.yml` entry config 为用户层正在编辑的同一模型声明了按模型推理字段才会触发;受支持的姿态是把这些字段留给 settings 文档(shipped 组合以 dormant 方式挂载该适配器),且 `models` 列表是数组、整体替换,这是带内的解决办法。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index be02e941df..ab286f006f 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -33,6 +33,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -47,6 +48,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 5ecec593da..3c7ecd4a91 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -13,10 +13,15 @@ * way down: switching models mid-reply takes effect on the next step, never * inside the one in flight. * - * Credentials stay outside that collection. The harness resolves a route's key - * through its own seam and passes it as the request's `apiKey` option, which - * pi-ai treats as the highest-priority auth override — so `Models` never holds - * a credential store and the harness keeps its fail-loud reference semantics. + * A route naming a credential reference still resolves it through the harness + * seam and passes it as the request's `apiKey` option, which pi-ai treats as + * the highest-priority auth override — that is what keeps the fail-loud + * reference semantics. Everything that override does not cover reaches pi-ai + * through the collection's own auth: the credential store holds the records a + * login wrote and a refresh rotates, and the auth context answers the ambient + * questions a provider asks while resolving. Both are stable across snapshots, + * so a configuration change rebuilds the collection without forgetting who is + * signed in. * * @module dsh-llm-pi-ai/adapter */ @@ -24,6 +29,8 @@ import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' import type { Api, + AuthContext, + CredentialStore, Model, Models, ModelThinkingLevel, @@ -74,6 +81,15 @@ export interface PiAiAdapterOptions { * `MISSING_CREDENTIAL` rather than falling back. */ resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise + /** + * How every collection this adapter builds resolves auth the request-level + * `apiKey` override does not cover. Required rather than optional: a + * collection built without them gets pi-ai's in-memory default store, which + * is empty at every boot and discarded on every configuration change, so a + * route whose only method is a login would report itself unconfigured on + * every request no matter how often the human signed in. + */ + auth: PiAiAuthInjection /** Resolve the optional durable attachment service at request time. */ resolveAttachments?: () => AttachmentStore | undefined /** @@ -83,6 +99,14 @@ export interface PiAiAdapterOptions { onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void } +/** The two auth injectables a pi-ai collection is built with. */ +export interface PiAiAuthInjection { + /** Durable storage for credentials pi-ai itself writes: logins, and the refreshes it runs under its own lock. */ + credentials: CredentialStore + /** Ambient lookups a provider performs while resolving its own auth. */ + authContext: AuthContext +} + /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( profile: ResolvedPiAiProviderProfile, @@ -204,7 +228,7 @@ export class PiAiAdapter extends LlmAdapter { private current(): PiAiSnapshot { const profiles = this.config.profiles() if (this.snapshot?.profiles === profiles) return this.snapshot - const models: MutableModels = createModels() + const models: MutableModels = createModels(this.config.auth) for (const profile of profiles.values()) models.setProvider(profile.piProvider) this.snapshot = { profiles, models } return this.snapshot diff --git a/packages/llm/llm-pi-ai/src/auth.ts b/packages/llm/llm-pi-ai/src/auth.ts new file mode 100644 index 0000000000..f729a77f85 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/auth.ts @@ -0,0 +1,207 @@ +/** + * The three adapters between pi-ai's auth model and the harness credential + * plane. Every pi-ai-specific concept stays on this side of them: the harness + * seams they consume — `ctx.credentials` records and `ctx.authorization` flows — + * name nothing from this library, so another adapter family can arrive with a + * different auth model and share the same two seams. + * + * @module dsh-llm-pi-ai/auth + */ + +import { homedir } from 'node:os' +import { access } from 'node:fs/promises' +import { resolve as resolvePath } from 'node:path' +import type { AuthContext, Credential, CredentialInfo, CredentialStore } from '@earendil-works/pi-ai' +import type { Context } from '@deepseek-ai/cordis' +import { + credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName, +} from '@deepseek-ai/dsh-credentials' +import type { CredentialKey, CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { LlmError } from '@deepseek-ai/dsh-llm' + +/** + * The record scope every credential this adapter family stores is written + * under. It is the plugin's registered name, which is what tells a later + * reader — a configuration UI, or a second adapter family serving the same + * provider name — that this plugin owns the format inside the record. + */ +export const RECORD_SCOPE = 'llm-pi-ai' + +/** + * The record address for one pi-ai provider id. + * @param providerId - pi-ai's own provider id, which is also the harness route key. + * @returns the scoped credential key this adapter family reads and writes. + */ +export function recordKeyFor(providerId: string): CredentialKey { + return credentialKey(RECORD_SCOPE, providerId) +} + +/** + * Translate a stored record into the credential pi-ai expects. + * + * An `api-key` record is structural on both sides, so it is rebuilt field by + * field. A `grant` payload is pi-ai's own OAuth credential, stored verbatim: + * the seam treats it as opaque JSON precisely so a library that owns a token + * format keeps owning it, refresh fields and all. + * @param record - the stored record, or undefined when nothing is stored. + * @returns the pi-ai credential, or undefined for an absent record. + */ +function toPiCredential(record: CredentialRecord | undefined): Credential | undefined { + if (record === undefined) return undefined + if (record.kind === 'api-key') { + return { + type: 'api_key', + ...record.key === undefined ? {} : { key: record.key }, + ...record.env === undefined ? {} : { env: { ...record.env } }, + } + } + return record.payload as Credential +} + +/** + * Translate a pi-ai credential into the record to store. + * @param credential - what a login or refresh produced. + * @returns the record to commit, in the union the credential seam stores. + */ +function toRecord(credential: Credential): CredentialRecord { + if (credential.type === 'api_key') { + return { + kind: 'api-key', + ...credential.key === undefined ? {} : { key: credential.key }, + ...credential.env === undefined ? {} : { env: { ...credential.env } }, + } + } + return { kind: 'grant', payload: credential } +} + +/** + * The credential service, or the failure that names what is missing. Reads + * answer "nothing stored" without a service, because a composition with no + * credential plane genuinely holds no credential; writes refuse, because a + * login whose grant silently evaporated would report success and then fail + * every request. + * @param ctx - the plugin context. + * @returns the live service. + * @throws {LlmError} code `NO_CREDENTIAL_STORE` when none is mounted. + */ +function writableStore(ctx: Context): CredentialProvider { + const credentials = ctx.get('credentials') + if (credentials === undefined) { + throw new LlmError( + 'llm-pi-ai: this composition mounts no credentials service, so there is nowhere to store the' + + ' credential a sign-in produces; mount one (dsh-credentials-local) to sign in', + 'NO_CREDENTIAL_STORE', + ) + } + return credentials +} + +/** + * A pi-ai `CredentialStore` over the harness credential records. + * + * pi-ai runs OAuth refresh *inside* `modify()`, so this store's exclusion has + * to cover a network round trip rather than a file rename — which is why the + * record write path takes a wait limit of its own rather than the short one a + * local write would need. + * + * pi-ai asks this store about every provider in the collection, hand-declared + * routes included, and a route key is an arbitrary settings dict key while a + * record id is not. An id outside the record grammar can never have stored a + * record, so reads answer "nothing stored" and a delete has nothing to remove; + * only `modify` refuses it, because a write that cannot land must not report + * that it did. + * @param ctx - the plugin context carrying the optional `ctx.credentials`. + * @returns the store to hand `createModels()`. + */ +export function credentialStoreFrom(ctx: Context): CredentialStore { + return { + async read(providerId) { + const credentials = ctx.get('credentials') + if (credentials === undefined) return undefined + if (!isCredentialKeySegment(providerId)) return undefined + return toPiCredential(await credentials.readRecord(recordKeyFor(providerId))) + }, + async list(): Promise { + const stored = await ctx.get('credentials')?.listRecords() ?? [] + const mine: CredentialInfo[] = [] + for (const entry of stored) { + // Records another plugin owns are not this collection's to report: + // their payloads are written in a format pi-ai never agreed to. + if (credentialKeyScope(entry.key) !== RECORD_SCOPE) continue + mine.push({ + providerId: credentialKeyId(entry.key), + type: entry.kind === 'api-key' ? 'api_key' : 'oauth', + }) + } + return mine + }, + async modify(providerId, mutate) { + if (!isCredentialKeySegment(providerId)) { + throw new LlmError( + `llm-pi-ai: provider id "${providerId}" cannot address a stored credential record (a record id is a` + + ' lowercase hyphenated identifier); authenticate this route through apiKeyEnv instead of a stored' + + ' credential', + 'UNSTORABLE_PROVIDER_ID', + ) + } + const stored = await writableStore(ctx).modifyRecord(recordKeyFor(providerId), async (current) => { + const next = await mutate(toPiCredential(current)) + return next === undefined ? undefined : toRecord(next) + }) + return toPiCredential(stored) + }, + // `async` so a missing service reaches the caller as a rejection: pi-ai's + // store contract is promise-returning, and a synchronous throw would + // escape the `ModelsError` wrapper every other storage failure gets. + async delete(providerId) { + if (!isCredentialKeySegment(providerId)) return + await writableStore(ctx).deleteRecord(recordKeyFor(providerId)) + }, + } +} + +/** + * A pi-ai `AuthContext` over the harness credential plane and the host + * filesystem. + * + * `env()` answers from the credential seam first, so a value a deployment + * stored through the harness is found by a provider's own ambient discovery — + * without this, that discovery reads only the process environment and a stored + * `AWS_ACCESS_KEY_ID` is invisible to it. `fileExists()` answers about the host + * process's own filesystem rather than the workspace `ctx.fs` seam, because the + * paths it is asked about (`~/.aws/credentials`, application-default + * credentials) are facts about where this process runs, not about the project + * under edit. + * @param ctx - the plugin context carrying the optional `ctx.credentials`. + * @returns the auth context to hand `createModels()`. + */ +export function authContextFrom(ctx: Context): AuthContext { + return { + async env(name) { + // pi-ai asks about arbitrary provider-declared names; one that is not a + // POSIX identifier can never have been stored as a reference, and asking + // the seam would throw instead of answering "not set". + if (isCredentialRefName(name)) { + const credentials = ctx.get('credentials') + const hit = await credentials?.resolve(credentialRef(name)) + if (hit !== undefined) return hit.value + } + return launchEnvironmentOf(ctx).get(name)?.value + }, + async fileExists(path) { + const expanded = path.startsWith('~/') || path === '~' + ? resolvePath(homedir(), path.slice(1).replace(/^\//, '')) + : path + try { + await access(expanded) + return true + } catch { + // Absent, unreadable, or a broken symlink — every one of which means + // this ambient credential source cannot be used, which is the only + // distinction the caller makes. + return false + } + }, + } +} diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 229e881c98..1e9b126dd5 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -176,26 +176,6 @@ export function catalogProviderIds(): readonly string[] { return getBuiltinProviders() } -/** - * Whether the installed catalog provider for one route declares an api-key - * method — the only authentication this adapter obtains on its own. - * - * A key is what the harness resolves through its own credential seam and hands - * pi-ai per request. pi-ai's other method, OAuth, resolves from a *stored* - * OAuth credential alone: `resolveProviderAuth` has no ambient path for it, - * this adapter builds its `Models` collection with no credential store, and - * nothing here runs a login flow. So a provider offering OAuth by itself - * leaves nothing for this adapter to authenticate with, and the posture such a - * provider invites — no key configured, credentials discovered by the provider - * — fails every request with `Provider is not configured`. - * @param provider - provider route key. - * @returns whether the catalog provider takes an api key; false for a route - * pi-ai does not ship, which the caller answers for separately. - */ -export function catalogProviderTakesApiKey(provider: string): boolean { - return catalogProvider(provider)?.auth.apiKey !== undefined -} - /** * The installed catalog models for one route, indexed by model id. * @param provider - provider route key. diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 1bbeec79db..846509f8c6 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -61,10 +61,12 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' -import { catalogProviderIds, catalogProviderTakesApiKey } from './catalog.ts' +import { authContextFrom, credentialStoreFrom } from './auth.ts' +import { catalogProviderIds } from './catalog.ts' import { assertServiceable, Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' import { discoverModels } from './discovery.ts' +import { registerPiAiFlows } from './login.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' @@ -79,6 +81,7 @@ export type { PiAiThinkingFormat, ResolvedPiAiProviderProfile, } from './config.ts' +export { recordKeyFor } from './auth.ts' export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' @@ -105,15 +108,10 @@ function registrationFacts(profiles: ReadonlyMap ctx.get('attachments'), onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn( @@ -208,6 +204,12 @@ export function apply(ctx: Context, config: Config): void { ) }, }) + // Independent of the route set: signing in is what makes a route worth + // adding, so the flows are offered before any profile names their provider. + // Scoped to the authorization seam rather than injected outright, because a + // composition without it (headless, ACP) simply has no surface to sign in + // from, while everything else this plugin does still works. + ctx.inject(['authorization'], (authorized) => { registerPiAiFlows(authorized, auth) }) // The full installed catalog is configurable from the moment the plugin // mounts — dormant or not — so configuration surfaces can offer every // pi-ai provider before any route exists. Hand-declared routes join it as diff --git a/packages/llm/llm-pi-ai/src/login.ts b/packages/llm/llm-pi-ai/src/login.ts new file mode 100644 index 0000000000..578489afb4 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/login.ts @@ -0,0 +1,161 @@ +/** + * Authorization flows for the pi-ai providers that ship a login. This is the + * whole of the translation between the harness's neutral notice/prompt + * vocabulary and pi-ai's `AuthInteraction`; nothing above it knows which + * library ran the conversation. + * + * @module dsh-llm-pi-ai/login + */ + +import { createModels } from '@earendil-works/pi-ai' +import type { AuthEvent, AuthPrompt, AuthType, Provider } from '@earendil-works/pi-ai' +import type { Context } from '@deepseek-ai/cordis' +import type { AuthorizationMethod, AuthorizationPrompt, AuthorizationSession } from '@deepseek-ai/dsh-authorization' +import { isCredentialKeySegment } from '@deepseek-ai/dsh-credentials' +import { catalogProvider, catalogProviderIds } from './catalog.ts' +import { recordKeyFor } from './auth.ts' +import type { PiAiAuthInjection } from './adapter.ts' + +/** + * The login methods one catalog provider offers. + * + * A method appears only when pi-ai can actually run it: `oauth` always carries + * a `login`, while an api-key method has one only when the provider collects + * its key interactively — which every installed one currently does, so a key is + * typed into pi-ai's own prompt rather than into the settings form. + * @param provider - the installed catalog provider, if pi-ai ships one. + * @returns its methods, most preferred first; empty when it offers no login. + */ +function loginMethods(provider: Provider | undefined): AuthorizationMethod[] { + const methods: AuthorizationMethod[] = [] + const oauth = provider?.auth.oauth + if (oauth !== undefined) methods.push({ id: 'oauth', label: oauth.loginLabel ?? oauth.name }) + const apiKey = provider?.auth.apiKey + if (apiKey?.login !== undefined) methods.push({ id: 'api-key', label: apiKey.name }) + return methods +} + +/** + * Restate one pi-ai login event in the seam's vocabulary. + * + * A device-code grant is the one event carrying two things the human needs at + * once — where to go and what to type there — which is why the neutral notice + * has a `code` beside its `url` rather than folding the code into the message. + * @param event - what pi-ai reported. + * @param session - the attempt to report it to. + */ +function relay(event: AuthEvent, session: AuthorizationSession): void { + switch (event.type) { + case 'info': { + const link = event.links?.[0] + session.notify({ message: event.message, ...link === undefined ? {} : { url: link.url } }) + return + } + case 'auth_url': + session.notify({ + message: event.instructions ?? 'Open this page to continue signing in.', + url: event.url, + }) + return + case 'device_code': + session.notify({ + message: 'Enter this code on the verification page to finish signing in.', + url: event.verificationUri, + code: event.userCode, + }) + return + case 'progress': + session.notify({ message: event.message }) + return + default: + // pi-ai's event union is open to new members: a build that meets one it + // does not know still shows the human that something is happening rather + // than going silent mid-login. + session.notify({ message: 'Signing in…' }) + } +} + +/** + * Restate one pi-ai prompt in the seam's vocabulary. + * + * `manual_code` becomes a plain text question because the difference pi-ai + * draws — a code the human copies from a browser rather than a value they know + * — changes nothing a surface renders. Its own `signal` is carried through, and + * that is the part which matters: it is how a flow racing a typed code against + * a browser callback withdraws the losing question. + * @param prompt - what pi-ai asked. + * @returns the neutral prompt to put to the human. + */ +function restate(prompt: AuthPrompt): AuthorizationPrompt { + const signal = prompt.signal === undefined ? {} : { signal: prompt.signal } + switch (prompt.type) { + case 'select': + return { ...signal, kind: 'select', message: prompt.message, options: prompt.options } + case 'secret': + return { + ...signal, + kind: 'secret', + message: prompt.message, + ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }, + } + default: + return { + ...signal, + kind: 'text', + message: prompt.message, + ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }, + } + } +} + +/** + * Register one authorization flow per installed provider that ships a login. + * + * Registration is unconditional on configuration: a provider has to be signed + * into before a route for it is worth adding, so the flow exists from the + * moment the plugin mounts rather than appearing once a profile does. + * @param ctx - the plugin context carrying `ctx.authorization`. + * @param auth - the injectables every collection here is built with. + */ +export function registerPiAiFlows(ctx: Context, auth: PiAiAuthInjection): void { + for (const providerId of catalogProviderIds()) { + const provider = catalogProvider(providerId) + const [first, ...rest] = loginMethods(provider) + /* v8 ignore next 3 -- every id here names an installed provider and every + installed provider ships a login, so nothing is skipped today; the guard + is what keeps that from becoming a crash if either stops being true. */ + if (provider === undefined || first === undefined) continue + /* v8 ignore next 7 -- every installed catalog id today is a lowercase + hyphenated identifier; the guard keeps a future upstream id outside the + record grammar (dotted or uppercase, as vendor ids elsewhere already + are) from throwing in `recordKeyFor` and failing the whole mount. */ + if (!isCredentialKeySegment(providerId)) { + ctx.logger.warn( + 'llm-pi-ai: catalog provider "%s" cannot address a credential record; its sign-in is not offered', + providerId) + continue + } + ctx.authorization.registerFlow({ + key: recordKeyFor(providerId), + label: provider.name, + methods: [first, ...rest], + async run(session) { + // A collection of its own, holding only the provider being signed + // into: login is not serving requests, and the credential it produces + // lands in the shared store either way. + const models = createModels(auth) + models.setProvider(provider) + // Total over the two ids declared above, and the seam only ever hands + // back one a flow declared. + const type: AuthType = session.method === 'oauth' ? 'oauth' : 'api_key' + // pi-ai persists what the login returns through that same store, which + // is what makes it the single writer of this record. + await models.login(providerId, type, { + signal: session.signal, + notify: (event) => { relay(event, session) }, + prompt: prompt => session.prompt(restate(prompt)), + }) + }, + }) + } +} diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 2446b10286..a36a180f7d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -13,6 +13,7 @@ import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { DEFAULT_MAX_REQUEST_IMAGE_BYTES, resolveProfiles } from '../src/config.ts' +import { memoryAuth } from './auth-double.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -47,6 +48,7 @@ function adapterOf( return new PiAiAdapter({ profiles: () => resolveProfiles(providers), resolveApiKey: () => Promise.resolve(apiKey), + auth: memoryAuth(), }) } diff --git a/packages/llm/llm-pi-ai/tests/auth-double.ts b/packages/llm/llm-pi-ai/tests/auth-double.ts new file mode 100644 index 0000000000..a05c017d04 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/auth-double.ts @@ -0,0 +1,39 @@ +import type { Credential } from '@earendil-works/pi-ai' +import type { PiAiAuthInjection } from '../src/adapter.ts' + +/** + * The auth injectables for tests that exercise streaming rather than + * authentication: an in-process credential store and an ambient context that + * finds nothing. A test needing real records builds the store over + * `ctx.credentials` instead, through `credentialStoreFrom`. + * @param seed - credentials to start with, by pi-ai provider id. + * @returns the injection to hand `PiAiAdapter`, with its store readable. + */ +export function memoryAuth(seed: Record = {}): PiAiAuthInjection & { + stored: Map +} { + const stored = new Map(Object.entries(seed)) + return { + stored, + credentials: { + read: id => Promise.resolve(stored.get(id)), + list: () => Promise.resolve([...stored].map(([providerId, credential]) => ({ + providerId, + type: credential.type, + }))), + async modify(id, mutate) { + const next = await mutate(stored.get(id)) + if (next !== undefined) stored.set(id, next) + return stored.get(id) + }, + delete: (id) => { + stored.delete(id) + return Promise.resolve() + }, + }, + authContext: { + env: () => Promise.resolve(undefined), + fileExists: () => Promise.resolve(false), + }, + } +} diff --git a/packages/llm/llm-pi-ai/tests/auth.spec.ts b/packages/llm/llm-pi-ai/tests/auth.spec.ts new file mode 100644 index 0000000000..27a496a2c3 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/auth.spec.ts @@ -0,0 +1,172 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' +import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials' +import { authContextFrom, credentialStoreFrom, recordKeyFor } from '../src/auth.ts' + +const CODEX = recordKeyFor('openai-codex') + +const dirs: string[] = [] + +/** A context whose credential records live in a throwaway `$DSH_HOME`. */ +async function stored(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-auth-')) + dirs.push(dir) + const ctx = new Context() + await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + return ctx +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('pi-ai credential store over harness records', () => { + it('reads nothing for a provider with no record', async () => { + const store = credentialStoreFrom(await stored()) + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + }) + + it('round-trips an api-key credential field by field', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + + await store.modify('cloudflare', () => + Promise.resolve({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } })) + + await expect(store.read('cloudflare')) + .resolves.toEqual({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } }) + await expect(ctx.credentials.readRecord(recordKeyFor('cloudflare'))) + .resolves.toEqual({ kind: 'api-key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } }) + }) + + it('stores an api-key credential carrying neither a key nor env', async () => { + const store = credentialStoreFrom(await stored()) + + await store.modify('bedrock', () => Promise.resolve({ type: 'api_key' })) + + await expect(store.read('bedrock')).resolves.toEqual({ type: 'api_key' }) + }) + + it('keeps an OAuth credential verbatim, refresh fields and all', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + const granted = { type: 'oauth' as const, access: 'at', refresh: 'rt', expires: 42, accountId: 'acc' } + + await store.modify('openai-codex', () => Promise.resolve(granted)) + + await expect(store.read('openai-codex')).resolves.toEqual(granted) + await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ kind: 'grant', payload: granted }) + }) + + it('shows the mutation the current credential and leaves it alone when declined', async () => { + const store = credentialStoreFrom(await stored()) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 })) + const seen: unknown[] = [] + + const unchanged = await store.modify('openai-codex', (current) => { + seen.push(current) + return Promise.resolve(undefined) + }) + + expect(seen).toEqual([{ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }]) + expect(unchanged).toEqual({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }) + }) + + it('lists only the records this adapter family owns', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 })) + await store.modify('cloudflare', () => Promise.resolve({ type: 'api_key', key: 'k' })) + // Another plugin's record for a provider name this one also serves: its + // payload is written in a format pi-ai never agreed to. + await ctx.credentials.modifyRecord(credentialKey('llm-kimi', 'openai-codex'), () => + Promise.resolve({ kind: 'grant', payload: { theirs: true } })) + + await expect(store.list()).resolves.toEqual([ + { providerId: 'openai-codex', type: 'oauth' }, + { providerId: 'cloudflare', type: 'api_key' }, + ]) + }) + + it('forgets a credential on delete, and stays quiet when there was none', async () => { + const store = credentialStoreFrom(await stored()) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 })) + + await store.delete('openai-codex') + await store.delete('openai-codex') + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + }) + + it('reads empty but refuses to write without a credentials service', async () => { + const store = credentialStoreFrom(new Context()) + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + await expect(store.list()).resolves.toEqual([]) + await expect(store.modify('openai-codex', () => Promise.resolve({ type: 'api_key', key: 'k' }))) + .rejects.toThrow(/mounts no credentials service/) + await expect(store.delete('openai-codex')).rejects.toThrow(/mounts no credentials service/) + }) + + it('treats a provider id outside the record grammar as holding nothing', async () => { + const store = credentialStoreFrom(await stored()) + + // A hand-declared route key is an arbitrary settings dict key, and pi-ai + // reads it during auth resolution: the answer is "not signed in", never a + // thrown address error… + await expect(store.read('My_Proxy')).resolves.toBeUndefined() + // …nothing can ever be stored under it, so a logout has nothing to remove… + await expect(store.delete('My_Proxy')).resolves.toBeUndefined() + // …while a write that cannot land must refuse rather than report success. + await expect(store.modify('My_Proxy', () => Promise.resolve({ type: 'api_key', key: 'k' }))) + .rejects.toThrow(/cannot address a stored credential record/) + }) +}) + +describe('pi-ai ambient auth context', () => { + beforeEach(() => { + vi.stubEnv('PI_AUTH_AMBIENT', 'from-environment') + }) + + it('answers an environment name from the credential seam first', async () => { + const ctx = await stored() + await ctx.credentials.set(credentialRef('PI_AUTH_SEAM'), 'from-seam') + + await expect(authContextFrom(ctx).env('PI_AUTH_SEAM')).resolves.toBe('from-seam') + }) + + it('falls back to the launch environment when nothing is stored', async () => { + await expect(authContextFrom(await stored()).env('PI_AUTH_AMBIENT')).resolves.toBe('from-environment') + }) + + it('answers "not set" for a name no reference could ever address', async () => { + // pi-ai asks about provider-declared names; one outside the reference + // grammar has no reference to miss, and must not throw. + await expect(authContextFrom(await stored()).env('not a var')).resolves.toBeUndefined() + }) + + it('answers about the host filesystem, expanding a leading ~', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-home-')) + dirs.push(dir) + await writeFile(join(dir, 'creds'), 'x') + // Both spellings of "home": os.homedir() reads HOME on POSIX and + // USERPROFILE on Windows, and the expansion under test goes through it. + vi.stubEnv('HOME', dir) + vi.stubEnv('USERPROFILE', dir) + const context = authContextFrom(await stored()) + + await expect(context.fileExists('~/creds')).resolves.toBe(true) + await expect(context.fileExists('~/missing')).resolves.toBe(false) + await expect(context.fileExists(join(dir, 'creds'))).resolves.toBe(true) + await expect(context.fileExists('~')).resolves.toBe(true) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 0322b81edf..99886cdbaf 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -15,6 +15,7 @@ import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-wo import { resolveProfiles } from '../src/config.ts' import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' +import { memoryAuth } from './auth-double.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const homes: string[] = [] @@ -1065,6 +1066,7 @@ describe('resolution snapshots', () => { // Credential resolution is the real await inside a stream call, and the // window a configuration change has to land in. resolveApiKey: async () => { await held; return 'k' }, + auth: memoryAuth(), }) const chunks: StreamChunk[] = [] @@ -1093,7 +1095,11 @@ describe('resolution snapshots', () => { const first = await mockServer([{ events: textEvents }]) const second = await mockServer([{ events: textEvents }]) let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } }) - const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') }) + const adapter = new PiAiAdapter({ + profiles: () => current, + resolveApiKey: () => Promise.resolve('k'), + auth: memoryAuth(), + }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], @@ -1160,30 +1166,22 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly) }) - it('withholds a catalog route this adapter cannot authenticate', async () => { + it('offers every installed catalog route, including one that only signs in', async () => { const ctx = await harness({}) const offered = ctx.llm.listConfigurableProviders().map(entry => entry.provider) // `openai-codex` is the one installed provider that authenticates through - // OAuth alone. pi-ai resolves OAuth only from a *stored* credential, this - // adapter constructs its collection with no credential store, and nothing - // here runs a login flow — so every request on such a route fails with - // `Provider is not configured` before it goes out. Offering it would put a - // provider on the settings page that no amount of configuration can make - // work. - expect(offered).not.toContain('openai-codex') - // A provider that offers OAuth *beside* an api-key method keeps its entry: - // the key is a path this adapter can serve. + // OAuth alone. It is offered like any other because the collection now + // carries a durable credential store and a login flow writes into it, so + // the route has a posture that works rather than only one that fails. + expect(offered).toContain('openai-codex') expect(offered).toContain('anthropic') expect(offered).toContain('openai') }) - it('still lists a withheld route a stored profile names, as a catalog route', async () => { - // Withholding the offer must not strand a profile someone already stored: - // the route keeps its entry so a configuration surface can edit or delete - // it, and `declared` still answers catalog membership rather than the - // offer, so the page does not mislabel it as a route this deployment - // invented. + it('lists a route a stored profile names as a catalog route, not a declared one', async () => { + // `declared` answers catalog membership, so a profile stored against a + // route pi-ai ships is not mislabelled as one this deployment invented. const ctx = await harness({ providers: { 'openai-codex': { apiKeyEnv: KEY_ENV } } }) expect(ctx.llm.listConfigurableProviders()).toContainEqual({ diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 1019354156..3decff3749 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -9,6 +9,7 @@ import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { FileSettingsProvider } from '@deepseek-ai/dsh-settings-file' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import AuthorizationService from '@deepseek-ai/dsh-authorization' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -37,7 +38,11 @@ async function home(): Promise { } /** Real dynamic composition mirroring the deepseek twin's harness. */ -async function boot(dir: string, config: LlmPiAi.Config): Promise { +async function boot( + dir: string, + config: LlmPiAi.Config, + options: { authorization?: boolean } = {}, +): Promise { const ctx = new Context() cleanups.push(async () => { await ctx.fiber.dispose() @@ -45,17 +50,38 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + if (options.authorization === true) await ctx.plugin(AuthorizationService) await ctx.plugin(LlmPiAi, config) return ctx } +describe('login flows in a real composition', () => { + it('offers a sign-in for a provider no route names, once the seam is mounted', async () => { + const ctx = await boot(await home(), {}, { authorization: true }) + + // Zero routes configured: signing in is what makes a route worth adding, + // so the offer cannot wait for a profile to name the provider. + const codex = ctx.authorization.describe(LlmPiAi.recordKeyFor('openai-codex')) + expect(codex?.methods.map(method => method.id)).toEqual(['oauth']) + }) + + it('mounts without the seam, and simply offers no sign-in', async () => { + const ctx = await boot(await home(), {}) + + // A headless or ACP composition has no surface to sign in from; everything + // else this plugin does still works. + expect(ctx.get('authorization')).toBeUndefined() + expect(ctx.llm.listConfigurableProviders().length).toBeGreaterThan(0) + }) +}) + describe('request-level dynamic profiles', () => { it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() await writeFile( join(dir, '.credentials.yaml'), - 'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-from-settings\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }]) @@ -93,7 +119,7 @@ describe('request-level dynamic profiles', () => { const dir = await home() await writeFile( join(dir, '.credentials.yaml'), - 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }]) @@ -122,7 +148,7 @@ describe('request-level dynamic profiles', () => { it('rotates the per-request credential referenced by apiKeyEnv', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 }) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) const ctx = await boot(dir, { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, @@ -173,7 +199,7 @@ describe('request-level dynamic profiles', () => { const dir = await home() await writeFile( join(dir, '.credentials.yaml'), - 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index a4e89424f6..6ca02f281a 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -49,7 +49,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) const settingsPath = join(root, 'settings.yaml') await writeFile(settingsPath, '# personal settings\n') - await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) + await writeFile(join(root, '.credentials.yaml'), 'version: 1\nrefs:\n PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ diff --git a/packages/llm/llm-pi-ai/tests/login.spec.ts b/packages/llm/llm-pi-ai/tests/login.spec.ts new file mode 100644 index 0000000000..beb65ce188 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/login.spec.ts @@ -0,0 +1,198 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AuthorizationService from '@deepseek-ai/dsh-authorization' +import type { AuthorizationInteraction, AuthorizationNotice, AuthorizationPrompt } from '@deepseek-ai/dsh-authorization' +import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' +import type { CredentialKey } from '@deepseek-ai/dsh-credentials' +import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType, Credential } from '@earendil-works/pi-ai' + +const login = vi.hoisted(() => vi.fn()) + +// The whole of what this module does with pi-ai is run one provider's login +// against a collection built with the harness store, so the collection is the +// boundary worth observing; a real login would open a browser. +vi.mock('@earendil-works/pi-ai', async importOriginal => ({ + ...await importOriginal(), + createModels: () => ({ setProvider: () => {}, login }), +})) + +const { credentialStoreFrom, authContextFrom, recordKeyFor } = await import('../src/auth.ts') +const { registerPiAiFlows } = await import('../src/login.ts') + +const CODEX = recordKeyFor('openai-codex') +const dirs: string[] = [] + +/** A context with the record store, the seam, and every pi-ai login flow. */ +async function harness(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-login-')) + dirs.push(dir) + const ctx = new Context() + await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + await ctx.plugin(AuthorizationService) + registerPiAiFlows(ctx, { credentials: credentialStoreFrom(ctx), authContext: authContextFrom(ctx) }) + return ctx +} + +/** An interaction recording everything a flow says, answering every question. */ +function surface(answer = 'typed'): AuthorizationInteraction & { + notices: AuthorizationNotice[] + prompts: AuthorizationPrompt[] +} { + const notices: AuthorizationNotice[] = [] + const prompts: AuthorizationPrompt[] = [] + return { + notices, + prompts, + notify: (notice) => { notices.push(notice) }, + prompt: (prompt) => { + prompts.push(prompt) + return Promise.resolve(answer) + }, + } +} + +/** Drive one attempt, letting the mocked login talk back through `converse`. */ +async function attempt( + ctx: Context, + converse: (interaction: AuthInteraction) => Promise, + request: { key?: CredentialKey; method?: string } = {}, +): Promise> { + const ui = surface() + login.mockImplementation(async (providerId: string, _type: AuthType, interaction: AuthInteraction) => { + await converse(interaction) + const granted: Credential = { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 } + await credentialStoreFrom(ctx).modify(providerId, () => Promise.resolve(granted)) + return granted + }) + await expect(ctx.authorization.begin({ + key: request.key ?? CODEX, + interaction: ui, + ...request.method === undefined ? {} : { method: request.method }, + })).resolves.toEqual({ status: 'authorized' }) + return ui +} + +afterEach(async () => { + login.mockReset() + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('pi-ai login flows', () => { + it('offers one flow per installed provider, with the methods that provider ships', async () => { + const ctx = await harness() + const offered = ctx.authorization.list() + + // The OAuth-only provider is exactly the case this exists for: nothing + // else could ever configure it. + expect(offered.find(entry => entry.key === CODEX)?.methods) + .toEqual([{ id: 'oauth', label: expect.stringContaining('ChatGPT') as string }]) + // A provider offering both keeps both, the subscription login first. + expect(offered.find(entry => entry.key === recordKeyFor('anthropic'))?.methods.map(one => one.id)) + .toEqual(['oauth', 'api-key']) + // A key-only provider still gets a flow, because pi-ai collects the key + // through its own prompt rather than leaving it to the settings form. + expect(offered.find(entry => entry.key === recordKeyFor('deepseek'))?.methods.map(one => one.id)) + .toEqual(['api-key']) + }) + + it('runs the pi-ai auth type the chosen method names', async () => { + const ctx = await harness() + + await attempt(ctx, () => Promise.resolve()) + expect(login).toHaveBeenLastCalledWith('openai-codex', 'oauth', expect.anything()) + + await attempt(ctx, () => Promise.resolve(), { key: recordKeyFor('anthropic'), method: 'api-key' }) + expect(login).toHaveBeenLastCalledWith('anthropic', 'api_key', expect.anything()) + }) + + it('commits what the login produced, where the adapter reads it back', async () => { + const ctx = await harness() + + await attempt(ctx, () => Promise.resolve()) + + await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ + kind: 'grant', + payload: { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }, + }) + }) + + it('restates every pi-ai login event in the neutral vocabulary', async () => { + const ctx = await harness() + const events: AuthEvent[] = [ + { type: 'info', message: 'Read this first', links: [{ url: 'https://help.example' }] }, + { type: 'info', message: 'Nothing to open' }, + { type: 'auth_url', url: 'https://auth.example/start', instructions: 'Approve in the tab' }, + { type: 'auth_url', url: 'https://auth.example/plain' }, + { type: 'device_code', userCode: 'WXYZ-1234', verificationUri: 'https://device.example' }, + { type: 'progress', message: 'Exchanging the code' }, + // pi-ai's event union is open; an unrecognised member must still show + // the human that something is happening. + { type: 'quantum-handshake' } as unknown as AuthEvent, + ] + + const ui = await attempt(ctx, (interaction) => { + for (const event of events) interaction.notify(event) + return Promise.resolve() + }) + + expect(ui.notices).toEqual([ + { message: 'Read this first', url: 'https://help.example' }, + { message: 'Nothing to open' }, + { message: 'Approve in the tab', url: 'https://auth.example/start' }, + { message: 'Open this page to continue signing in.', url: 'https://auth.example/plain' }, + { + message: 'Enter this code on the verification page to finish signing in.', + url: 'https://device.example', + code: 'WXYZ-1234', + }, + { message: 'Exchanging the code' }, + { message: 'Signing in…' }, + ]) + }) + + it('restates every pi-ai prompt, carrying the per-prompt withdrawal signal', async () => { + const ctx = await harness() + const withdraw = new AbortController() + const prompts: AuthPrompt[] = [ + { type: 'text', message: 'Your workspace', placeholder: 'acme' }, + { type: 'secret', message: 'Paste the key' }, + { type: 'secret', message: 'Paste the token', placeholder: 'sk-…' }, + { type: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] }, + // The manual-code question a browser callback can win the race against. + { type: 'manual_code', message: 'Paste the code', signal: withdraw.signal }, + ] + + const ui = await attempt(ctx, async (interaction) => { + for (const prompt of prompts) await interaction.prompt(prompt) + }) + + expect(ui.prompts).toEqual([ + { kind: 'text', message: 'Your workspace', placeholder: 'acme' }, + { kind: 'secret', message: 'Paste the key' }, + { kind: 'secret', message: 'Paste the token', placeholder: 'sk-…' }, + { kind: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] }, + { kind: 'text', message: 'Paste the code', signal: withdraw.signal }, + ]) + }) + + it('hands the flow the attempt-wide cancellation signal', async () => { + const ctx = await harness() + let seen: AbortSignal | undefined + const controller = new AbortController() + login.mockImplementation((_id: string, _type: AuthType, interaction: AuthInteraction) => { + seen = interaction.signal + controller.abort() + return new Promise(() => {}) + }) + + await expect(ctx.authorization.begin({ + key: CODEX, + interaction: surface(), + signal: controller.signal, + })).resolves.toEqual({ status: 'cancelled' }) + expect(seen?.aborted).toBe(true) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index 317b803f0c..2f409c6d9d 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -13,6 +13,7 @@ vi.mock('@earendil-works/pi-ai/api/openai-completions.lazy', () => ({ import { PiAiAdapter } from '../src/adapter.ts' import { resolveProfiles } from '../src/config.ts' +import { memoryAuth } from './auth-double.ts' afterEach(() => { streamSimple.mockReset() }) @@ -27,6 +28,7 @@ function gatewayAdapter(): PiAiAdapter { }, }), resolveApiKey: () => Promise.resolve('test-key'), + auth: memoryAuth(), }) } diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 6f0b821d36..172dbe8a6e 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../../credentials/authorization" + }, { "path": "../../settings/settings" }, diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index 3f11c82323..90b1bde56f 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -30,7 +30,7 @@ describe('TestRemote', () => { new TestRemote(ctx) // No subscriber for this name: the emit must be inert rather than throwing, // because the wire carries whatever the Host allowlist selected. - expect(() => { ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() + expect(() => { ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() await ctx.fiber.dispose() }) diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index c54e974be8..98e35ef83a 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/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/util/atomic-write/README.md -README.md: 4d0b55291955c9d37f4788c7d37ad8e6ce728f70 -README.zh.md: c2d7f0b49fa123befbb663ac43862a40b4ef19b4 +README.md: 0e6501c0ae35bf26d7df67f28ce0ae24c85ecca6 +README.zh.md: 377f85f5c4aab74e8c04068b616de32a8ba063d6 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 4d0b552919..0e6501c0ae 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -30,6 +30,8 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { `withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms that the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer. +How long a contender waits is a property of the operation the holder runs, so it is stated per call through `waitMs`. The default is sized for file work alone; a holder whose cycle includes a network round trip — a credential mutation that refreshes an expired token — states a longer one, because leaving the default would fail every other writer of that file for the duration. The retry cadence stays fixed: it governs how often a contender asks, which no caller has a reason to vary. + ## Model Experience None, as this is a pure filesystem primitive; nothing here reaches a model request. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index c2d7f0b49f..377f85f5c4 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -30,6 +30,8 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { `withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。 +等待多久是持锁方所跑操作的属性,因此由每次调用经 `waitMs` 声明。默认值只按纯文件工作量级选定;若持锁方的循环包含一次网络往返——例如刷新过期 token 的凭据变更——就应声明更长的值,否则该文件的其他写入方会在这段时间内全部失败。退避节奏保持固定:它决定竞争者多久问一次,调用方没有理由改变它。 + ## 模型体验 无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts index 21c9de5f35..3e5764a329 100644 --- a/packages/util/atomic-write/src/index.ts +++ b/packages/util/atomic-write/src/index.ts @@ -78,14 +78,36 @@ async function isLockContention(error: unknown, lockPath: string): Promise( filename: string, operation: () => Promise, + options?: FileLockOptions, ): Promise { const lockPath = `${filename}.lock` - const deadline = Date.now() + LOCK_TIMEOUT_MS + const deadline = Date.now() + (options?.waitMs ?? DEFAULT_LOCK_WAIT_MS) let delay = LOCK_RETRY_INITIAL_MS for (;;) { try { diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index 42cbd287c0..683abe51bc 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -28,6 +28,18 @@ async function scratch(): Promise { return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) } +/** Resolve once the lockfile exists, so contention is measured against a held lock. */ +async function waitForLock(lockPath: string): Promise { + for (;;) { + try { + await stat(lockPath) + return + } catch { + await new Promise(resolve => setTimeout(resolve, 5)) + } + } +} + describe('writeFileAtomic', () => { it('creates the file and its parents with exactly the stated mode', async () => { const dir = await scratch() @@ -105,4 +117,33 @@ describe('withFileLock', () => { })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i) expect(called).toBe(false) }) + + it('waits for the caller-stated limit rather than the protocol default', async () => { + // An operation whose work includes a network round trip legitimately holds + // the lock far longer than the render-and-rename the default was sized + // for. The limit is per call so one such operation cannot fail every other + // writer of the same file, and a caller that states a short one still + // fails fast. + const dir = await scratch() + const target = join(dir, 'document') + let release = (): void => {} + const held = new Promise((resolve) => { release = resolve }) + const holder = withFileLock(target, () => held) + // The holder owns the lock once its lockfile exists; contending before + // that would measure nothing. + await waitForLock(`${target}.lock`) + + // Elapsed time is the assertion that distinguishes a honoured limit from + // the ignored argument: without it the contender simply waits out the + // protocol default and fails with the same message. + const startedAt = Date.now() + await expect(withFileLock(target, async () => 'impatient', { waitMs: 50 })) + .rejects.toThrow(/timed out waiting for the writer lock/) + expect(Date.now() - startedAt).toBeLessThan(1_000) + + const patient = withFileLock(target, async () => 'patient', { waitMs: 10_000 }) + release() + await holder + expect(await patient).toBe('patient') + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84cd47be51..53dd8aa315 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3802,6 +3802,21 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval + packages/credentials/authorization: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../credentials + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + packages/credentials/credentials: devDependencies: '@deepseek-ai/cordis': @@ -5520,6 +5535,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-authorization': + specifier: workspace:^ + version: link:../../credentials/authorization '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials @@ -8718,6 +8736,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../packages/attachment/attachment + '@deepseek-ai/dsh-authorization': + specifier: workspace:^ + version: link:../../packages/credentials/authorization '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/shell/bash-local diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 86689a6bf6..befad374c1 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 721c9987b6..ac4535bc1b 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -417,24 +417,63 @@ describe('Python release workflows', () => { }) describe('Issue lifecycle workflow', () => { - it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { + it('runs the lifecycle job on every PR/review event but gates token and board steps', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') + const policy = loadWorkflow('.github/workflows/issue-policy.yml') + const lifecycleJob = workflowJob(lifecycle, 'lifecycle') + if (!Array.isArray(lifecycleJob.steps)) throw new TypeError('Issue lifecycle job must define steps') + + // The job has no job-level `if`, so it is listed on every pull_request / + // pull_request_review event and reports success instead of a gray skip. The + // write-capable steps are gated at step level so approved/commented reviews + // never mint a Project/Issue App token nor touch the board. + expect(lifecycle.on).toHaveProperty('pull_request') + expect(lifecycle.on).toHaveProperty('pull_request_review') + expect(lifecycleJob.if).toBeUndefined() + // Keep the subscription-type gates: issue-lifecycle does not re-subscribe + // ready_for_review (issue-policy owns that) and only reacts to submitted + // review events. const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') - const lifecycleJob = workflowJob(lifecycle, 'lifecycle') - const policy = loadWorkflow('.github/workflows/issue-policy.yml') - const policyPullRequest = workflowEvent(policy, 'pull_request') - expect(lifecyclePullRequest.types).not.toContain('ready_for_review') expect(lifecyclePullRequest.types).toContain('review_requested') expect(lifecycleReview.types).toEqual(['submitted']) - expect(lifecycleJob.if).toBe( - "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}", - ) + const gated = "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}" + const steps = lifecycleJob.steps.filter(isRecord) + const tokenStep = steps.find(s => s.name === 'Create project token') + const handleStep = steps.find(s => s.name === 'Handle repository event') + expect(tokenStep).toMatchObject({ if: gated }) + expect(handleStep).toMatchObject({ if: gated }) + + // issue-policy owns PR validation; it is read-only and a real gate. + const policyPullRequest = workflowEvent(policy, 'pull_request') expect(policyPullRequest.types).toContain('ready_for_review') }) }) +describe('npm release workflows', () => { + it('keeps publication dispatch-only and pack in the PR workflow', () => { + // pack stays in the PR/master release workflows so a PR proves the set packs. + for (const file of ['release.yml', 'release-vendor.yml']) { + const workflow = loadWorkflow(`.github/workflows/${file}`) + if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`) + expect(Object.keys(workflow.jobs).sort()).toEqual(['pack']) + } + + // publication is workflow_dispatch-only (never a PR check) and keeps the + // npm-publish environment plus the shared dist-tag group. + for (const file of ['release-publish.yml', 'release-vendor-publish.yml']) { + const workflow = loadWorkflow(`.github/workflows/${file}`) + if (!isRecord(workflow.on) || !isRecord(workflow.jobs)) throw new TypeError(`${file} must define on and jobs`) + expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) + const publish = workflow.jobs.publish + if (!isRecord(publish)) throw new TypeError(`${file} must define a publish job`) + expect(publish.environment).toBe('npm-publish') + expect(publish.concurrency).toMatchObject({ group: 'Release-publish' }) + } + }) +}) + describe('Git hooks', () => { it('leaves frozen Agent Note sidecars to the archive verifier', () => { const lefthook = loadWorkflow('lefthook.yml') diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts index cce5cff2a0..e2eb0ebe72 100644 --- a/scripts/client-build-environment.client.spec.ts +++ b/scripts/client-build-environment.client.spec.ts @@ -26,6 +26,7 @@ const dshBuildWorkflows = [ 'e2b-e2e.yml', 'e2e.yml', 'release.yml', + 'release-publish.yml', 'sandbox.yml', ] diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a088890413..483518efd1 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -64,6 +64,7 @@ export const SERVICE_PAGE: Record = { commands: 'commands.md', compaction: 'compaction.md', cordisInspect: 'extensions.md', + authorization: 'credentials.md', credentials: 'credentials.md', directoryPicker: 'workspace.md', dynamicCordisRunner: 'extensions.md', @@ -172,6 +173,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'approval': 'approval.md', 'commands': 'commands.md', 'cordis': 'extensions.md', + 'authorization': 'credentials.md', 'credentials': 'credentials.md', 'domain': 'storage.md', 'fs': 'filesystem.md', @@ -184,6 +186,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'system-prompt': 'system-prompt.md', 'session-telemetry': 'session-telemetry.md', 'tools': 'tools.md', + 'webserver': 'web-server.md', 'workflow': 'workflow.md', } @@ -463,8 +466,23 @@ export const LINK_MAP: Readonly> = { SettingsPathOp: 'settings.md', SettingsDescribeOptions: 'settings.md', SettingsUpdateSource: 'settings.md', + AuthorizationEntry: 'credentials.md', + AuthorizationFlow: 'credentials.md', + AuthorizationInteraction: 'credentials.md', + AuthorizationMethod: 'credentials.md', + AuthorizationNotice: 'credentials.md', + AuthorizationOutcome: 'credentials.md', + AuthorizationPrompt: 'credentials.md', + AuthorizationRequest: 'credentials.md', + AuthorizationSession: 'credentials.md', + AuthorizationSettlement: 'credentials.md', + AuthorizationStatus: 'credentials.md', CredentialRef: 'credentials.md', + CredentialKey: 'credentials.md', CredentialInfo: 'credentials.md', + CredentialRecord: 'credentials.md', + CredentialRecordEntry: 'credentials.md', + CredentialRecordInfo: 'credentials.md', ResolvedCredential: 'credentials.md', AskUserQuestionAnswer: 'user-questions.md', AskUserQuestionRequest: 'user-questions.md', @@ -480,6 +498,7 @@ export const LINK_MAP: Readonly> = { PresetSpec: 'permission-presets.md', InvariantInstaller: 'invariants.md', WebRoute: 'web-server.md', + IndexInjection: 'web-server.md', StorageBackend: 'storage.md', StorageForms: 'storage.md', Domain: 'storage.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 420bad476c..5e19c20113 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -189,6 +189,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['llm-deepseek', 'llm-pi-ai', 'apiproxy'], note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage.', }, + { + key: 'authorization', + pkg: 'authorization', + title: 'Authorization flow registry', + mode: 'seam', + implementations: [], + consumers: ['llm-pi-ai'], + note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.', + }, { key: 'sessionTelemetry', pkg: 'session-telemetry', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 31399faf35..02d0e66c0e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -133,6 +133,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/settings/settings-file': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model-facing behavior.' }, 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model-facing use a value authorizes.' }, 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model-facing behavior.' }, + 'packages/credentials/authorization': { kind: 'none', reason: 'A configuration-time conversation with a human; no flow, notice, or prompt reaches a model request.' }, 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers nothing model-facing.' }, 'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers nothing model-facing.' }, 'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 91e623e2d1..8d8d5e1433 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -83,6 +83,7 @@ "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], + "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], "@deepseek-ai/dsh-api-remotes/types": ["./packages/api/remotes/src/types.ts"], "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 4becb707ab..ba65e0a522 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -51,6 +51,7 @@ "apps/web/tests/message-feedback-layout.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/reference-composer.e2e.ts", + "apps/web/tests/markdown-wide-table.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", @@ -153,6 +154,7 @@ { "path": "./packages/settings/settings-file" }, { "path": "./packages/credentials/credentials" }, { "path": "./packages/credentials/credentials-local" }, + { "path": "./packages/credentials/authorization" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" },