# 宿主侧插件开发:本部署的硬约束 > 这几条都是**踩过坑、有源码或实测证据**的结论。写宿主侧代码前先读这里。 > 全部在 DSH `0.2.0-rc.2` + profile `desktop` 上验证。 --- ## 1. `ctx.fs` 的写入必须传 per-call 沙箱策略 ### 症状 在工作区**内部**创建文件,却被拒绝: ``` Error: cannot write "/Users/juergenie/Projects/Workspaces/DSH/foo.excalidraw": file access denied under workspace-write mode ``` 注意:路径明明在会话工作区里,报错却说 `workspace-write` 拒绝。 ### 根因 `@deepseek-ai/dsh-fs-sandbox/lib/index.js:153-164`: ```js async checkedTarget(target, sandboxPolicy) { const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve(); // ← 无 session! const { mode } = policy; if (mode === "danger-full-access") return target; if (mode === "read-only") throw new FsError(`cannot write "…": file access denied under read-only mode`, "FS_SANDBOX_DENIED"); const fresh = await this.resolve(target.displayPath); let contained = false; for (const root of writableRoots(policy)) if (await isPathUnder(fresh.targetKey, root)) { contained = true; break; } if (!contained) throw new FsError(`cannot write "…": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED"); return fresh; } ``` **不传 `sandboxPolicy` 时,后端调用的是 `sandboxPolicy.resolve()`——没有 session 参数**, 于是拿到的是部署默认策略,它的可写根**不包含会话工作区** → 拒绝。 ### 正确写法 照抄官方 `@deepseek-ai/dsh-tool-fs` 的 `FsSandboxController`(asar 内 `lib/index.js:1084-1090` 与 `:581-588`): ```js /** 本次调用应盖在写入上的沙箱策略。 */ function standingPolicy(ctx, exec) { const mode = ctx.fs?.sandboxMode if (mode === undefined) return undefined // 没有会限制写入的后端 const resolver = ctx.get('sandboxPolicy') if (resolver === undefined) { throw new Error('…: the mounted filesystem confines writes but ctx.sandboxPolicy is missing') } return resolver.resolve({ ...(exec.agent ? { session: exec.agent.session } : {}) }) } // 解析路径:写入必须以策略里的 workspaceRoot 为基准 const policy = standingPolicy(ctx, exec) const target = await ctx.fs.resolve(path, { ...(policy?.workspaceRoot !== undefined ? { cwd: policy.workspaceRoot } : {}), signal: exec.signal, }) // ★ 第 5 个参数就是策略,不传就会被上面的默认策略拒掉 await ctx.fs.writeText(target, content, expected, exec.signal, policy) await ctx.fs.editText(target, edit, expected, exec.signal, policy) ``` **读操作(`stat` / `readText` / `readByteRange` / `listDir`)不需要策略**——沙箱只管变更。 ### 排查提示 把 `sandboxMode` / 解析出的策略 / 会话 cwd 一起写进错误消息。这条报错本身**不含任何上下文**, 不主动附加信息的话,你只能看到一句"拒绝",无法区分是越界还是策略缺失。 --- ## 2. 宿主侧插件代码**不热重载** ### 症状 改了 `lib/*.mjs`,重新 `install_bundle` 或 `set_plugin` 停用/启用, 返回都是 `application: applied`,**但行为完全没变**——新代码没被导入。 ### 证据 本项目中做过 4 次尝试,全部无效: | 尝试 | 结果 | |---|---| | `set_plugin` 停用 → 启用 | `application: applied`,行为不变 | | `install_bundle` 重装 | `application: applied`,行为不变 | | `remove_bundle` → `install_bundle` | `application: applied`,行为不变 | | **改 `exports["."]` 指向新文件名**(换模块 URL,绕 ESM 缓存) | `application: applied`,行为不变 | 判定方法:在新代码里给错误消息附加一段独一无二的诊断后缀,然后观察报错里有没有它。 **四次都没有** → 模块确实没被重新导入。 ### 根因 `@deepseek-ai/dsh-hmr` 的 README(asar 内)原文: > 启动器提供 `profileContext` 时,base 组合包以 `root: []` 启用 HMR;没有该 profile 上下文的宿主保留此条目为禁用状态。 > 如需监听源码模块,**在启动前**通过 profile patch 配置 base 组合包提供的 `hmr` 条目:`root: ["."]` 也就是说:**在 profile 下,HMR 默认只监听配置,不监听源码模块**。 `plugin_manager` 的操作会「应用」配置变更,但不会重新导入已经加载过的宿主模块。 ### 后果与应对 - **宿主侧(`lib/*.mjs`)改动必须重启 DSH 才生效。** - 浏览器侧(`lib/client.js`)不受此限——`dsh-client-hmr` 会标记并重载客户端 bundle。 - 因此:**先把宿主侧逻辑做对再重启**,不要指望边改边看。 能用纯函数表达的(如 `lib/scene.mjs`)就抽出来,配独立冒烟测试在宿主之外跑。 - 想让模块也热重载,需要在 profile patch 里把 `hmr` 的 `root` 配成非空—— 但这会监听 profile 目录而非本项目目录,且属于对**整个 profile** 的改动,本项目不擅自设置。 --- ## 3. 依赖解析:`link:` 与 profile 的关系 - 插件以 `link:` 挂进 profile 时,**真实路径在 profile 之外**,Node 的模块解析会从 `<项目>/lib/` 往上找,找不到 `@deepseek-ai/*`。 - profile 侧之所以能解析,是因为 `~/.dsh/profiles/node_modules/@deepseek-ai/*` 里有一批指向宿主 App 的**软链**(由桌面版安装器建立)。 - **应对**:把 `@deepseek-ai/dsh-tools` 放进 `devDependencies` (开发时装进本包 `node_modules`,分发时由宿主提供,同时保留 optional `peerDependencies`)。 ### 刻意不用 `@deepseek-ai/schemastery` `dsh-free-search` 的源码里有一条明确的兼容警告:Node 会优先解析插件目录下的嵌套依赖, 若那里有旧版 `schemastery`,它会**遮蔽**顶层正确的版本,报出与根因完全无关的错误 (`z.string(...).volatile is not a function`)。 因此本项目**不导出 `Config`**(那需要 schemastery),配置在 `apply` 里防御性读取。 代价是插件在插件列表里没有可编辑的 Config schema。 --- ## 4. `plugin_manager` 的标识符形态 - `install_bundle` / `remove_bundle` 用**包名或绝对路径**(`dsh-graphics` / `/Users/…/dsh-graphics`)。 - `set_plugin` 用 **entryId**,形如 `include:`,即 `include:graphics`。 用包名会返回 `unknown-plugin`。 - 装好之后用 `cordis_inspect_query`(`platform: host`、`provider: Config`、`method: listConfigs`、 `input: {"name": "<包名>"}`)确认行是否存在,**不需要审批**。 - 该查询返回的 `status` 字段描述的是 **Config schema** 状态,不是激活状态: - `schema` = 该行导出了 Config schema - `absent` = 该行没有 Config schema(本项目就是这个) - 不能用它判断插件是否激活;判断激活要看工具是否真的出现在 Agent 的工具表里。 --- ## 5. 怎么确认插件真的激活了 `application: applied` **不等于**插件可用。可靠的验证顺序: 1. `cordis_inspect_query` → `provider: Tool`、`method: listTools`,看工具名在不在。 2. **更干净的做法**:起一个 subagent 当探针,让它报告自己看到的工具名。 新会话的工具体系是按当前注册表重新组合的,能真实反映"Agent 能不能调用"。 3. 最后才是端到端跑一遍真实功能。