From d8151d4642b9e984712864fed3c1c6b23799f114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=81=E5=85=89=E8=BE=89?= Date: Thu, 3 Sep 2026 00:55:01 +0800 Subject: [PATCH] =?UTF-8?q?=E9=95=9C=E5=83=8F=E9=BB=98=E8=AE=A4=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E9=94=81=E7=89=88=E6=9C=AC=E7=9A=84=20dsh-im=20IM=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 托管实例打开后应直接有「IM机器人」,不必再跑 dsh plugin add。 构建时用官方安装器把 @xmanrui/dsh-im 钉进 seed,启动时只补进租户 web profile,已有插件和用户自己钉的版本不覆盖。运行期不访问 npm。 Closes #63 Co-authored-by: Cursor --- Dockerfile | 14 ++ README.md | 8 +- docs/apemind-integration.md | 5 +- docs/architecture.md | 3 +- docs/lifecycle.md | 5 +- host-agent/src/config.ts | 6 + host-agent/src/dsh-im.ts | 197 +++++++++++++++++++++++++++++ host-agent/src/supervisor.ts | 16 ++- host-agent/test/dsh-im.test.ts | 109 ++++++++++++++++ host-agent/test/supervisor.test.ts | 40 +++++- 10 files changed, 392 insertions(+), 11 deletions(-) create mode 100644 host-agent/src/dsh-im.ts create mode 100644 host-agent/test/dsh-im.test.ts diff --git a/Dockerfile b/Dockerfile index 92e13a4..1c48381 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,20 @@ RUN npm install -g @deepseek-ai/dsh@${DSH_VERSION} && npm cache clean --force ARG PNPM_VERSION=11.25.0 RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate && pnpm --version +# Official IM plugin, pinned. host-agent copies this seed into each tenant +# $DSH_HOME so instances do not download from npm at start. +ARG DSH_IM_VERSION=4.8.0 +RUN mkdir -p /opt/dsh-seed \ + && HOME=/opt/dsh-seed DSH_HOME=/opt/dsh-seed/.dsh \ + dsh --profile web --dump-default-config >/dev/null \ + && HOME=/opt/dsh-seed DSH_HOME=/opt/dsh-seed/.dsh \ + PNPM_STORE_DIR=/tmp/pnpm-store \ + dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@${DSH_IM_VERSION} \ + && test -f /opt/dsh-seed/.dsh/profiles/web/node_modules/@xmanrui/dsh-im/package.json \ + && grep -q "@xmanrui/dsh-im" /opt/dsh-seed/.dsh/profiles/web/package.json \ + && rm -rf /tmp/pnpm-store /opt/dsh-seed/.local /opt/dsh-seed/Library \ + && chmod -R a+rX /opt/dsh-seed/.dsh + # apemind CLI is baked in (no runtime download): pinned version, pinned # per-arch sha256, fetched from the public immutable release route. ARG TARGETARCH diff --git a/README.md b/README.md index 5a68b12..2659d74 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,11 @@ python3 scripts/density.py --cleanup-only --url http://127.0.0.1:9090 镜像只在 GitHub Actions 构建(推 tag `v*.*.*` 触发),不在本地构建。dsh 版本在 Dockerfile 的 `DSH_VERSION` 中锁定;`pnpm` 由 `corepack` 按 `PNPM_VERSION` 钉死并 -放到 PATH(`dsh plugin` 需要它);`apemind` CLI 同样构建时锁版本 + sha256, -装到 `/usr/local/bin/apemind`(运行期零下载)。升级 dsh、pnpm 或 CLI 一律走新镜像 -tag 加回归验证。租户 HOME 与 CLI 身份注入见 [docs/lifecycle.md](docs/lifecycle.md) §1。 +放到 PATH(`dsh plugin` 需要它);官方 IM 插件 `@xmanrui/dsh-im` 按 `DSH_IM_VERSION` +预装进 `/opt/dsh-seed/.dsh`,host-agent 拉起实例时写入租户 web profile; +`apemind` CLI 同样构建时锁版本 + sha256,装到 `/usr/local/bin/apemind` +(运行期零下载)。升级 dsh、pnpm、dsh-im 或 CLI 一律走新镜像 tag 加回归验证。 +租户 HOME 与 CLI 身份注入见 [docs/lifecycle.md](docs/lifecycle.md) §1。 当前发布 tag 是 `v0.2.8`。离线机先在联网环境导出镜像再 `docker load`: diff --git a/docs/apemind-integration.md b/docs/apemind-integration.md index 94bca30..3a8ad0d 100644 --- a/docs/apemind-integration.md +++ b/docs/apemind-integration.md @@ -52,8 +52,9 @@ CLI,对 agent 就是「原生能力」——不占上下文预算(不像 MCP 自定义 dsh plugin 则相反:dsh 处于 developer preview,plugin API 随版本漂移, 每次升级锁定版本都要回归;能力面要一个个做成 plugin UI 才有价值,维护成本随 -覆盖面线性增长。托管形态从第一天就坚持「零 plugin 代码」,本方案维持这个决策。 -将来若确需 dsh 界面级集成(例如侧栏里的知识库选择器),再单独评估。 +覆盖面线性增长。托管形态从第一天就坚持「零自研 plugin 代码」,本方案维持这个决策。 +IM 渠道用上游 `@xmanrui/dsh-im`(镜像锁版本,不是本仓代码)。将来若确需 +dsh 界面级集成(例如侧栏里的知识库选择器),再单独评估。 apemind CLI 现状已经具备关键性质,**不需要重写**:Go 单二进制、零运行时依赖、 `APEMIND_BASE_URL` + `APEMIND_API_KEY` 的 Bearer 认证(env 优先,其次读 diff --git a/docs/architecture.md b/docs/architecture.md index 8f79eb3..20a4d94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,6 +92,7 @@ flowchart TB - 身份/访问控制 → 网关在 dsh 进程外完成,dsh 零登录。 - 知识库/ApeMind 能力 → 官方 `@deepseek-ai/dsh-mcp-client` 插件(streamable-http + Bearer header),纯配置。 +- IM 机器人 → 上游 npm 包 `@xmanrui/dsh-im`,镜像按 `DSH_IM_VERSION` 预装,host-agent 写入每个租户的 web profile;不写自研 plugin。 - 模型接入 → ApeMind 把工作区可用的 chat 模型投影成托管 provider 块(`llm-pi-ai.providers.apemind`,`baseURL` 指 ApeMind 的 OpenAI 兼容网关,Bearer 用托管 key;env 契约与渲染细节见 [lifecycle.md](lifecycle.md) §3.2);BYOK 用户仍可自填官方 provider 配置。 - 托管配置注入 → `dsh web --patch`:官方 patch overlay,managed 配置与用户自己的配置文件互不覆盖。 @@ -166,7 +167,7 @@ sequenceDiagram AIO 底座(Xvfb/Chromium/VNC/noVNC/supervisord/nginx/gem-server/tinyproxy/bubblewrap)整体弃用。托管 dsh WebUI 用不到桌面沙箱,却带来体积、架构限制和多余攻击面。若未来要浏览器自动化/桌面,另起独立镜像轨道。 -全新镜像(node:22-bookworm-slim,amd64+arm64):系统层提供租户 shell 环境与隔离工具;全局安装锁定版本的 `@deepseek-ai/dsh`;`corepack` 钉死 `pnpm` 并放到 PATH(`dsh plugin` 的官方安装器);构建时锁版本 + sha256 校验装入 `apemind` CLI(`/usr/local/bin/apemind`,运行期零下载);host-agent esbuild 单文件;`tini` 作 PID 1。暴露 8080/9090,数据卷 `/data`。 +全新镜像(node:22-bookworm-slim,amd64+arm64):系统层提供租户 shell 环境与隔离工具;全局安装锁定版本的 `@deepseek-ai/dsh`;`corepack` 钉死 `pnpm` 并放到 PATH(`dsh plugin` 的官方安装器);构建时把锁版本的 `@xmanrui/dsh-im` 装进 `/opt/dsh-seed/.dsh`;构建时锁版本 + sha256 校验装入 `apemind` CLI(`/usr/local/bin/apemind`,运行期零下载);host-agent esbuild 单文件;`tini` 作 PID 1。暴露 8080/9090,数据卷 `/data`。 - host-agent 以 root 运行(需要 setuid 切租户 uid 与 iptables);容器保持尽可能少的 capability,P2 回环隔离时加 `NET_ADMIN`。 - 私有化扩展点:客户 `FROM apecloud/apemind-computer` 再 apt 加自己的工具链。 diff --git a/docs/lifecycle.md b/docs/lifecycle.md index d1e0207..c34e25b 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -12,6 +12,7 @@ ``` /usr/local/bin/apemind 镜像内置 CLI(构建时锁版本 + sha256),所有实例进程经 PATH 共用 +/opt/dsh-seed/.dsh 镜像内置 web profile seed(含锁版本 @xmanrui/dsh-im),只读,不进 PVC /data/users// HOME,0700(防跨租户遍历,与 uid 隔离无关,恒开) workspace/ dsh 进程的 cwd;agent 读写的文件都在这里 .dsh/ DSH_HOME:dsh 自己的会话、缓存、settings @@ -36,6 +37,7 @@ | `/usr/local/bin/apemind` | 镜像构建 | 全租户共用只读二进制,不随 PVC、不随实例删除 | | `.apemind/` | 只有 host-agent(受控制面 ensure 驱动) | **投影**。权威在 ApeMind 数据库(绑定身份的 managed key、MCP 地址、模型清单);磁盘上这份只是启动进程所需的物化,删了可以从控制面重新生成 | | `.dsh/AGENTS.md` | 只有 host-agent | **托管引导**。权威是 `env.json`;每次 spawn 前重写。`.dsh/` 其余文件仍是 dsh 私有 | +| `$DSH_HOME/profiles/web` 里的 `@xmanrui/dsh-im` | host-agent 只补缺 | **默认 IM 插件**。权威是镜像 `/opt/dsh-seed/.dsh`;已有版本和其它插件不覆盖 | | `.config/apemind/` | 只有 host-agent | **CLI 凭证投影**。dsh 从 bash/工具子进程剥掉名字含 KEY、PASSWORD、SECRET、TOKEN 的环境变量,agent 跑 `apemind` 时读不到 `APEMIND_API_KEY`;这份 profile 是 CLI 的官方 env 回退。权威仍是 `env.json`,每次 ensure/spawn 覆盖 | | `.dsh/` 其余 | 只有 dsh 进程 | 上游运行时私有。ApeMind 不读它当配置源,也不把它回流主站 | | `workspace/` | 租户(经 dsh agent) | 用户磁盘。闲置回收、host 重启都保留;只在显式删除实例时销毁 | @@ -138,8 +140,9 @@ dsh {patch} --profile web --no-open --port {port} uid 隔离开启时以分配的 uid/gid 运行;stdout/stderr 进 `.apemind/dsh.log`。 -`managed.cordis.yml` 有两段内容,都按 env 是否齐全条件渲染: +`managed.cordis.yml` 有三段内容。IM 段始终写出;其余两段按 env 是否齐全条件渲染: +- **IM 机器人 RPC**(始终):给 `xmanrui-dsh-im` 写 `rpcAuthority: trusted-host`。网关把浏览器 Host 指到回环,面板域名要能管机器人。插件本体在镜像 seed,host-agent 拉起前写入 `$DSH_HOME/profiles/web`。 - **MCP 工具**(`APEMIND_MCP_URL` + `APEMIND_API_KEY`):插入官方 `@deepseek-ai/dsh-mcp-client` 插件行,streamable-http 指向 MCP 地址,Authorization 头用 `!!js` 从**进程环境**读 `APEMIND_API_KEY`。 - **模型提供方投影**(`APEMIND_LLM_BASE_URL` + `APEMIND_API_KEY` + 非空 `APEMIND_LLM_MODELS`):在 `llm-pi-ai` 行的 config 上合并一个名为 `apemind` 的 provider(`api: openai-completions`、`baseURL` 指 ApeMind 的 OpenAI 兼容网关、`apiKeyEnv: APEMIND_API_KEY`),模型列表来自 `APEMIND_LLM_MODELS`——一个 JSON 数组,元素 `{id, name?, context_window?, vision?}`。`id` 是 ApeMind 模型 id(dsh 发起补全时原样回传,网关按它解析上游);`name` 写成 dsh `PiAiModelProfile.name`(选择器文案),不是 provider 级的 `displayName`。JSON 非法或元素缺 `id` 时 ensure 直接失败(控制面 400),不写任何文件。每次拉起 dsh 都会按当时的 `env.json` 重写 patch,避免宿主升级后仍读到旧 yaml。 diff --git a/host-agent/src/config.ts b/host-agent/src/config.ts index 5a510c7..9daa719 100644 --- a/host-agent/src/config.ts +++ b/host-agent/src/config.ts @@ -25,6 +25,11 @@ export interface Config { loopbackIsolation: boolean /** Optional helper that sets PR_SET_PDEATHSIG + a new process group before exec. */ dshExec: string + /** + * Seed $DSH_HOME that already has @xmanrui/dsh-im installed. Empty disables + * the default IM plugin. The image writes this under /opt/dsh-seed/.dsh. + */ + dshImSeed: string version: string } @@ -64,6 +69,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { uidBase: intEnv(env, "COMPUTER_UID_BASE", 0), loopbackIsolation: (env.COMPUTER_LOOPBACK_ISOLATION ?? "").trim() === "1", dshExec: (env.COMPUTER_DSH_EXEC ?? "/usr/local/bin/dsh-exec").trim(), + dshImSeed: (env.COMPUTER_DSH_IM_SEED ?? "/opt/dsh-seed/.dsh").trim(), version: env.COMPUTER_VERSION ?? "dev", } } diff --git a/host-agent/src/dsh-im.ts b/host-agent/src/dsh-im.ts new file mode 100644 index 0000000..6e9ff90 --- /dev/null +++ b/host-agent/src/dsh-im.ts @@ -0,0 +1,197 @@ +import * as fsp from "node:fs/promises" +import * as path from "node:path" + +/** Official npm package installed into every hosted web profile. */ +export const DSH_IM_PACKAGE = "@xmanrui/dsh-im" + +const WEB_REL = path.join("profiles", "web") + +export interface WebProfilePackage { + name?: string + private?: boolean + dependencies?: Record + dsh?: { profile?: { bundles?: string[] } } + [key: string]: unknown +} + +export const EMPTY_WEB_PROFILE: WebProfilePackage = { + name: "dsh-profile-web", + private: true, + dependencies: {}, + dsh: { + profile: { + bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"], + }, + }, +} + +export function seedWebDir(seedDshHome: string): string { + return path.join(seedDshHome, WEB_REL) +} + +export function tenantWebDir(dshHome: string): string { + return path.join(dshHome, WEB_REL) +} + +/** Add the pinned IM plugin without replacing other deps or a user-chosen version. */ +export function mergeDshImBundle( + pkg: WebProfilePackage, + version: string, +): { next: WebProfilePackage; changed: boolean } { + const dependencies = { ...(pkg.dependencies ?? {}) } + const bundles = [...(pkg.dsh?.profile?.bundles ?? EMPTY_WEB_PROFILE.dsh!.profile!.bundles!)] + let changed = false + if (dependencies[DSH_IM_PACKAGE] === undefined) { + dependencies[DSH_IM_PACKAGE] = version + changed = true + } + if (!bundles.includes(DSH_IM_PACKAGE)) { + bundles.push(DSH_IM_PACKAGE) + changed = true + } + const next: WebProfilePackage = { + ...pkg, + dependencies, + dsh: { + ...pkg.dsh, + profile: { + ...pkg.dsh?.profile, + bundles, + }, + }, + } + return { next, changed } +} + +export function mergeWorkspaceExclude(text: string, spec: string): string { + const quoted = `'${spec}'` + if (text.includes(quoted) || text.includes(spec)) return text + if (/minimumReleaseAgeExclude:\s*$/m.test(text) || /minimumReleaseAgeExclude:\s*\n/.test(text)) { + return text.replace(/minimumReleaseAgeExclude:\s*\n/, `minimumReleaseAgeExclude:\n - ${quoted}\n`) + } + const suffix = text.endsWith("\n") ? "" : "\n" + return `${text}${suffix}minimumReleaseAgeExclude:\n - ${quoted}\n` +} + +async function pathExists(target: string): Promise { + try { + await fsp.access(target) + return true + } catch { + return false + } +} + +async function readJson(target: string): Promise { + return JSON.parse(await fsp.readFile(target, "utf8")) +} + +async function copyMissing(src: string, dest: string): Promise { + if (await pathExists(dest)) return false + await fsp.mkdir(path.dirname(dest), { recursive: true }) + await fsp.cp(src, dest, { recursive: true }) + return true +} + +async function overlayNodeModules(seedNm: string, tenantNm: string): Promise { + if (!(await pathExists(seedNm))) return 0 + await fsp.mkdir(tenantNm, { recursive: true }) + let copied = 0 + for (const entry of await fsp.readdir(seedNm, { withFileTypes: true })) { + if (entry.name === ".pnpm") continue + const src = path.join(seedNm, entry.name) + const dest = path.join(tenantNm, entry.name) + if (entry.name.startsWith("@") && entry.isDirectory()) { + await fsp.mkdir(dest, { recursive: true }) + for (const scoped of await fsp.readdir(src)) { + if (await copyMissing(path.join(src, scoped), path.join(dest, scoped))) copied += 1 + } + continue + } + if (entry.name === ".bin" && entry.isDirectory()) { + await fsp.mkdir(dest, { recursive: true }) + for (const bin of await fsp.readdir(src)) { + if (await copyMissing(path.join(src, bin), path.join(dest, bin))) copied += 1 + } + continue + } + if (await copyMissing(src, dest)) copied += 1 + } + return copied +} + +export interface EnsureDshImResult { + applied: boolean + reason: string +} + +/** + * Make sure a tenant web profile lists and can load the baked IM plugin. + * Missing seed (dev / unit tests) is a no-op. Existing extra plugins stay. + */ +export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string): Promise { + const seed = seedDshHome.trim() + if (seed === "") return { applied: false, reason: "seed disabled" } + const seedPkgPath = path.join(seedWebDir(seed), "package.json") + if (!(await pathExists(seedPkgPath))) return { applied: false, reason: "seed missing" } + let seedPkg: WebProfilePackage + try { + seedPkg = (await readJson(seedPkgPath)) as WebProfilePackage + } catch { + return { applied: false, reason: "seed package unreadable" } + } + const version = seedPkg.dependencies?.[DSH_IM_PACKAGE] + if (typeof version !== "string" || version.trim() === "") { + return { applied: false, reason: "seed has no dsh-im pin" } + } + + const web = tenantWebDir(dshHome) + await fsp.mkdir(web, { recursive: true }) + const pkgPath = path.join(web, "package.json") + let pkg: WebProfilePackage = EMPTY_WEB_PROFILE + if (await pathExists(pkgPath)) { + try { + pkg = (await readJson(pkgPath)) as WebProfilePackage + } catch { + pkg = EMPTY_WEB_PROFILE + } + } + const merged = mergeDshImBundle(pkg, version) + let changed = merged.changed || !(await pathExists(pkgPath)) + if (changed) { + await fsp.writeFile(pkgPath, `${JSON.stringify(merged.next, null, 2)}\n`, { mode: 0o644 }) + } + + const seedWs = path.join(seedWebDir(seed), "pnpm-workspace.yaml") + const tenantWs = path.join(web, "pnpm-workspace.yaml") + if (await pathExists(seedWs)) { + const spec = `${DSH_IM_PACKAGE}@${version}` + if (await pathExists(tenantWs)) { + const current = await fsp.readFile(tenantWs, "utf8") + const next = mergeWorkspaceExclude(current, spec) + if (next !== current) { + await fsp.writeFile(tenantWs, next.endsWith("\n") ? next : `${next}\n`, { mode: 0o644 }) + changed = true + } + } else { + const seedText = await fsp.readFile(seedWs, "utf8") + await fsp.writeFile(tenantWs, mergeWorkspaceExclude(seedText, spec), { mode: 0o644 }) + changed = true + } + } + + for (const name of ["cordis.yml", "cordis.patch.yml"] as const) { + const dest = path.join(web, name) + const src = path.join(seedWebDir(seed), name) + if (!(await pathExists(dest)) && (await pathExists(src))) { + await fsp.copyFile(src, dest) + changed = true + } + } + + const copied = await overlayNodeModules( + path.join(seedWebDir(seed), "node_modules"), + path.join(web, "node_modules"), + ) + return { applied: changed || copied > 0, reason: "ok" } +} diff --git a/host-agent/src/supervisor.ts b/host-agent/src/supervisor.ts index 33e921e..e317344 100644 --- a/host-agent/src/supervisor.ts +++ b/host-agent/src/supervisor.ts @@ -6,6 +6,7 @@ import * as net from "node:net" import * as path from "node:path" import { CgroupManager } from "./cgroup.ts" import type { Config } from "./config.ts" +import { ensureDefaultDshIm } from "./dsh-im.ts" import type { HostSettingsStore } from "./settings.ts" import { log } from "./log.ts" import { USER_ID_RE } from "./ticket.ts" @@ -142,10 +143,15 @@ function renderModelProviderLines(baseUrl: string, models: ManagedModel[]): stri return lines } -/** Managed cordis patch: ApeMind MCP tools plus the projected model provider. - * Secrets stay in the process env; the patch only carries env var names. */ +/** Managed cordis patch: IM plugin RPC authority, ApeMind MCP tools, and the + * projected model provider. Secrets stay in the process env; the patch only + * carries env var names. */ function renderManagedPatch(env: Record): string | undefined { - const sections: string[] = [] + const sections: string[] = [ + "- id: xmanrui-dsh-im", + " config:", + " rpcAuthority: trusted-host", + ] if (env.APEMIND_MCP_URL && env.APEMIND_API_KEY) { sections.push( "- insert:", @@ -505,8 +511,12 @@ export class Supervisor { const home = this.homeDir(inst.userId) const extraEnv = await this.readInstanceEnv(inst) await this.syncManagedFiles(inst, extraEnv) + const dshHome = path.join(home, ".dsh") + const seeded = await ensureDefaultDshIm(dshHome, this.cfg.dshImSeed) + if (seeded.applied) log.info("default dsh-im seeded", { user: inst.userId }) if (inst.meta.uid !== undefined) { await chownTree(path.join(this.homeDir(inst.userId), ".apemind"), inst.meta.uid, inst.meta.uid) + if (seeded.applied) await chownTree(path.join(dshHome, "profiles"), inst.meta.uid, inst.meta.uid) } const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, diff --git a/host-agent/test/dsh-im.test.ts b/host-agent/test/dsh-im.test.ts new file mode 100644 index 0000000..54c4a07 --- /dev/null +++ b/host-agent/test/dsh-im.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict" +import * as fs from "node:fs" +import * as fsp from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { test } from "node:test" +import { + DSH_IM_PACKAGE, + EMPTY_WEB_PROFILE, + ensureDefaultDshIm, + mergeDshImBundle, + mergeWorkspaceExclude, +} from "../src/dsh-im.ts" + +test("merge adds the pin and bundle without dropping other plugins", () => { + const { next, changed } = mergeDshImBundle( + { + ...EMPTY_WEB_PROFILE, + dependencies: { "dsh-auth-everying": "github:example/auth" }, + dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-auth-everying"] } }, + }, + "4.8.0", + ) + assert.equal(changed, true) + assert.equal(next.dependencies?.[DSH_IM_PACKAGE], "4.8.0") + assert.equal(next.dependencies?.["dsh-auth-everying"], "github:example/auth") + assert.deepEqual(next.dsh?.profile?.bundles, [ + "@deepseek-ai/dsh-base", + "@deepseek-ai/dsh-web-app", + "dsh-auth-everying", + DSH_IM_PACKAGE, + ]) +}) + +test("merge keeps a user-chosen dsh-im version", () => { + const { next, changed } = mergeDshImBundle( + { + dependencies: { [DSH_IM_PACKAGE]: "4.7.0" }, + dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", DSH_IM_PACKAGE] } }, + }, + "4.8.0", + ) + assert.equal(changed, false) + assert.equal(next.dependencies?.[DSH_IM_PACKAGE], "4.7.0") +}) + +test("workspace exclude is appended once", () => { + const first = mergeWorkspaceExclude("packages:\n - .\n", "@xmanrui/dsh-im@4.8.0") + assert.match(first, /minimumReleaseAgeExclude:\n - '@xmanrui\/dsh-im@4\.8\.0'/) + const second = mergeWorkspaceExclude(first, "@xmanrui/dsh-im@4.8.0") + assert.equal(second, first) +}) + +test("ensure is a no-op when the seed is missing", async () => { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "dsh-im-none-")) + try { + const result = await ensureDefaultDshIm(path.join(dir, "tenant"), path.join(dir, "missing-seed")) + assert.equal(result.applied, false) + assert.equal(result.reason, "seed missing") + assert.equal(fs.existsSync(path.join(dir, "tenant", "profiles")), false) + } finally { + await fsp.rm(dir, { recursive: true, force: true }) + } +}) + +test("ensure writes the profile and copies only missing packages", async () => { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "dsh-im-seed-")) + try { + const seedWeb = path.join(dir, "seed", "profiles", "web") + const seedNm = path.join(seedWeb, "node_modules") + await fsp.mkdir(path.join(seedNm, "@xmanrui", "dsh-im"), { recursive: true }) + await fsp.mkdir(path.join(seedNm, "undici"), { recursive: true }) + await fsp.writeFile(path.join(seedNm, "@xmanrui", "dsh-im", "index.js"), "export default 1\n") + await fsp.writeFile(path.join(seedNm, "undici", "index.js"), "export default 2\n") + await fsp.writeFile( + path.join(seedWeb, "package.json"), + `${JSON.stringify({ name: "dsh-profile-web", dependencies: { [DSH_IM_PACKAGE]: "4.8.0" } }, null, 2)}\n`, + ) + await fsp.writeFile( + path.join(seedWeb, "pnpm-workspace.yaml"), + "packages:\n - .\n\nnodeLinker: hoisted\n", + ) + + const tenant = path.join(dir, "tenant") + const tenantNm = path.join(tenant, "profiles", "web", "node_modules") + await fsp.mkdir(path.join(tenantNm, "undici"), { recursive: true }) + await fsp.writeFile(path.join(tenantNm, "undici", "index.js"), "keep-me\n") + await fsp.writeFile( + path.join(tenant, "profiles", "web", "package.json"), + `${JSON.stringify({ + name: "dsh-profile-web", + dependencies: { leftover: "1.0.0" }, + dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] } }, + }, null, 2)}\n`, + ) + + const result = await ensureDefaultDshIm(tenant, path.join(dir, "seed")) + assert.equal(result.applied, true) + const pkg = JSON.parse(fs.readFileSync(path.join(tenant, "profiles", "web", "package.json"), "utf8")) + assert.equal(pkg.dependencies[DSH_IM_PACKAGE], "4.8.0") + assert.equal(pkg.dependencies.leftover, "1.0.0") + assert.ok(pkg.dsh.profile.bundles.includes(DSH_IM_PACKAGE)) + assert.equal(fs.readFileSync(path.join(tenantNm, "undici", "index.js"), "utf8"), "keep-me\n") + assert.equal(fs.readFileSync(path.join(tenantNm, "@xmanrui", "dsh-im", "index.js"), "utf8"), "export default 1\n") + assert.match(fs.readFileSync(path.join(tenant, "profiles", "web", "pnpm-workspace.yaml"), "utf8"), /dsh-im@4\.8\.0/) + } finally { + await fsp.rm(dir, { recursive: true, force: true }) + } +}) diff --git a/host-agent/test/supervisor.test.ts b/host-agent/test/supervisor.test.ts index 1ced903..03b624c 100644 --- a/host-agent/test/supervisor.test.ts +++ b/host-agent/test/supervisor.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" import * as fs from "node:fs" +import * as os from "node:os" import * as path from "node:path" import { test } from "node:test" import { CapacityError } from "../src/supervisor.ts" @@ -43,6 +44,8 @@ test("env with mcp settings renders the managed patch and passes --patch", async const home = path.join(env.cfg.dataDir, "users", "bob") const patch = fs.readFileSync(path.join(home, ".apemind", "managed.cordis.yml"), "utf8") assert.match(patch, /dsh-mcp-client/) + assert.match(patch, /id: xmanrui-dsh-im/) + assert.match(patch, /rpcAuthority: trusted-host/) assert.match(patch, /http:\/\/mcp\.test\/mcp/) assert.doesNotMatch(patch, /sk-test-123/, "the key must stay out of the patch file") const probe = JSON.parse(fs.readFileSync(path.join(home, ".apemind", "probe.json"), "utf8")) @@ -213,7 +216,10 @@ test("llm projection without models keeps the provider row out of the patch", as APEMIND_LLM_MODELS: "[]", }) const home = path.join(env.cfg.dataDir, "users", "iris") - assert.equal(fs.existsSync(path.join(home, ".apemind", "managed.cordis.yml")), false) + const patch = fs.readFileSync(path.join(home, ".apemind", "managed.cordis.yml"), "utf8") + assert.match(patch, /id: xmanrui-dsh-im/) + assert.doesNotMatch(patch, /llm-pi-ai/) + assert.doesNotMatch(patch, /dsh-mcp-client/) } finally { await env.cleanup() } @@ -309,3 +315,35 @@ test("state survives a supervisor restart via meta.json", async () => { await env.cleanup() } }) + +test("start copies the baked dsh-im plugin into the tenant web profile", async () => { + const seed = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-im-host-seed-")) + try { + const seedWeb = path.join(seed, "profiles", "web") + fs.mkdirSync(path.join(seedWeb, "node_modules", "@xmanrui", "dsh-im"), { recursive: true }) + fs.writeFileSync(path.join(seedWeb, "node_modules", "@xmanrui", "dsh-im", "index.js"), "ok\n") + fs.writeFileSync( + path.join(seedWeb, "package.json"), + `${JSON.stringify({ name: "dsh-profile-web", dependencies: { "@xmanrui/dsh-im": "4.8.0" } }, null, 2)}\n`, + ) + const env = await makeEnv({ COMPUTER_DSH_IM_SEED: seed }) + try { + await env.sup.ensure("mina", "running") + const pkgPath = path.join(env.cfg.dataDir, "users", "mina", ".dsh", "profiles", "web", "package.json") + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) + assert.equal(pkg.dependencies["@xmanrui/dsh-im"], "4.8.0") + assert.ok(pkg.dsh.profile.bundles.includes("@xmanrui/dsh-im")) + assert.equal( + fs.readFileSync( + path.join(env.cfg.dataDir, "users", "mina", ".dsh", "profiles", "web", "node_modules", "@xmanrui", "dsh-im", "index.js"), + "utf8", + ), + "ok\n", + ) + } finally { + await env.cleanup() + } + } finally { + fs.rmSync(seed, { recursive: true, force: true }) + } +})