diff --git a/.github/compat/2026-11-01-dsh-0.2.2-memory-after.png b/.github/compat/2026-11-01-dsh-0.2.2-memory-after.png new file mode 100644 index 0000000..8947238 Binary files /dev/null and b/.github/compat/2026-11-01-dsh-0.2.2-memory-after.png differ diff --git a/.github/compat/2026-11-01-dsh-0.2.2-memory-without-webserver.png b/.github/compat/2026-11-01-dsh-0.2.2-memory-without-webserver.png new file mode 100644 index 0000000..b934aee Binary files /dev/null and b/.github/compat/2026-11-01-dsh-0.2.2-memory-without-webserver.png differ diff --git a/README.en.md b/README.en.md index 979a758..23990c3 100644 --- a/README.en.md +++ b/README.en.md @@ -108,7 +108,7 @@ dsh plugin --profile web add @hr98w/dsh-memory Restart `dsh web` completely after installation. -The plugin adapts to the newer DSH Web RPC and Session persistence APIs. Restart Web after updating the plugin; refreshing the page alone is not enough. +The plugin targets the DSH 0.2.x Web Connection, RPC, and Session persistence APIs. After updating from 0.1.4 or earlier, fully restart Web; refreshing the page alone is not enough. ### Install from source @@ -150,7 +150,7 @@ $DSH_HOME/memory/ └── .md # authoritative detailed memory ``` -- Web management APIs are available only to the local machine; +- Web management is enabled only when the page is served from the local machine; the `/memory` channel uses the DSH Connection Host/Origin and browser-auth fence (loopback by default); - Web memory-write requests never submit a memory root, absolute path, cwd, or internal Workspace key; the header shows the Host-reported local memory root for diagnostics only; - Debug is off by default and does not record conversation evidence, memory/model proposal bodies, or API keys; - `MEMORY.md` is a rebuildable index; detailed Markdown files are authoritative Workspace data. diff --git a/README.md b/README.md index d859696..a87067f 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ dsh plugin --profile web add @hr98w/dsh-memory 安装完成后请完全重启 `dsh web`。 -插件已适配新版 DSH 的 Web RPC 与 Session persistence 接口;更新插件后也需重启 Web,不能只刷新页面。 +插件适配 DSH 0.2.x 的 Web Connection、RPC 与 Session persistence 接口;从 0.1.4 或更早版本更新后必须完全重启 Web,不能只刷新页面。 ### 从源码安装 @@ -150,7 +150,7 @@ $DSH_HOME/memory/ └── .md # 权威详细记忆 ``` -- Web 管理接口只允许本机访问; +- Web 管理页面只在本机访问时启用;`/memory` 通道沿用 DSH Connection 的 Host/Origin 与 browser-auth 准入(默认只接受 loopback); - Web 的记忆写入请求不会提交记忆根目录、绝对路径、cwd 或内部 Workspace key;页面顶部仅为本机诊断显示 Host 返回的记忆根目录; - Debug 默认关闭,不记录对话证据、记忆正文、模型提议正文或 API key; - `MEMORY.md` 是可重建索引,详细 Markdown 文件才是 Workspace 权威数据。 diff --git a/cordis.patch.yml b/cordis.patch.yml index 999ede6..0ec56d2 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,9 +1,10 @@ # dsh-memory contributes one Host plugin; its browser half is discovered through dsh.client. -# Standard DSH Web Connection needs webServer for generic RPC registrations. -# Preserve webRuntime used by its config; module-level dependencies still merge. +# The standard DSH Web Connection row reads `webStartup` for its config and still accesses +# `webServer` from its own provider context to register generic RPC channels; keep both +# provider dependencies. Module-level dependencies such as `credentials` still merge. - id: connection name: '@deepseek-ai/dsh-client-connection' - inject: [webRuntime, webServer] + inject: [webStartup, webServer] - insert: - id: dsh-memory diff --git a/docs/architecture.md b/docs/architecture.md index 1308dd1..d28c985 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ ```text Agent step Web Browser │ │ - │ Prompt context / memory_update │ loopback RPC /memory + │ Prompt context / memory_update │ Connection RPC /memory ▼ ▼ ┌──────────────────────────────────────────────────┐ │ Host Cordis plugin: @hr98w/dsh-memory │ @@ -29,7 +29,7 @@ Agent step Web Browser | 文件 | 职责 | |---|---| -| `src/index.ts` | Host Cordis entry;组合 Service、Prompt、Tool 和 loopback RPC Consumer | +| `src/index.ts` | Host Cordis entry;组合 Service、Prompt、Tool 和 Connection RPC Consumer | | `src/memory-store.ts` | Markdown Provider;格式校验、revision、CAS、scope lock、原子发布和索引重建 | | `src/memory-observation.ts` | 将每次动态 context 的 Host-only revision 绑定到 live Agent,并为模型写入提供 CAS 前置条件 | | `src/protocol.ts` | Host/Browser 共用的 wire 类型和双侧运行时解析 | @@ -111,12 +111,12 @@ memory_update(change without revision) ## Browser 请求流程 -Bundle 对标准 `connection` row(同时匹配 id 与包名)设置 `inject: [webRuntime, webServer]`,保留其 Web 配置依赖,并修复 DSH Electron 合并后通用 RPC 注册缺少 `webServer` 依赖的问题。模块声明的 `credentials` 仍由 Loader 合并;不改认证或请求处理逻辑。Headless 没有该 row 时补丁警告并跳过,不创建 Connection。自定义 Connection row 的额外依赖需由后加载的用户 patch 保留,因为 Bundle patch 的 `inject` 字段是替换而非追加。背景与移除条件见 [兼容性决策](decisions/implemented/bug-fix/2026-09-10-connection-webserver-injection.md)。 +Bundle 对标准 `connection` row(同时匹配 id 与包名)设置 `inject: [webStartup, webServer]`:当前 DSH 的该 row 从 `webStartup` 求值 `trustedHosts` 配置,而通用 RPC 注册仍从 provider context 读取 `webServer`;两者缺一都会让 Connection 或 memory RPC 子 fiber 失败。模块声明的 `credentials` 仍由 Loader 合并;不改认证或请求处理逻辑。当前 DSH 的 `connection.rpc.handle()` 没有 per-channel authority 参数,memory 通道沿用 Connection 的标准 Host/Origin 与 browser-auth 准入,Browser 仍在 `isLoopback` 为 false 时禁用 Memory 操作。Headless 没有该 row 时补丁警告并跳过,不创建 Connection。自定义 Connection row 的额外依赖需由后加载的用户 patch 保留,因为 Bundle patch 的 `inject` 字段是替换而非追加。背景与移除条件见 [兼容性决策](decisions/implemented/bug-fix/2026-09-10-connection-webserver-injection.md)。 ```text Memory Settings -> ctx.connection.rpc.call('/memory', endpoint, payload) - -> Connection enforces loopback authority + -> Connection applies the Host/Origin and browser-auth fence -> Host dsh-memory handler validates payload -> MemoryStore -> Host validates/constructs response diff --git a/docs/decisions/implemented/architecture/2026-08-28-host-client-foundation.md b/docs/decisions/implemented/architecture/2026-08-28-host-client-foundation.md index 1306c9d..6146fbf 100644 --- a/docs/decisions/implemented/architecture/2026-08-28-host-client-foundation.md +++ b/docs/decisions/implemented/architecture/2026-08-28-host-client-foundation.md @@ -6,14 +6,14 @@ The first implementation needed to prove that a standalone DSH Bundle can own du ## Decision -Build the first vertical slice around a Host `MemoryStore` Service. The Host entry mounts dynamic prompt context and `memory_update` through optional Cordis inject fibers, and mounts a dedicated loopback Connection RPC channel only when Web provides `connection`. The Browser entry registers an independent Settings section and parses every Host response again. +Build the first vertical slice around a Host `MemoryStore` Service. The Host entry mounts dynamic prompt context and `memory_update` through optional Cordis inject fibers, and mounts a dedicated Connection RPC channel only when Web provides `connection`. The Browser entry registers an independent Settings section, gates Memory operations on `connection.isLoopback`, and parses every Host response again. The first UI milestone implements Status and Global editing. Workspaces and Sessions retain visible navigation seats but do not claim functional data flows until their Host endpoints and lifecycle tests exist. ## Alternatives considered - Direct file access from the Browser was rejected because Browser and Host are separate runtimes and the Browser must not receive filesystem authority. -- A Typert `/api` Remote was rejected because the generic Gateway uses trusted-host authority, while memory administration requires loopback authority. +- A Typert `/api` Remote was rejected because it would expose memory administration through the shared Gateway surface instead of a dedicated channel the Browser gates on loopback. - Building Session consolidation before the online read/write path was rejected because it would introduce a second writer before revision, validation, and index publication were proven. ## Consequences diff --git a/docs/decisions/implemented/bug-fix/2026-09-10-connection-webserver-injection.md b/docs/decisions/implemented/bug-fix/2026-09-10-connection-webserver-injection.md index d672a41..58761c7 100644 --- a/docs/decisions/implemented/bug-fix/2026-09-10-connection-webserver-injection.md +++ b/docs/decisions/implemented/bug-fix/2026-09-10-connection-webserver-injection.md @@ -1,25 +1,30 @@ -# Restore the standard Web Connection registration dependency +# Restore the standard Web Connection provider dependencies ## Problem -DSH commit `2ef85b1e17591fb9fadc78549726413c56f38dfa`, merged into master by `ba05b7d49de63106afdd7efcf741cca1985fae18`, removed `webServer` from Connection's provider dependencies. Generic `connection.rpc.handle()` still accesses that service through the provider context. The memory RPC child fails with `cannot get property "webServer" without inject`, while the parent plugin remains active. Unmatched POST requests then reach the static fallback and return HTTP 405. +The current DSH Web bundle composes the standard `connection` row with `inject: [webStartup]`: that row evaluates its `trustedHosts` config from `webStartup`. The Connection module itself declares only `credentials`, while generic `connection.rpc.handle()` still reads `webServer` through the provider context to register an HTTP route. -Root-provided service mocks hide this failure. The reproduction requires sibling provider plugins and explicitly awaiting failed child fibers, not just the parent entry. +The v0.1.4 Bundle override set the row's entry-level `inject` to `[webRuntime, webServer]`. Current DSH no longer provides `webRuntime`, so Connection waits for a missing service and the Web startup aborts with `connection (required) missing webRuntime`. The same override also dropped `webStartup`, so the row config could not be evaluated. Removing the override entirely keeps Web booting but leaves the memory RPC child failing with `cannot get property "webServer" without inject`; unmatched POST requests then reach the static fallback and return HTTP 405. + +Root-provided service mocks hide the registration failure. The reproduction requires sibling provider plugins and explicitly awaiting failed child fibers, not just the parent entry. ## Decision -Add a Bundle override guarded by both the standard `connection` id and `@deepseek-ai/dsh-client-connection` package name. Set its entry-level `inject` to `[webRuntime, webServer]`: retain the standard Web config's dependency and restore HTTP registration access. Loader still merges module-level dependencies such as `credentials`. Do not modify DSH source, user configuration, authentication, RPC envelopes, or memory semantics. +Keep a Bundle override guarded by both the standard `connection` id and `@deepseek-ai/dsh-client-connection` package name, with entry-level `inject: [webStartup, webServer]`: retain the config service the current row needs and restore HTTP registration access. Loader still merges module-level dependencies such as `credentials`. Do not modify DSH source, user configuration, authentication, RPC envelopes, or memory semantics. + +Adapt the affected Host APIs to their current shapes: `connection.rpc.handle(channel, handler)` has no per-channel authority argument, so the memory channel inherits Connection's standard Host/Origin and browser-auth fence; the Browser surface still gates Memory operations on `connection.isLoopback`. `installModelSelection(agentCtx)` takes only the Agent context and reads the step selection from DSH. -Keep a Cordis regression test and a separate real-DSH source-checkout check using the shipped YAML, official patch algorithm, actual HostConnectionService, and built memory entry. Cover missing/mismatched targets, credentials/config preservation, failing unpatched registration, successful patched registration, disposal, and Headless loading. +Keep a Cordis regression test and a separate real-DSH source-checkout check using the shipped YAML, official patch algorithm, actual `HostConnectionService`, and built memory entry. Cover missing/mismatched targets, credentials/config preservation, failing unpatched registration, successful patched registration, disposal, and Headless loading. ## Alternatives considered - Adding `webServer` only to the memory consumer does not repair the provider-context access. +- Removing the Bundle override leaves generic registration broken under the current DSH baseline. - Direct HTTP registration would duplicate transport and security responsibilities. - An upstream fix is preferable long-term, but this repair must stay within this repository. ## Consequences -Restart Web to recompose the Bundle. A Headless composition without Connection emits a skipped-patch warning but gains no Web dependency. This workaround targets the standard Web row; a renamed row is not patched. Because entry `inject` is replaced, deployments with custom extra dependencies must retain them in a later user patch. Connection without a Web server is outside this compatibility override's supported use. +Restart Web to recompose the Bundle. A Headless composition without Connection emits a skipped-patch warning but gains no Web dependency. This workaround targets the standard Web row; a renamed row is not patched. Because entry `inject` is replaced, deployments with custom extra dependencies must retain them in a later user patch. The real-DSH check pins the patched `inject` to the current web-bundle dependencies plus `webServer`, so a future bundle dependency change fails the check instead of being silently dropped. -Remove the override once the supported DSH baseline fixes generic registration ownership and passes the same sibling-provider regression. Keep using the host's existing authentication; this workaround does not restore or change older host interpretations of the `authority` option. +Remove the override once the supported DSH baseline fixes generic registration ownership and passes the same sibling-provider regression. Keep using the host's existing authentication and admission policy; the plugin does not add a separate loopback check or change older host interpretations of an authority option. diff --git a/docs/decisions/implemented/feature/2026-08-31-consolidation-model-selection.md b/docs/decisions/implemented/feature/2026-08-31-consolidation-model-selection.md index 3465508..6e4e8ab 100644 --- a/docs/decisions/implemented/feature/2026-08-31-consolidation-model-selection.md +++ b/docs/decisions/implemented/feature/2026-08-31-consolidation-model-selection.md @@ -10,7 +10,7 @@ Add a fourth Memory Settings tab named Model. The Host projects active providers Persist only the selected provider and model in `$DSH_HOME/memory/settings.yml`. The settings file has a closed schema, revision-derived CAS, and atomic replacement. A successful selection updates the live consolidation worker for subsequent attempts; an attempt snapshots its route before starting. -Expose the catalog and mutation through loopback-only, runtime-validated `models/read` and `models/select` RPC endpoints. The Host rechecks that a selected route is active before committing it. +Expose the catalog and mutation through runtime-validated `models/read` and `models/select` Connection RPC endpoints; the Settings surface disables them when the page is not loopback. The Host rechecks that a selected route is active before committing it. ## Alternatives considered diff --git a/docs/decisions/implemented/feature/2026-08-31-optional-consolidation-debug-logging.md b/docs/decisions/implemented/feature/2026-08-31-optional-consolidation-debug-logging.md index 9f74ff9..02a2694 100644 --- a/docs/decisions/implemented/feature/2026-08-31-optional-consolidation-debug-logging.md +++ b/docs/decisions/implemented/feature/2026-08-31-optional-consolidation-debug-logging.md @@ -8,7 +8,7 @@ Detailed JSONL diagnostics were written for every consolidation attempt. They ar Rename the Browser tab to Settings and persist a `debug` boolean beside the consolidation model route in plugin-owned `settings.yml`. Debug logging is disabled by default and applies to new attempts. When disabled, the debug Consumer performs no directory or file write; receipts and replayable worker Sessions remain unchanged. -Keep format-1 settings readable and interpret their missing debug field as disabled. New commits write format 2 and remain revision checked. The existing loopback settings mutation validates the boolean at runtime and can change Debug without requiring the DSH model catalog when the model route itself is unchanged. +Keep format-1 settings readable and interpret their missing debug field as disabled. New commits write format 2 and remain revision checked. The existing Connection settings mutation validates the boolean at runtime and can change Debug without requiring the DSH model catalog when the model route itself is unchanged. Only show a debug path for an attempt whose response says logging was enabled. Do not keep debug paths on every historical Session card. diff --git a/docs/decisions/implemented/protocol/2026-08-31-manual-session-consolidation-rpc.md b/docs/decisions/implemented/protocol/2026-08-31-manual-session-consolidation-rpc.md index e753b3a..974147d 100644 --- a/docs/decisions/implemented/protocol/2026-08-31-manual-session-consolidation-rpc.md +++ b/docs/decisions/implemented/protocol/2026-08-31-manual-session-consolidation-rpc.md @@ -6,7 +6,7 @@ The Browser needs to start one consolidation attempt and show its durable result ## Decision -Expose `sessions/consolidate` on the existing loopback-only `/memory` channel. The Browser submits exactly one non-empty Session id, and the Host resolves all source and target state through `SessionPersistence`, the live Agent registry, and `MemoryStore`. +Expose `sessions/consolidate` on the existing `/memory` Connection channel. The Browser submits exactly one non-empty Session id, and the Host resolves all source and target state through `SessionPersistence`, the live Agent registry, and `MemoryStore`. Assemble the runner only in an optional Cordis child fiber that requires `sessionPersistence`, `agents`, and `sessions`. The first version waits for one attempt and propagates the Connection abort signal instead of introducing a background job system. Its closed response contains only terminal state, recovery flag, review id, attempt number, and change count. Both Host and Browser parse request and response values at runtime. @@ -21,7 +21,7 @@ The Session list exposes only a receipt status summary. It marks whether that re ## Consequences -- Manual consolidation remains loopback-only and Host-authoritative. +- Manual consolidation remains Host-authoritative; the Settings surface enables it only on a loopback page, while the channel follows Connection admission. - Missing Session services disable only consolidation while other Memory Consumers continue to load. - A completed action can refresh the list and distinguish current-review results from receipts for an older Session revision. - If real Web testing shows Connection timeouts are unsuitable, a later decision may replace the synchronous response with a Host-owned job protocol. diff --git a/docs/design.md b/docs/design.md index e401bae..5b64743 100644 --- a/docs/design.md +++ b/docs/design.md @@ -176,7 +176,7 @@ Memory 是 DSH Settings 中独立 section,包含: 页面顶部另有只读状态摘要,展示本机 memory root、字节数和 Workspace 数量;错误与最近操作结果在当前页面就地显示。 -Browser 与 Host 是两个 Cordis tree。Browser 只能通过 `/memory` loopback Connection RPC 使用受校验的业务值,不能直接访问 Host `ctx`、文件系统、`MemoryStore` 或 `SessionPersistence`。 +Browser 与 Host 是两个 Cordis tree。Browser 只能通过 `/memory` Connection RPC 使用受校验的业务值(页面在非 loopback 访问时禁用 Memory 操作),不能直接访问 Host `ctx`、文件系统、`MemoryStore` 或 `SessionPersistence`。 目标 endpoint: diff --git a/docs/development.md b/docs/development.md index a854794..6dc06e5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -54,7 +54,7 @@ pnpm run build node scripts/check-memory-rpc.mjs /absolute/path/to/deepseek-harness ``` -脚本使用真实补丁算法和 Connection,检查同级服务提供方的 RPC 注册、子 fiber 失败、路由卸载,以及缺少 Connection 的 Headless 组合。它只创建临时记忆目录并在退出时清理,不启动 HTTP 服务或验证浏览器认证。真实页面仍需按下面步骤人工验证。标准 Headless 没有 `connection` row 时会提示跳过该补丁,不影响启动。 +脚本使用真实补丁算法和 Connection,检查当前 web-app `connection` row 的 `webStartup` 配置依赖、`webServer` 注册、未打补丁时的子 fiber 失败、路由卸载,以及缺少 Connection 的 Headless 组合。它只创建临时记忆目录并在退出时清理,不启动 HTTP 服务或验证浏览器认证。真实页面仍需按下面步骤人工验证。标准 Headless 没有 `connection` row 时会提示跳过该补丁,不影响启动。 ```sh pnpm run build @@ -65,11 +65,11 @@ dsh web --no-open 确认: -1. dump config 包含 `id: dsh-memory`; -2. Web boot 没有 `plugin tree failed to load`; +1. dump config 包含 `id: dsh-memory`,且 `connection` row 的 `inject` 同时包含 `webStartup` 与 `webServer`; +2. Web boot 打印访问 URL,且没有 `startup failed`、`plugin tree failed to load` 或 missing-service 列表; 3. `__DSH_BOOT__` 包含 `@hr98w/dsh-memory` Client; 4. 启动页插件清单中的 `/plugins/@hr98w/dsh-memory/client.js?rev=...` 返回 Browser bundle,而不是 SPA fallback; -5. Settings 导航出现 Memory; +5. Settings 导航出现 Memory,页面显示“已启用”和根目录,而不是 `transport failure ... HTTP 405`; 6. Global 保存后 revision 变化,旧页面保存得到 conflict; 7. Headless 不包含 `connection` 时仍能加载 Prompt 和 Tool。 diff --git a/docs/roadmap.md b/docs/roadmap.md index d88c02d..75d6c5a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,7 +11,7 @@ - revision/CAS、scope lock 和原子提交; - Global 与当前 Workspace index 的动态 Prompt context; - Host-owned revision observation 与原子 batch `memory_update`; -- loopback RPC 的 `status`、`global/read`、`global/replace`; +- Connection RPC 的 `status`、`global/read`、`global/replace`; - Settings 中的 Status 与 Global 编辑; - Host Workspace catalog、`workspaces/list`、`workspace/read` 与只读 Workspaces UI; - `workspace/commit`、Browser 批量编辑和保留草稿的 CAS 冲突体验; diff --git a/package.json b/package.json index 7e90248..413f833 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hr98w/dsh-memory", - "version": "0.1.4", + "version": "0.1.5", "description": "Claude Code- and Codex-inspired long-term memory for DeepSeek Harness", "repository": { "type": "git", @@ -52,7 +52,6 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-slots" ], diff --git a/scripts/check-memory-rpc.mjs b/scripts/check-memory-rpc.mjs index a139495..2822b2e 100644 --- a/scripts/check-memory-rpc.mjs +++ b/scripts/check-memory-rpc.mjs @@ -31,10 +31,13 @@ const warn = (...args) => warnings.push(args) const webEntries = applyEntryPatches([], yaml.load(readFileSync(resolve('packages/bundle/web-app/cordis.patch.yml'), 'utf8'), { schema: entryListSchema }), () => {}) const original = webEntries.find(entry => entry.id === 'connection') assert(original, 'Standard Web Connection row must exist') +assert(original.inject.includes('webStartup'), 'Standard Web Connection row must keep its webStartup config dependency') const patched = applyEntryPatches([original], patches, warn) assert.equal(warnings.length, 0) assert.deepEqual(patched[0].config, original.config, 'Preserve authentication and runtime config') -assert.deepEqual(patched[0].inject, ['webRuntime', 'webServer']) +// The Bundle row replaces entry-level inject, so this also pins every dependency the DSH baseline declares. +assert.deepEqual(patched[0].inject, [...original.inject, 'webServer']) +assert.equal(patched[0].inject.includes('webRuntime'), false) warnings = [] const headless = applyEntryPatches([], patches, warn) @@ -51,21 +54,24 @@ try { for (const [label, entry] of [['unpatched', original], ['patched', patched[0]], ['headless', undefined]]) { const ctx = new Context() const routes = new Map() + let inject try { if (entry) { await ctx.plugin({ apply(provider) { provider.provide('credentials', {}) - provider.provide('webRuntime', {}) + provider.provide('webStartup', { trustedHosts: [] }) provider.provide('webServer', { register(route) { routes.set(route.path, route) return () => routes.delete(route.path) } }) } }) - const inject = Inject.resolve(connectionInject) + inject = Inject.resolve(connectionInject) Inject.resolve(entry.inject, inject) assert('credentials' in inject) await ctx.plugin({ inject, apply(provider) { - // Authentication is not invoked: this check exercises registration ownership, not HTTP. + // The row config is evaluated from webStartup; authentication is not invoked because this + // check exercises registration ownership, not HTTP. + assert.deepEqual(provider.webStartup.trustedHosts, []) new HostConnectionService(provider, [], {}) } }) } @@ -76,9 +82,7 @@ try { for (const runtime of ctx.registry.values()) for (const child of runtime.fibers) { if (child.state === 3) try { await child.await() } catch (error) { failures.push(error.message) } } - const expectsRoute = entry !== undefined && ( - 'webServer' in Inject.resolve(connectionInject) || 'webServer' in Inject.resolve(entry.inject) - ) + const expectsRoute = entry !== undefined && 'webServer' in inject if (entry && !expectsRoute) { assert(failures.some(message => message.includes('webServer'))) assert.equal(routes.has('/memory'), false) diff --git a/src/consolidation-worker.ts b/src/consolidation-worker.ts index 6a92ab8..3567b92 100644 --- a/src/consolidation-worker.ts +++ b/src/consolidation-worker.ts @@ -1,7 +1,7 @@ /** Isolated, replayable worker Agent for producing a Session consolidation proposal. */ import type { Context } from '@deepseek-ai/cordis' -import { installModelSelection, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import { installModelSelection } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionId as DshSessionId } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -281,11 +281,7 @@ export class SessionConsolidationWorker { } } })) - const selection: ModelSelectionRef = { - current: { provider: config.provider, model: config.model }, - assembled: undefined, - } - installModelSelection(agentCtx as unknown as Context, selection) + installModelSelection(agentCtx as unknown as Context) agentCtx.on('agent/error', ({ error }) => { agentFailure ??= error this.debug?.failure(debugIdentity, 'agent-error', error) diff --git a/src/dsh-runtime.d.ts b/src/dsh-runtime.d.ts index 1629662..abab933 100644 --- a/src/dsh-runtime.d.ts +++ b/src/dsh-runtime.d.ts @@ -1,10 +1,5 @@ declare module '@deepseek-ai/dsh-agent' { - export interface ModelSelectionRef { - current: { provider: string; model: string } | undefined - assembled: { provider: string; model: string } | undefined - } - - export function installModelSelection(context: unknown, selection: ModelSelectionRef): () => void + export function installModelSelection(context: unknown): () => void } declare module '@deepseek-ai/dsh-llm' { diff --git a/src/host-contracts.ts b/src/host-contracts.ts index f430cd7..3a24f72 100644 --- a/src/host-contracts.ts +++ b/src/host-contracts.ts @@ -37,7 +37,6 @@ export interface MemoryHostConnection { handle( channel: string, handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise>, - options: { authority: 'loopback' }, ): () => Promise } } diff --git a/src/index.ts b/src/index.ts index e5d8378..c8c1943 100644 --- a/src/index.ts +++ b/src/index.ts @@ -262,8 +262,7 @@ export function apply(ctx: Context, config: Config = {}): void { payload, signal, ), - { authority: 'loopback' }, - ), 'dsh-memory: loopback RPC') + ), 'dsh-memory: Memory RPC') }) } diff --git a/tests/connection-compat.spec.ts b/tests/connection-compat.spec.ts index a09a4e3..fbe8443 100644 --- a/tests/connection-compat.spec.ts +++ b/tests/connection-compat.spec.ts @@ -3,61 +3,112 @@ import { Context, Service } from '@deepseek-ai/cordis' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' -const patch: Array<{ id?: string; name?: string; inject?: string[] }> = parse( +interface PatchEntry { + id?: string + name?: string + inject?: string[] + config?: unknown +} + +const patch: PatchEntry[] = parse( readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8'), ) const compatibility = patch.find(entry => entry.id === 'connection')! -/** Mirrors Connection's caller-owned effect and provider-context service access. +/** Mirrors current DSH Connection's caller-owned effect and provider-context service access. * Real DSH composition is additionally checked by scripts/check-memory-rpc.mjs. */ class ConnectionFixture extends Service { constructor(ctx: Context) { super(ctx, 'connection') } get rpc() { - const owner = this.ctx as Context & { webServer: { register(channel: string): () => void } } + const owner = this.ctx as Context & { webServer: { register(route: { path: string }): () => void } } return { - handle: (channel: string) => owner.effect(() => owner.webServer.register(channel)), + handle: (channel: string) => owner.effect(() => owner.webServer.register({ path: channel })), } } } +/** Sibling plugins provide every service the patched Connection row declares. */ +async function providerServices(ctx: Context, routes: Set): Promise { + await ctx.plugin({ apply(provider: Context) { + provider.provide('credentials', {}) + provider.provide('webStartup', { trustedHosts: [] }) + provider.provide('webServer', { register(route: { path: string }) { + routes.add(route.path) + return () => { routes.delete(route.path) } + } }) + } }) +} + describe('Web Connection compatibility', () => { - it('targets only the standard Connection entry and preserves its Web runtime dependency', () => { + it('targets only the standard Connection entry and keeps both provider dependencies', () => { expect(compatibility).toEqual({ - id: 'connection', name: '@deepseek-ai/dsh-client-connection', inject: ['webRuntime', 'webServer'], + id: 'connection', name: '@deepseek-ai/dsh-client-connection', inject: ['webStartup', 'webServer'], }) + // The row's own config (trustedHosts and authentication) must stay bundle-owned. + expect(compatibility.config).toBeUndefined() + expect(compatibility.inject).not.toContain('webRuntime') }) - it.each([false, true])('registers and disposes RPC with compatibility=%s', async (patched) => { + it('reads webStartup for the provider config and webServer for registration', async () => { const ctx = new Context() const routes = new Set() try { - // Providers must be sibling plugins, not root.provide() mocks which bypass strict injection. - await ctx.plugin({ apply(provider: Context) { - provider.provide('credentials', {}) - provider.provide('webRuntime', {}) - provider.provide('webServer', { register(channel: string) { - routes.add(channel) - return () => { routes.delete(channel) } - } }) + await providerServices(ctx, routes) + await ctx.plugin({ + inject: ['credentials', ...compatibility.inject!], + apply(provider: Context) { + // The bundle row evaluates its config from this service before providing Connection. + expect((provider as Context & { webStartup: { trustedHosts: string[] } }).webStartup.trustedHosts).toEqual([]) + new ConnectionFixture(provider) + }, + }) + const consumer = ctx.plugin({ inject: ['connection'], apply(client: Context) { + client.effect(() => client.connection.rpc.handle('/memory', async () => ({ ok: true, value: null }))) } }) + await consumer.await() + expect([...routes]).toEqual(['/memory']) + await consumer.dispose() + expect(routes.size).toBe(0) + } finally { + await ctx.fiber.dispose() + } + }) + + it('fails the provider config without webStartup', async () => { + const ctx = new Context() + const routes = new Set() + try { + await providerServices(ctx, routes) + const provider = ctx.plugin({ + inject: ['credentials', 'webServer'], + apply(provider: Context) { + (provider as Context & { webStartup: { trustedHosts: string[] } }).webStartup.trustedHosts + new ConnectionFixture(provider) + }, + }) + await expect(provider.await()).rejects.toThrow('cannot get property "webStartup" without inject') + } finally { + await ctx.fiber.dispose() + } + }) + + it('fails generic RPC registration without webServer in the provider context', async () => { + const ctx = new Context() + const routes = new Set() + try { + await providerServices(ctx, routes) + // This is the unpatched row: module dependencies plus the bundle's webStartup only. await ctx.plugin({ - inject: ['credentials', ...(patched ? compatibility.inject! : ['webRuntime'])], + inject: ['credentials', 'webStartup'], apply(provider: Context) { new ConnectionFixture(provider) }, }) const consumer = ctx.plugin({ inject: ['connection'], apply(client: Context) { - client.effect(() => client.connection.rpc.handle('/memory', async () => ({ ok: true, value: null }), { authority: 'loopback' })) + client.effect(() => client.connection.rpc.handle('/memory', async () => ({ ok: true, value: null }))) } }) - if (patched) { - await consumer.await() - expect([...routes]).toEqual(['/memory']) - await consumer.dispose() - expect(routes.size).toBe(0) - } else { - await expect(consumer.await()).rejects.toThrow('cannot get property "webServer" without inject') - expect(routes.size).toBe(0) - } + await expect(consumer.await()).rejects.toThrow('cannot get property "webServer" without inject') + expect(routes.size).toBe(0) } finally { await ctx.fiber.dispose() } diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index f2c3792..950f4d3 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -51,9 +51,9 @@ describe('Host Cordis composition', () => { }) ctx.provide('connection', { rpc: { - handle(channel: string, handler: typeof rpcHandler, options: unknown) { + handle(channel: string, handler: typeof rpcHandler) { rpcHandler = handler - channels.push({ channel, handler, options }) + channels.push({ channel, handler }) return async () => undefined }, }, @@ -127,7 +127,7 @@ describe('Host Cordis composition', () => { }]) expect(contexts).toHaveLength(1) expect(tools).toMatchObject([{ name: 'memory_update' }]) - expect(channels).toMatchObject([{ channel: '/memory', options: { authority: 'loopback' } }]) + expect(channels).toMatchObject([{ channel: '/memory' }]) const memory = ctx.get('memoryStore') as MarkdownMemoryStore const scope = memory.scopeForCwd('/work/plugin-rpc') diff --git a/tests/stubs/dsh-agent.ts b/tests/stubs/dsh-agent.ts index 172941c..8a38515 100644 --- a/tests/stubs/dsh-agent.ts +++ b/tests/stubs/dsh-agent.ts @@ -1,8 +1,3 @@ -export interface ModelSelectionRef { - current: { provider: string; model: string } | undefined - assembled: { provider: string; model: string } | undefined -} - -export function installModelSelection(_context: unknown, _selection: ModelSelectionRef): () => void { +export function installModelSelection(_context: unknown): () => void { return () => undefined }