diff --git a/AGENTS.md b/AGENTS.md index a9b3836..d45deb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context - **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。 - **代码改动必须走 PR**:功能 / 修复 / 测试等非文档改动一律在分支上开发(`feat/*` / `fix/*`),用 `gh pr create` 发起 PR,review 合并后才进 main;**仅纯文档类改动**(README / AGENTS.md / docs/ 等)允许直接推送到 main。 - **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles//`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。 +- **DSH 市场受管安装兼容约束**:发布清单的 `dependencies` / `peerDependencies` / `optionalDependencies` 三字段**一律不得出现 `cordis`**(市场预览按名硬拒,optional 无效),且 `scripts` 不得含 `preinstall` / `install` / `postinstall` / `prepare`。回归由 `tests/market-manifest.spec.ts` 守护——违反即市场目录拿不到 `repository_backlink` 验证目标。 - 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。 ### CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`) @@ -39,7 +40,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context - **服务名**:`betterSidebar`(即 `ctx.betterSidebar`) - **发布侧**:better-sidebar 的 client half(`src/client/index.tsx`,通过 `ctx.provide('betterSidebar', service)` 发布) - **消费侧**:你的插件的 client half(`inject = ['betterSidebar', ...]`,然后 `ctx.betterSidebar.registerTab(...)`) -- **类型合并**:`declare module 'cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并 +- **类型合并**:`declare module '@deepseek-ai/cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并 > ⚠️ **host 半不发布此服务**:`ctx.betterSidebar` 只在 client 侧存在。如果你的插件 host 半需要读 better-sidebar 状态,走 better-sidebar 自己的 HTTP/WS 路由(`/sidebar/api/*`),不走服务。 @@ -53,7 +54,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context { "name": "my-plugin", "peerDependencies": { - "cordis": "^4.0.0-rc.8", + "@deepseek-ai/cordis": "^4.0.1", "dsh-better-sidebar": "workspace:*" }, "peerDependenciesMeta": { @@ -115,7 +116,7 @@ import type { } from 'dsh-better-sidebar/client/service' ``` -> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中,service 声明会拉入它)——纯浏览器侧插件建议走 `client/service` 路径,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码,宿主消费者本就处于 Node 环境)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),无 `@types/node`、`skipLibCheck: false` 也能编译。 +> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module '@deepseek-ai/cordis'` 在 context-types.d.ts 中,service 声明会拉入它)——纯浏览器侧插件建议走 `client/service` 路径,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码,宿主消费者本就处于 Node 环境)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),无 `@types/node`、`skipLibCheck: false` 也能编译。 --- @@ -588,7 +589,7 @@ interface OpenTabSeed { | 陷阱 | 说明 | |---|---| | **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`;类型 `import type {}` 会被擦除,不触发门禁 | -| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`,你 `import type {}` 即拿到类型 | +| **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部落在 DSH 运行时正主 `@deepseek-ai/cordis`(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`——DSH host/client 包对同一成员声明不同类型(如 `sessions`),合并会 TS2717 冲突)。消费者 `import type {} from 'dsh-better-sidebar'` 即拿到 `ctx.betterSidebar` augmentation,或直接 `import type { Context } from 'dsh-better-sidebar'`。**公开版 `cordis` 不再被依赖**(市场规则拒绝依赖字段出现 `cordis`,见 §0 硬约束) | | **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 | | **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 | @@ -642,7 +643,7 @@ interface OpenTabSeed { "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } }, "peerDependencies": { - "cordis": "^4.0.0-rc.8", + "@deepseek-ai/cordis": "^4.0.1", "dsh-better-sidebar": "workspace:*", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "react": "^18.2.0" @@ -657,7 +658,7 @@ interface OpenTabSeed { ```tsx import { createElement } from 'react' import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const inject = ['betterSidebar'] diff --git a/README.md b/README.md index f63fd8b..2290d92 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,9 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar - 🖥️ **Windows 下隐藏 Git 子进程窗口**([#301](https://github.com/omdsh-dev/DSH-better-sidebar/pull/301),关闭 [#124](https://github.com/omdsh-dev/DSH-better-sidebar/issues/124)):`runGit()` 统一加 `windowsHide: true`,仓库状态轮询与操作不再闪现控制台窗口(其他平台行为不变) - 📁 **未跟踪文件夹内文件差异**([#242](https://github.com/omdsh-dev/DSH-better-sidebar/pull/242)):`git status` 从 `--untracked-files=normal` 切换为 `--untracked-files=all`——新文件夹内每个文件独立成行、可正常加载差异(修正 `fs.read` 报 "is a directory",与 VSCode 默认行为一致) - ⚡ **开关/拖拽每帧 React 重渲染消除**(关闭 [#315](https://github.com/omdsh-dev/DSH-better-sidebar/issues/315)):centerRect 改 ref + 底栏 DOM 直写(零 React 渲染);TabContent memo(显式比较器);新增 frame-batcher 对 Divider/dock 拖拽按帧合并;拖拽期跳过无意义 locate。4x CPU 节流 A/B:开关 >17ms 帧 collapse 19→6 / expand 24→4~6,p95 21ms→15ms;拖拽不变(非回归) +- 🛒 **DSH 市场受管安装兼容**:移除 `peerDependencies` 里的公开版 `cordis`(市场预览硬拒依赖字段出现 `cordis`,optional 无效),使 npm 包满足 [dsh-community-market 安装规范](https://github.com/anywhere-labs/deepseek-harness-desktop/blob/master/dsh-community-market/docs/install-and-uninstall.zh.md)——目录(dshfind / 1024Store)里的本插件条目将重新获得 `repository_backlink` 验证目标,可直接从 Desktop 市场受管安装 +- 🔤 **类型基底迁移到 `@deepseek-ai/cordis`**:声明面(`src/context-types.ts`)不再依赖/重述公开版 cordis——`Context` = 真实 vendored cordis Context 与结构化服务面的**交集**,`ctx.betterSidebar` 类型合并改挂在 `@deepseek-ai/cordis` 上。**消费者迁移**:`import type { Context } from 'cordis'` 改为 `import type { Context } from '@deepseek-ai/cordis'`(`import type {} from 'dsh-better-sidebar'` 的类型合并方式不变);对未使用该导入的插件无影响 +- 🍃 **`ctx.effect` 严格化顺手修了 4 处**:拦截注册失败时 effect 体返回 `undefined` 改为 no-op disposer(vendored cordis 的 effect 契约要求返回 disposer,返回 `undefined` 属非法形状)
历史版本(v0.12.0 – v0.15.1) @@ -306,6 +309,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar - 📐 **推挤变量挂载期持续有效**([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259),修复 [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)):拖拽松手后底边栏不再闪全宽 - 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):无代码逻辑改动 - 🔒 **上传链路安全加固**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化 +- 🔐 **文件 API workspace 边界加固**([#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)):`fs.tree/read/write`、媒体、HTML 预览和上传统一按真实路径限制在会话 workspace 内,拒绝越界绝对路径与外链符号链接 ### v0.14.0 diff --git a/README_EN.md b/README_EN.md index cbbd363..2e678d1 100644 --- a/README_EN.md +++ b/README_EN.md @@ -274,6 +274,9 @@ All changes since v0.15.1: - 🖥️ **Hide spawned git windows on Windows** ([#301](https://github.com/omdsh-dev/DSH-better-sidebar/pull/301), fixes [#124](https://github.com/omdsh-dev/DSH-better-sidebar/issues/124)): the shared `runGit()` spawn options now set `windowsHide: true`, so repo-status polling and git actions no longer flash console windows on Windows (no behavior change on other platforms) - 📁 **Untracked files inside new folders** ([#242](https://github.com/omdsh-dev/DSH-better-sidebar/pull/242)): `git status` switched from `--untracked-files=normal` to `--untracked-files=all` — each file inside a new folder now shows as its own row and its diff can be loaded (no more `fs.read` "is a directory" error), matching VSCode's default behavior - ⚡ **Per-frame React re-renders eliminated for toggles/drags** (closes [#315](https://github.com/omdsh-dev/DSH-better-sidebar/issues/315)): `centerRect` moved to a ref with direct DOM writes to the bottom bar (zero React renders); `TabContent` memoized with an explicit comparator; a new frame-batcher coalesces Divider/dock drags per frame; meaningless locate passes skipped during drags. 4x CPU-throttled A/B: >17ms frames on toggle collapse 19→6 / expand 24→4~6, p95 21ms→15ms; drags unchanged (non-regression) +- 🛒 **DSH marketplace managed-install compatibility**: the public `cordis` entry was removed from `peerDependencies` (the market preview hard-rejects `cordis` in any dependency field — optional does not help), so the npm package now satisfies the [dsh-community-market install rules](https://github.com/anywhere-labs/deepseek-harness-desktop/blob/master/dsh-community-market/docs/install-and-uninstall.zh.md) — catalog sources (dshfind / 1024Store) can re-issue the `repository_backlink` verified target and the plugin becomes installable through the Desktop market +- 🔤 **Type base migrated to `@deepseek-ai/cordis`**: the declaration surface (`src/context-types.ts`) no longer depends on or restates the public `cordis` — `Context` is now an **intersection** of the real vendored cordis Context with the structural service faces, and the `ctx.betterSidebar` type merge lives on `@deepseek-ai/cordis`. **Consumer migration**: change `import type { Context } from 'cordis'` to `import type { Context } from '@deepseek-ai/cordis'` (the `import type {} from 'dsh-better-sidebar'` merge path is unchanged); plugins that never used that import are unaffected +- 🍃 **Strict `ctx.effect` also fixed 4 latent sites**: interception/IME-guard effect bodies returned `undefined` on failure instead of a disposer (invalid shape under the vendored cordis effect contract) — now a no-op disposer
Older releases (v0.12.0 – v0.15.1) @@ -310,6 +313,7 @@ All changes since v0.14.0: - ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged - 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload) - 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released +- 🔐 **Workspace boundary hardening for file APIs** ([#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)): `fs.tree/read/write`, media, HTML preview, and uploads now share real-path containment checks that reject outside absolute paths and symlink escapes - 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): no code changes needed - 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index b0cbf30..46bb830 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -24,12 +24,12 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**: ## 2. 前置:类型合并与依赖声明 -### 2.1 双 cordis 实例问题 +### 2.1 类型合并(统一 `@deepseek-ai/cordis`) -外部插件在 DSH monorepo 之外解析,拿不到官方 cordis 的 augmentation,因此 `ctx.betterSidebar` 不会自动出现在你的 `Context` 类型上。解法由 better-sidebar 自己提供: +DSH 运行时和生态的类型正主是 vendored `@deepseek-ai/cordis`。你的插件解析到它的 `Context` 时,`ctx.betterSidebar` 不会自动出现——由 better-sidebar 用 `declare module '@deepseek-ai/cordis'` 补上: ```ts -import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类型合并 +import type {} from 'dsh-better-sidebar' // 触发 declare module '@deepseek-ai/cordis' 类型合并 ``` 这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。 @@ -40,7 +40,7 @@ import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类 { "name": "my-plugin", "peerDependencies": { - "cordis": "^4.0.0-rc.8", + "@deepseek-ai/cordis": "^4.0.1", "dsh-better-sidebar": "workspace:*" }, "peerDependenciesMeta": { @@ -78,7 +78,7 @@ import type { } from 'dsh-better-sidebar/client/service' ``` -> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。 +> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module '@deepseek-ai/cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。 --- @@ -87,7 +87,7 @@ import type { ```ts // my-plugin/src/client/index.ts import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需) @@ -487,6 +487,8 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me | `pty.close` / `agent-pty.close` | 释放终端(外部 tab 一般用不到) | | `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫) | +> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`/sidebar/file`、`/sidebar/html` 和 `/sidebar/upload` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。 + 媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`): ```ts @@ -664,7 +666,7 @@ ctx.effect(() => | 陷阱 | 说明 | |---|---| | **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 | -| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`(`src/context-types.ts`),你 `import type {} from 'dsh-better-sidebar'` 即拿到类型 | +| **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部在 DSH 运行时正主 `@deepseek-ai/cordis` 上(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`)。消费者 `import type {} from 'dsh-better-sidebar'` 即拿到 `ctx.betterSidebar`,或直接 `import type { Context } from 'dsh-better-sidebar'`。公开版 `cordis` 不再被依赖 | | **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 | | **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 | @@ -690,7 +692,7 @@ ctx.effect(() => "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } }, "peerDependencies": { - "cordis": "^4.0.0-rc.8", + "@deepseek-ai/cordis": "^4.0.1", "dsh-better-sidebar": "workspace:*", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "react": "^18.2.0" @@ -706,7 +708,7 @@ ctx.effect(() => ```tsx import { createElement } from 'react' import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const inject = ['betterSidebar'] diff --git a/package.json b/package.json index b7c0ec8..be5cbf5 100644 --- a/package.json +++ b/package.json @@ -100,14 +100,10 @@ "@deepseek-ai/dsh-subagent": "^0.1.0-rc.8", "@deepseek-ai/dsh-tools": "^0.1.0-rc.8", "@huanlin/dsh-plugin-better-locale": "^0.1.0", - "cordis": "^4.0.0-rc.8", "react": "^18.2.0", "react-dom": "^18.2.0" }, "peerDependenciesMeta": { - "cordis": { - "optional": true - }, "@huanlin/dsh-plugin-better-locale": { "optional": true } @@ -173,6 +169,7 @@ "react-dom": "18.2.0", "tsdown": "^0.22.2", "typescript": "^5.6.0", + "unrun": "^0.3.1", "vitest": "^4.1.8" } } diff --git a/scripts/check-consumer-types.sh b/scripts/check-consumer-types.sh index 300c88a..2e3f22e 100755 --- a/scripts/check-consumer-types.sh +++ b/scripts/check-consumer-types.sh @@ -22,6 +22,10 @@ trap 'rm -rf "$WORK"' EXIT mkdir -p "$WORK/node_modules" ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar" +# The vendored cordis base (and, through its realpath, cosmokit / standard-schema) +# must resolve so the consumer can type `Context` against the DSH runtime scope. +mkdir -p "$WORK/node_modules/@deepseek-ai" +ln -s "$(pwd)/node_modules/@deepseek-ai/cordis" "$WORK/node_modules/@deepseek-ai/cordis" cat > "$WORK/check.ts" <<'EOF' import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service' @@ -32,6 +36,10 @@ import type { } from 'dsh-better-sidebar/client/service' import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-better-sidebar/client/service' import type { SidebarPrefs } from 'dsh-better-sidebar/client/service' +// The vendored-cordis path: `Context` from '@deepseek-ai/cordis' plus the +// side-effect type import above must expose `ctx.betterSidebar` — the consumer +// never needs to import this package's own Context type. +import type { Context as VendoredContext } from '@deepseek-ai/cordis' declare const ctx: { betterSidebar: BetterSidebarService } const tab: TabDescriptor = { @@ -65,6 +73,11 @@ const prefs: SidebarPrefs = snap.prefs const store: SidebarStore = null as unknown as SidebarStore void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features void SIDEBAR_SERVICE_VERSION; void SIDEBAR_FEATURES +// Vendored-cordis augmentation path. +declare const vctx: VendoredContext +vctx.betterSidebar.registerTab(tab) +vctx.betterSidebar.registerFileViewer(viewer) +void vctx.betterSidebar.getSnapshot() EOF cat > "$WORK/tsconfig.json" <<'EOF' @@ -88,14 +101,16 @@ set +e STATUS=$? set -e # Fail only on errors that mention OUR package / declarations / the fixture. -if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -v "node_modules/cordis\|node_modules/cosmokit"; then +# Upstream noise (vendored cordis / cosmokit declaration warnings under the +# repo's pinned TS lib) lives anywhere under node_modules and is filtered. +if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -vE "node_modules/[^ ]*(cordis|cosmokit)"; then echo "[check-consumer-types] FAIL: errors in the dsh-better-sidebar declaration surface:" >&2 grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2 exit 1 fi if [ "$STATUS" -ne 0 ]; then echo "[check-consumer-types] pass 2 note: strict mode only reports upstream declaration noise:" - grep -v "dsh-better-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5 + grep -v "dsh-better-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5 || true fi echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained." diff --git a/src/client/index.tsx b/src/client/index.tsx index 95b9530..6afb0f9 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -313,7 +313,7 @@ export function apply(ctx: Context): void { return registerTurnTailInterception(ctx, sidebarStore) } catch (error) { fail('interception', error) - return undefined + return () => {} } }, 'dsh-better-sidebar: turn-tail interception', @@ -325,7 +325,7 @@ export function apply(ctx: Context): void { return registerOpenPathInterception(ctx, sidebarStore) } catch (error) { fail('interception', error) - return undefined + return () => {} } }, 'dsh-better-sidebar: open-path interception', @@ -369,7 +369,7 @@ export function apply(ctx: Context): void { }) } catch (error) { fail('interception', error) - return undefined + return () => {} } }, 'dsh-better-sidebar: link interception', @@ -389,7 +389,7 @@ export function apply(ctx: Context): void { return registerImeGuard() } catch (error) { fail('ime guard', error) - return undefined + return () => {} } }, 'dsh-better-sidebar: IME composition guard', diff --git a/src/context-types.ts b/src/context-types.ts index 5686795..a62333b 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -1,31 +1,34 @@ /** * Structural types for the cordis services this plugin consumes, plus the - * Context augmentation both halves share. A third-party plugin resolves - * outside the DSH monorepo's single cordis instance, so the upstream - * `declare module 'cordis'` augmentations do not reach this Context — and - * the npm cordis package does not declare the DSH-vendored runtime members - * (`ctx.effect`, service properties). The members below mirror the actual - * runtime shapes this plugin touches: - * - webServer: @deepseek-ai/dsh-host-webserver (the WebServer) - * - sessions: host side @deepseek-ai/dsh-session (SessionStore), client - * side the runtime ISessions list feed - * - conversation: client side ui-conversation's IConversation (composer - * draft), read lazily through `ctx.get` — cross-plugin service reads need - * an inject declaration, so the direct property is never typed here - * - webRuntime: @deepseek-ai/dsh-web-app (bind-derived trusted hosts) - * - slots: the client runtime SlotRegistry - * - effect: the DSH-vendored cordis lifecycle helper - * Drift from upstream is contained to this file. + * Context face both halves share. + * + * The type base is the vendored `@deepseek-ai/cordis` Context (the runtime + * DSH actually runs); the service members this plugin touches are restated + * below as structural mirrors and combined with the base by INTERSECTION. + * Intersection (not `declare module` augmentation) is deliberate: DSH's own + * packages already augment `@deepseek-ai/cordis`, and the host and client + * packages declare *different* types for the same member — host + * `sessions: SessionStore` vs client runtime `sessions: ISessions` — so a + * single program that re-declares them would fail interface merging + * (TS2717). Intersecting keeps every face available and lets each call site + * resolve against the member it needs without any module-level conflict. + * + * `effect`, `get`, `provide`, `inject`, `logger`, `emit`, `isolate` and the + * event helpers come from the vendored cordis base and are intentionally NOT + * restated here: their strict shapes are the runtime contract (e.g. an + * effect body must return a disposer). Only the string-keyed session-feed + * `on` overload is added, because the cordis `on` is keyed to its own + * typed `Events` map and the harness session feed is a plain string event. * * This file must stay FREE of Node.js types (`node:http`, `node:stream`, * `Buffer`): it is part of the CLIENT-reachable declaration graph (the - * `Context` in `TabComponentProps` and the `declare module` augmentation), + * `Context` in `TabComponentProps` and the `betterSidebar` augmentation), * so a Node import here would leak into browser-only consumer builds. The * webServer faces below are therefore structural mirrors with plain * interfaces (the host casts to real Node types at the few boundaries that * need them — e.g. the `ws` upgrade hook in src/index.ts). */ -import type { Context } from 'cordis' +import type { Context as CordisContext } from '@deepseek-ai/cordis' import type { BetterSidebarService } from './client/service.ts' /** The request face route handlers see (structural subset of node's @@ -514,97 +517,78 @@ export interface SidebarAgent { } } -declare module 'cordis' { - interface Context { - webServer: SidebarWebServer - sessions: SidebarSessionStore & SidebarSessionsService - connection: SidebarConnectionHandle - webRuntime: SidebarWebRuntime - slots: SidebarSlotsService - workspaces: SidebarWorkspacesService - settings: SidebarSettingsService - invariants: SidebarInvariantsService - tools: SidebarToolsService - /** - * The client locale service (`@deepseek-ai/dsh-client-locale`): the - * sidebar's copy follows its active locale and registers its - * dictionaries under the `betterSidebar` namespace. Client side only. - */ - locale: SidebarLocaleService - /** - * The client module system (rc.8+): resolves module-table specifiers - * (seed words, graph rows) — the chunk loader's externals go through it - * (`ctx.modules.import`). rc.7 exposed the same surface as the - * `window.__DSH_MODULES__` page global; the loader keeps that fallback. - */ - modules: { import(specifier: string): Promise } - /** - * The host background-job registry (`ctx.get('jobs')`; optional — the - * sidebar routes degrade to a 503 when the deployment lacks it). - */ - jobs: SidebarJobsService - /** - * The host live-agent registry (`ctx.get('agents')`; optional — used to - * resolve the caller the jobs fence compares against, and to create / - * resume the Side Chat thread agents). - */ - agents: SidebarAgentsService - /** - * The host subagent runtime (`ctx.subagents`; optional — the Subagent - * page's live batch route reads descendant catalogs through it). - */ - subagents: SidebarSubagentsService - /** - * The host agent-presets service (`ctx.get('agentPresets')`; optional — - * absent deployments compose nothing and every session shares the host - * composition). - */ - agentPresets: SidebarAgentPresetsService - /** - * The host session-title service (`ctx.get('sessionTitle')`; optional — - * the Side Chat thread label pin degrades to the auto-generated title). - */ - sessionTitle: SidebarSessionTitleService - /** - * The host session-persistence service (`ctx.get('sessionPersistence')`; - * optional — needed only for the Side Chat cold-resume composition). - */ - sessionPersistence: SidebarSessionPersistenceService - /** - * The client-side sidebar registry: external plugins register tab types - * and file previewers here. Provided by the client half (see - * {@link ./client/index.tsx}); undefined on the host side. - */ - betterSidebar: BetterSidebarService - /** - * Subscribe to the session append feed (mirror of the cordis event API): - * the listener receives every appended session event with the LIVE - * Session instance that appended it. The api-proxy pushes the same feed - * to browsers; the sidebar uses it to mirror job_output events the - * session store's own log can lag behind (restart divergence). Returns - * the disposer. - */ - on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void - /** - * Register a lifecycle callback (DSH-vendored cordis): runs at plugin - * activation; its returned cleanup runs at disposal. - */ - effect(fn: () => void | (() => void), label?: string): void - } +/** + * The shape this plugin actually consumes, intersected with the vendored + * cordis `Context` below (see the file header for why intersection is used + * instead of module augmentation). + */ +export interface SidebarContextShape { + /** The webServer service face this plugin uses. */ + webServer: SidebarWebServer + /** The session store (host `.get`) and the client list feed (`.list`) faces. */ + sessions: SidebarSessionStore & SidebarSessionsService + /** The wire handle the Side Chat transcript polls through. */ + connection: SidebarConnectionHandle + /** The web runtime trust list (bind-derived). */ + webRuntime: SidebarWebRuntime + /** The client slot registry (register/inject). */ + slots: SidebarSlotsService + /** The client workspaces service face (file-open funnel). */ + workspaces: SidebarWorkspacesService + /** The settings service face (prefs persistence + namespace reads). */ + settings: SidebarSettingsService + /** The invariant registry face. */ + invariants: SidebarInvariantsService + /** The tool registry face. */ + tools: SidebarToolsService + /** The client locale service face. */ + locale: SidebarLocaleService + /** The client module system (rc.8+ chunk-loader externals). */ + modules: { import(specifier: string): Promise } + /** The host background-job registry (optional; routes degrade to 503). */ + jobs: SidebarJobsService + /** The host live-agent registry (optional; side chat thread agents). */ + agents: SidebarAgentsService + /** The host subagent runtime (optional; live topology batch route). */ + subagents: SidebarSubagentsService + /** The host agent-presets service (optional; side chat cold resume). */ + agentPresets: SidebarAgentPresetsService + /** The host session-title service (optional; side chat thread label pin). */ + sessionTitle: SidebarSessionTitleService + /** The host session-persistence service (optional; side chat cold resume). */ + sessionPersistence: SidebarSessionPersistenceService + /** The composer draft face (client ui-conversation, lazy `ctx.get` probe). */ + conversation: SidebarConversation + /** + * The client-side sidebar registry: external plugins register tab types + * and file previewers here. Provided by the client half (see + * {@link ./client/index.tsx}); undefined on the host side. + */ + betterSidebar: BetterSidebarService + /** + * String-keyed session feed subscribe (the vendored cordis `on` is keyed + * to its typed Events map; the harness session feed is a plain string + * event). The listener receives every appended session event with the + * LIVE Session instance that appended it. + */ + on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void } /** - * Dual cordis-scope augmentation: DSH's runtime (and the public packages) - * resolve against the vendored `@deepseek-ai/cordis` scope, which DSH's own - * packages already augment with `effect`/`on`/service members. This side only - * adds the sidebar service member so consumers importing `Context` from - * `@deepseek-ai/cordis` (instead of the public `cordis` above) still see - * `ctx.betterSidebar`. + * The Context this plugin sees: the vendored cordis Context intersected with + * the structural service faces above. Re-exported from the package root so a + * consumer can `import type { Context } from 'dsh-better-sidebar'`. + */ +export type Context = CordisContext & SidebarContextShape + +/** + * Consumer-facing augmentation (deliberately the only one kept): a plugin + * that imports `Context` from `@deepseek-ai/cordis` and does + * `import type {} from 'dsh-better-sidebar'` sees `ctx.betterSidebar` + * without importing this package's own Context type. */ declare module '@deepseek-ai/cordis' { interface Context { betterSidebar: BetterSidebarService } } - -export type { Context } diff --git a/src/fs-operations.ts b/src/fs-operations.ts index a48447d..c867a09 100644 --- a/src/fs-operations.ts +++ b/src/fs-operations.ts @@ -1,13 +1,12 @@ /** * Workspace-safe file mutations for the sidebar (the upload route today). * - * Every write is lexically confined to the session workspace: the upload - * directory is resolved absolute and must sit inside the session cwd, the - * relative path is sanitized (absolute paths, '.', '..' and empty segments - * are refused), and the final target must stay inside both. Containment is - * lexical (no symlink resolution) — a symlinked directory inside the cwd can - * redirect writes outside, matching the trust model of the other /sidebar/* - * routes. Bytes stream from the request body to a uniquely named temp sibling + * Every write is confined to the real session workspace: the upload + * directory is resolved absolute and its target is checked through existing + * filesystem ancestors, the relative path is sanitized (absolute paths, '.', + * '..' and empty segments are refused), and the final target must stay inside + * the workspace after symlink resolution. Bytes stream from the request body + * to a uniquely named temp sibling * and are renamed into place, so a failed, aborted, or oversized upload never * leaves a partial file at the target path. */ @@ -17,7 +16,8 @@ import { createWriteStream } from 'node:fs' import { mkdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import type { SidebarHttpRequest } from './context-types.ts' -import { isWithin, requireAbsolute } from './fs-tree.ts' +import { requireAbsolute } from './fs-tree.ts' +import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' import { SidebarError } from './wire.ts' /** Inputs of one upload: the session scope plus the request body stream. */ @@ -48,9 +48,7 @@ export interface WorkspaceUploadInput { export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> { const { cwd, dir, relativePath, chunks, limit } = input const base = requireAbsolute(dir) - if (!isWithin(cwd, base)) { - throw new SidebarError('forbidden', 'upload directory escapes the session workspace', 403) - } + await ensureWorkspacePath(cwd, base) if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) { throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400) } @@ -59,11 +57,9 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400) } const target = join(base, ...segments) - if (!isWithin(cwd, target) || !isWithin(base, target)) { - throw new SidebarError('forbidden', 'target escapes the session workspace', 403) - } - const tmp = join(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`) - await mkdir(dirname(target), { recursive: true }) + const safeTarget = await ensureWorkspaceWritePath(cwd, target) + const tmp = join(dirname(safeTarget), `.${basename(safeTarget)}.dsh-upload-${randomUUID()}.tmp`) + await mkdir(dirname(safeTarget), { recursive: true }) const stream = createWriteStream(tmp, { flags: 'wx' }) // Resolves once the stream fully closes; created up front so a stream that // already closed (successful end, later failure) cannot leave the wait hanging. @@ -85,8 +81,8 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise stream.end((error?: Error | null) => (error === undefined || error === null ? resolve() : reject(error))) }) if (streamError !== undefined) throw streamError - await rename(tmp, target) - const info = await stat(target) + await rename(tmp, safeTarget) + const info = await stat(safeTarget) return { path: target, size: info.size } } catch (error) { // Wait for the stream to fully close before unlinking (Windows locks open diff --git a/src/html-route.ts b/src/html-route.ts index 1dfac34..cc74261 100644 --- a/src/html-route.ts +++ b/src/html-route.ts @@ -57,7 +57,7 @@ export function encodeHtmlUrl(sessionId: string, path: string): string { * Decode a route pathname into the session + absolute file path. Rejects * a wrong prefix (404), an empty path, malformed percent encoding, and a * missing sessionId or file path (400). The caller still must bound the - * decoded path with requireAbsolute + isWithin(cwd) — a decoded `..` + * decoded path with the workspace real-path guard — a decoded `..` * segment resolves outside the cwd and is refused there. */ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult { @@ -96,7 +96,7 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult { // A Windows drive segment ('D:') is the FIRST path segment of an encoded // drive path. Rejoining it with a leading slash would yield '/D:/work/...' // which node's path.resolve() mangles into 'D:\D:\work\...' on Windows — - // the html route's isWithin(cwd) fence would then reject every drive path. + // the html route's workspace fence would then reject every drive path. // Keep the drive form slash-free so requireAbsolute() resolves it verbatim. path = tail.join('/') } else { diff --git a/src/index.ts b/src/index.ts index 067add6..eb20bdf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,8 +28,9 @@ import { type SidebarConfig, type SidebarPrefs, } from './config.ts' -import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' +import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' import { writeWorkspaceUpload } from './fs-operations.ts' +import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' import { searchFiles } from './fs-search.ts' import { decodeHtmlUrl } from './html-route.ts' import { extractFrameAncestors } from './browser-probe.ts' @@ -54,8 +55,10 @@ import { readJsonBody, requireString, SidebarError, writeError, writeJson, write export { Config } export type { SidebarConfig, ResolvedSidebarConfig } -// Re-export the Context augmentation (declare module 'cordis') so consumers -// `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`. +// Re-export the Context augmentation (`declare module '@deepseek-ai/cordis'`) +// so consumers `import type {} from 'dsh-better-sidebar'` and gain +// `ctx.betterSidebar`; the Context re-export below is the vendored cordis +// Context intersected with the structural service faces. // Also re-export the service descriptor types so consumers can type their // registerTab / registerFileViewer arguments without reaching into /client. export type { Context } from './context-types.ts' @@ -128,6 +131,12 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri */ async function resolveGitPath(cwd: string, raw: string): Promise { if (isAbsolute(raw)) return requireAbsolute(raw) + // Prefer the session-relative interpretation when it names an existing + // path. Git status reports repository-root-relative names, but the sidebar + // security boundary is the session workspace; this preference keeps files + // inside a nested session readable without reopening the repository root. + const sessionPath = requireAbsolute(join(cwd, raw)) + if (await stat(sessionPath).then(() => true).catch(() => false)) return sessionPath const root = await git.repoRoot(cwd).catch(() => cwd) return requireAbsolute(join(root, raw)) } @@ -248,7 +257,7 @@ function buildApi( 'fs.tree': async (payload) => { const { cwd } = cwdOf(payload) const record = payload as { path?: unknown } - const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path')) + const target = record.path === undefined ? cwd : await ensureWorkspacePath(cwd, requireString(payload, 'path')) return listDirectory(target, resolved.listLimit) }, 'fs.search': async (payload) => { @@ -263,14 +272,14 @@ function buildApi( const { cwd } = cwdOf(payload) // Relative paths are git-derived (status/diff report repo-root-relative // names; the untracked diff view reads the file through this route). - const path = await resolveGitPath(cwd, requireString(payload, 'path')) + const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'))) const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit) if (binary) return { kind: 'binary', size, truncated, head } return { kind: 'text', content, truncated } }, 'fs.write': async (payload) => { const { cwd } = cwdOf(payload) - const path = requireAbsolute(requireString(payload, 'path')) + const path = await ensureWorkspaceWritePath(cwd, requireString(payload, 'path')) const content = requireString(payload, 'content') const tmp = `${path}.dsh-sidebar-tmp-${process.pid}` try { @@ -722,14 +731,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { const raw = url.searchParams.get('path') if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required') const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) - const path = requireAbsolute(raw) - if (!isWithin(cwd, path)) { - // Only files under the session cwd are served as media (the editor - // opens images from the explorer; produced files go through read). - // isWithin (not a raw startsWith) so case-mismatched Windows paths - // and mixed separators cannot be misclassified. - throw new SidebarError('fs-error', 'media path outside the session working directory', 403) - } + const path = await ensureWorkspacePath(cwd, raw) const info = await stat(path) if (!info.isFile() || info.size > resolved.mediaLimit) { throw new SidebarError('fs-error', 'not a file or too large', 400) @@ -784,13 +786,11 @@ export function apply(ctx: Context, config?: SidebarConfig): void { const { sessionId, path } = decoded.ref // The session's authoritative cwd (client cwd cannot ride in the URL // — the path encoding has no query; a detached first request falls - // back to the process cwd and is normally refused by isWithin, same - // semantics as the media route's fallback). + // back to the process cwd and is normally refused by the workspace + // real-path guard, with the same semantics as the media route's + // fallback. const cwd = sessionCwdOf(ctx, sessionId) - const absolute = requireAbsolute(path) - if (!isWithin(cwd, absolute)) { - throw new SidebarError('fs-error', 'html path outside the session working directory', 403) - } + const absolute = await ensureWorkspacePath(cwd, path) const info = await stat(absolute) if (!info.isFile() || info.size > resolved.mediaLimit) { throw new SidebarError('fs-error', 'not a file or too large', 400) diff --git a/src/path-security.ts b/src/path-security.ts new file mode 100644 index 0000000..0edbcef --- /dev/null +++ b/src/path-security.ts @@ -0,0 +1,75 @@ +/** Filesystem path guards shared by sidebar APIs that access a session workspace. */ +import { realpath } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { isWithin, requireAbsolute } from './fs-tree.ts' +import { SidebarError } from './wire.ts' + +/** Resolve a path and convert filesystem resolution failures to an API error. */ +async function resolveRealPath(path: string, label: string): Promise { + try { + return await realpath(path) + } catch (error) { + throw new SidebarError('fs-error', `cannot resolve ${label} "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) + } +} + +/** Reject a resolved path whose real filesystem target escapes the workspace. */ +function assertWithinWorkspace(workspace: string, target: string): void { + if (!isWithin(workspace, target)) { + throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403) + } +} + +/** + * Resolve an existing workspace path through symlinks and enforce containment. + * + * @param cwd - Session workspace directory. + * @param target - Client-supplied absolute path. + * @returns The canonical absolute path used for the filesystem operation. + */ +export async function ensureWorkspacePath(cwd: string, target: string): Promise { + const absolute = requireAbsolute(target) + const [realCwd, realTarget] = await Promise.all([ + resolveRealPath(cwd, 'workspace'), + resolveRealPath(absolute, 'target'), + ]) + assertWithinWorkspace(realCwd, realTarget) + return realTarget +} + +/** + * Validate a write destination, including destinations that do not exist yet. + * Existing targets are resolved to catch symlinks; missing targets are checked + * against the nearest existing ancestor before the caller creates or renames. + * The returned path is rebuilt from that canonical ancestor, so an existing + * symlink is never left in the path passed to the write operation. + * + * @param cwd - Session workspace directory. + * @param target - Client-supplied absolute destination path. + * @returns A canonical path for an existing target or its nearest existing ancestor. + */ +export async function ensureWorkspaceWritePath(cwd: string, target: string): Promise { + const absolute = requireAbsolute(target) + const realCwd = await resolveRealPath(cwd, 'workspace') + let existingPath = absolute + const missingSegments: string[] = [] + + for (;;) { + try { + const realTarget = await realpath(existingPath) + assertWithinWorkspace(realCwd, realTarget) + return missingSegments.reduce((path, segment) => join(path, segment), realTarget) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (error instanceof SidebarError) throw error + throw new SidebarError('fs-error', `cannot resolve target "${existingPath}": ${error instanceof Error ? error.message : String(error)}`, 400) + } + const parent = dirname(existingPath) + if (parent === existingPath) { + throw new SidebarError('fs-error', `cannot resolve target "${absolute}"`, 400) + } + missingSegments.unshift(basename(existingPath)) + existingPath = parent + } + } +} diff --git a/tests/fs-operations.spec.ts b/tests/fs-operations.spec.ts index 63c3896..e0ebefb 100644 --- a/tests/fs-operations.spec.ts +++ b/tests/fs-operations.spec.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { writeWorkspaceUpload } from '../src/fs-operations.ts' @@ -103,6 +103,23 @@ describe('writeWorkspaceUpload', () => { } }) + it('refuses upload directories and targets that resolve outside the workspace', async () => { + const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-symlink-outside-')) + const link = join(root, 'upload-link') + try { + symlinkSync(outside, link) + await expect(writeWorkspaceUpload({ + cwd: root, dir: link, relativePath: 'x.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'forbidden' }) + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: 'upload-link/x.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'forbidden' }) + } finally { + rmSync(link, { force: true }) + rmSync(outside, { recursive: true, force: true }) + } + }) + it('refuses oversized uploads without leaving a target or temp file', async () => { const target = join(root, 'big.bin') await expect(writeWorkspaceUpload({ diff --git a/tests/market-manifest.spec.ts b/tests/market-manifest.spec.ts new file mode 100644 index 0000000..3638575 --- /dev/null +++ b/tests/market-manifest.spec.ts @@ -0,0 +1,108 @@ +/** + * DSH community-market compatibility guard: keeps the npm package inside the + * "Host 接受什么" rules of dsh-community-market's + * docs/install-and-uninstall.zh.md, so the catalog's verified npm install + * target (repository_backlink) keeps passing for this package: + * + * - no `cordis` entry in dependencies / peerDependencies / + * optionalDependencies (the market preview hard-rejects the legacy Cordis + * runtime name in any of the three fields, even when optional), + * - no install lifecycle scripts (preinstall / install / postinstall / + * prepare) in the **packed** manifest — the repo manifest legitimately + * keeps `prepare` for git-install builds; pnpm strips it from the published + * surface, and this guard verifies that stripped surface stays clean, + * - a safe, bund-visible `dsh.bundle.patch` path (`./cordis.patch.yml`), + * - a repository identity that normalizes to a credential-free HTTPS GitHub + * owner/repo URL (the catalog ↔ npm backlink comparison), + * - an exact stable SemVer version (no ranges, no tags, no prerelease). + * + * The packed-manifest checks run `pnpm pack` into a temp dir (the `prepare` + * script rebuilds the gitignored lib/, like the release flow does). + */ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url))) + +const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as { + name: string + version: string + repository?: { type?: string; url?: string } + engines?: { node?: string } + dsh?: { bundle?: { patch?: string } } + scripts?: Record + dependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record +} + +/** The market's lifecycle-script reject list (install-and-uninstall.zh.md). */ +const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'] as const + +/** Stable exact SemVer: exactly three numeric segments (no tag / prerelease). */ +const STABLE_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u + +/** Read `package.json` from a fresh `pnpm pack` tarball (the publish surface). */ +function packedManifest(): Record { + const dir = mkdtempSync(join(tmpdir(), 'market-pack-')) + try { + execFileSync('pnpm', ['pack', '--pack-destination', dir], { cwd: ROOT, stdio: 'pipe' }) + execFileSync('tar', ['-xzf', join(dir, `${pkg.name}-${pkg.version}.tgz`), '-C', dir, 'package/package.json'], { stdio: 'pipe' }) + return JSON.parse(readFileSync(join(dir, 'package/package.json'), 'utf8')) as Record + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +describe('DSH community-market manifest compatibility', () => { + it('declares no cordis entry in any dependency field (dependencies / peerDependencies / optionalDependencies)', () => { + for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const) { + const deps = pkg[field] + if (deps === undefined) continue + expect(Object.keys(deps), field).not.toContain('cordis') + } + }) + + it('declares no install lifecycle scripts in the PACKED manifest (the published surface)', () => { + const packed = packedManifest() + const scripts = (packed.scripts as Record | undefined) ?? {} + for (const script of LIFECYCLE_SCRIPTS) { + expect(scripts, script).not.toHaveProperty(script) + } + }) + + it('declares a safe bundle patch that exists inside the package', () => { + const patch = pkg.dsh?.bundle?.patch + expect(patch, 'dsh.bundle.patch').toBeDefined() + const path = patch! + expect(path.startsWith('./'), 'must be a relative path').toBe(true) + const relative = path.slice(2) + expect(relative).not.toBe('') + expect(relative.startsWith('/')).toBe(false) + expect(relative.includes('\\')).toBe(false) + expect(relative.split('/').every(segment => segment.length > 0 && segment !== '.' && segment !== '..')) + .toBe(true) + expect(existsSync(resolve(ROOT, relative)), relative).toBe(true) + }) + + it('declares a repository identity the market can normalize to a credential-free HTTPS GitHub owner/repo URL', () => { + const repository = pkg.repository + expect(repository, 'repository').toBeDefined() + const url = new URL(repository!.url!) + expect(url.protocol).toBe('https:') + expect(url.username).toBe('') + expect(url.password).toBe('') + expect(url.search).toBe('') + expect(url.hash).toBe('') + expect(url.hostname.toLowerCase()).toBe('github.com') + expect(url.pathname.split('/').filter(Boolean)).toHaveLength(2) + }) + + it('declares an exact stable SemVer version (no range, tag, or prerelease)', () => { + expect(pkg.version).toMatch(STABLE_SEMVER) + }) +}) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index ba6ffc7..4b57753 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -15,6 +15,20 @@ import { listDirectory } from '../src/fs-tree.ts' import { defaultShell, PtyManager, type SidebarPty } from '../src/pty-manager.ts' import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts' +/** Symlink creation may require elevated privileges on Windows. */ +const canCreateSymlink = (() => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-security-probe-')) + try { + mkdirSync(join(dir, 'target')) + symlinkSync(join(dir, 'target'), join(dir, 'link')) + return true + } catch { + return false + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() + interface FakeContext { webRuntime: { trustedHosts: readonly string[] } webServer: { @@ -420,7 +434,7 @@ describe('session cwd resolution over the API route', () => { sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined } } - const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => { + const mountAll = (overrides: CtxOverrides = {}): SidebarWebRoute[] => { const routes: SidebarWebRoute[] = [] const ctx = { webRuntime: { trustedHosts: [] }, @@ -438,14 +452,16 @@ describe('session cwd resolution over the API route', () => { get: () => undefined, } apply(ctx as never) - return routes.find(route => route.path === '/sidebar/api')! + return routes } + const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => mountAll(overrides).find(route => route.path === '/sidebar/api')! + const invoke = async ( route: SidebarWebRoute, method: string, payload: unknown, - ): Promise<{ ok: boolean; value?: { cwd: string }; error?: { message: string } }> => { + ): Promise<{ ok: boolean; status: number; value?: { cwd: string }; error?: { code?: string; message: string } }> => { const body = Buffer.from(JSON.stringify(payload)) const req = { method: 'POST', @@ -459,7 +475,18 @@ describe('session cwd resolution over the API route', () => { end: (chunk: unknown) => { out.body += String(chunk ?? '') }, } as never await route.handler(req, res) - return JSON.parse(out.body) as { ok: boolean; value?: { cwd: string }; error?: { message: string } } + return { ...JSON.parse(out.body) as { ok: boolean; value?: { cwd: string }; error?: { code?: string; message: string } }, status: out.status } + } + + const invokeGet = async (route: SidebarWebRoute, url: string): Promise<{ status: number; body: string }> => { + const out: { status: number; body: string } = { status: 200, body: '' } + const req = { method: 'GET', url, headers: { host: '127.0.0.1:3080' } } as never + const res = { + writeHead: (status: number) => { out.status = status }, + end: (chunk: unknown) => { out.body += String(chunk ?? '') }, + } as never + await route.handler(req, res) + return out } it('uses the client summary cwd while the session is detached', async () => { @@ -532,6 +559,156 @@ describe('session cwd resolution over the API route', () => { expect(value.value?.kind).toBe('text') expect(value.value?.content).toContain('runGit') }) + + it('rejects repo-root-relative fs.read paths outside a nested session workspace', async () => { + const route = mount({ + sessions: { + get: () => ({ header: { cwd: join(process.cwd(), 'src') } }), + }, + }) + const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'package.json' }) + expect(result).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + }) + + it('rejects fs.tree paths outside the session workspace', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + const outsideFile = join(outside, 'secret.txt') + writeFileSync(outsideFile, 'secret') + try { + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: outside }) + expect(tree).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects fs.read paths outside the session workspace', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + const outsideFile = join(outside, 'secret.txt') + writeFileSync(outsideFile, 'secret') + try { + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const read = await invoke(route, 'fs.read', { sessionId: 'security', path: outsideFile }) + expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects fs.write paths outside the session workspace', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + try { + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const write = await invoke(route, 'fs.write', { sessionId: 'security', path: join(outside, 'written.txt'), content: 'hack' }) + expect(write).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects media and HTML reads through a workspace symlink', async () => { + if (!canCreateSymlink) return + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-route-symlink-security-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + const mediaPath = join(outside, 'secret.png') + const htmlPath = join(outside, 'secret.html') + writeFileSync(mediaPath, 'not an image') + writeFileSync(htmlPath, '

secret

') + try { + symlinkSync(outside, join(workspace, 'link')) + const routes = mountAll({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const media = routes.find(route => route.path === '/sidebar/file')! + const html = routes.find(route => route.path === '/sidebar/html')! + const mediaResult = await invokeGet(media, `/sidebar/file?sessionId=security&path=${encodeURIComponent(join(workspace, 'link', 'secret.png'))}`) + const htmlPathname = join(workspace, 'link', 'secret.html').replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/') + const htmlResult = await invokeGet(html, `/sidebar/html/security${htmlPathname}`) + expect(mediaResult).toMatchObject({ status: 403 }) + expect(JSON.parse(mediaResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } }) + expect(htmlResult).toMatchObject({ status: 403 }) + expect(JSON.parse(htmlResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps fs.tree missing-path failures as fs errors', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-')) + const workspace = join(root, 'workspace') + mkdirSync(workspace) + try { + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: join(workspace, 'missing') }) + expect(tree).toMatchObject({ ok: false, status: 400, error: { code: 'fs-error' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it.skipIf(!canCreateSymlink)('rejects workspace symlinks that resolve outside the workspace', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + writeFileSync(join(outside, 'secret.txt'), 'secret') + try { + symlinkSync(outside, join(workspace, 'link')) + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: join(workspace, 'link') }) + expect(tree).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it.skipIf(!canCreateSymlink)('rejects fs.read through a workspace symlink', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + writeFileSync(join(outside, 'secret.txt'), 'secret') + try { + symlinkSync(outside, join(workspace, 'link')) + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const read = await invoke(route, 'fs.read', { sessionId: 'security', path: join(workspace, 'link', 'secret.txt') }) + expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it.skipIf(!canCreateSymlink)('rejects fs.write through a workspace symlink', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + try { + symlinkSync(outside, join(workspace, 'link')) + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const write = await invoke(route, 'fs.write', { sessionId: 'security', path: join(workspace, 'link', 'new.txt'), content: 'hack' }) + expect(write).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) }) describe('side card settings routes', () => {