Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,25 @@ 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
# Official web plugins, 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
ARG DSH_AUTOMATION_VERSION=0.1.25
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} \
dsh plugin --profile web add -w --save-exact --registry=https://registry.npmjs.org/ \
@xmanrui/dsh-im@${DSH_IM_VERSION} \
&& HOME=/opt/dsh-seed DSH_HOME=/opt/dsh-seed/.dsh \
PNPM_STORE_DIR=/tmp/pnpm-store \
dsh plugin --profile web add -w --save-exact --registry=https://registry.npmjs.org/ \
@michengai/dsh-automation@${DSH_AUTOMATION_VERSION} \
&& test -f /opt/dsh-seed/.dsh/profiles/web/node_modules/@xmanrui/dsh-im/package.json \
&& test -f /opt/dsh-seed/.dsh/profiles/web/node_modules/@michengai/dsh-automation/package.json \
&& grep -q "@xmanrui/dsh-im" /opt/dsh-seed/.dsh/profiles/web/package.json \
&& grep -q "@michengai/dsh-automation" /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

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +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` 需要它);官方 IM 插件 `@xmanrui/dsh-im` 按 `DSH_IM_VERSION`
放到 PATH(`dsh plugin` 需要它);官方 IM 插件 `@xmanrui/dsh-im` 和定时任务插件
`@michengai/dsh-automation` 按 `DSH_IM_VERSION` / `DSH_AUTOMATION_VERSION`
预装进 `/opt/dsh-seed/.dsh`,host-agent 拉起实例时写入租户 web profile;
`apemind` CLI 同样构建时锁版本 + sha256,装到 `/usr/local/bin/apemind`
(运行期零下载)。升级 dsh、pnpm、dsh-im 或 CLI 一律走新镜像 tag 加回归验证。
(运行期零下载)。升级 dsh、pnpm、默认插件或 CLI 一律走新镜像 tag 加回归验证。
租户 HOME 与 CLI 身份注入见 [docs/lifecycle.md](docs/lifecycle.md) §1。

当前发布 tag 是 `v0.2.8`。离线机先在联网环境导出镜像再 `docker load`:
Expand Down
4 changes: 2 additions & 2 deletions docs/apemind-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ CLI,对 agent 就是「原生能力」——不占上下文预算(不像 MCP
自定义 dsh plugin 则相反:dsh 处于 developer preview,plugin API 随版本漂移,
每次升级锁定版本都要回归;能力面要一个个做成 plugin UI 才有价值,维护成本随
覆盖面线性增长。托管形态从第一天就坚持「零自研 plugin 代码」,本方案维持这个决策。
IM 渠道用上游 `@xmanrui/dsh-im`(镜像锁版本,不是本仓代码)。将来若确需
dsh 界面级集成(例如侧栏里的知识库选择器),再单独评估。
IM 渠道用上游 `@xmanrui/dsh-im`,定时任务用上游 `@michengai/dsh-automation`
(镜像锁版本,不是本仓代码)。将来若确需 dsh 界面级集成(例如侧栏里的知识库选择器),再单独评估。

apemind CLI 现状已经具备关键性质,**不需要重写**:Go 单二进制、零运行时依赖、
`APEMIND_BASE_URL` + `APEMIND_API_KEY` 的 Bearer 认证(env 优先,其次读
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,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。
- 定时任务 → 上游 npm 包 `@michengai/dsh-automation`,镜像按 `DSH_AUTOMATION_VERSION` 预装,同一条 seed 写入路径。
- 模型接入 → 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 配置与用户自己的配置文件互不覆盖。

Expand Down Expand Up @@ -167,7 +168,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` 的官方安装器);构建时把锁版本的 `@xmanrui/dsh-im` 装进 `/opt/dsh-seed/.dsh`;构建时锁版本 + 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` 和 `@michengai/dsh-automation` 装进 `/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 加自己的工具链。
Expand Down
4 changes: 2 additions & 2 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

```
/usr/local/bin/apemind 镜像内置 CLI(构建时锁版本 + sha256),所有实例进程经 PATH 共用
/opt/dsh-seed/.dsh 镜像内置 web profile seed(含锁版本 @xmanrui/dsh-im),只读,不进 PVC
/opt/dsh-seed/.dsh 镜像内置 web profile seed(含锁版本 @xmanrui/dsh-im 与 @michengai/dsh-automation),只读,不进 PVC
/data/users/<instance_key>/ HOME,0700(防跨租户遍历,与 uid 隔离无关,恒开)
workspace/ dsh 进程的 cwd;agent 读写的文件都在这里
.dsh/ DSH_HOME:dsh 自己的会话、缓存、settings
Expand All @@ -37,7 +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`;已有版本和其它插件不覆盖 |
| `$DSH_HOME/profiles/web` 里的默认插件 | 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 重启都保留;只在显式删除实例时销毁 |
Expand Down
4 changes: 2 additions & 2 deletions host-agent/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export interface Config {
/** 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.
* Seed $DSH_HOME that already has the default web plugins installed. Empty
* disables seeding. The image writes this under /opt/dsh-seed/.dsh.
*/
dshImSeed: string
version: string
Expand Down
76 changes: 50 additions & 26 deletions host-agent/src/dsh-im.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as fsp from "node:fs/promises"
import * as path from "node:path"

/** Official npm package installed into every hosted web profile. */
/** Official npm packages installed into every hosted web profile. */
export const DSH_IM_PACKAGE = "@xmanrui/dsh-im"
export const DSH_AUTOMATION_PACKAGE = "@michengai/dsh-automation"

const WEB_REL = path.join("profiles", "web")

Expand Down Expand Up @@ -33,21 +34,30 @@ 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(
/** Add every extra plugin from the seed profile. Existing pins stay. */
export function mergeSeedPlugins(
pkg: WebProfilePackage,
version: string,
seedPkg: WebProfilePackage,
): { 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
for (const [name, version] of Object.entries(seedPkg.dependencies ?? {})) {
if (typeof version !== "string" || version.trim() === "") continue
if (dependencies[name] === undefined) {
dependencies[name] = version
changed = true
}
if (!bundles.includes(name)) {
bundles.push(name)
changed = true
}
}
if (!bundles.includes(DSH_IM_PACKAGE)) {
bundles.push(DSH_IM_PACKAGE)
changed = true
for (const name of seedPkg.dsh?.profile?.bundles ?? []) {
if (!bundles.includes(name)) {
bundles.push(name)
changed = true
}
}
const next: WebProfilePackage = {
...pkg,
Expand All @@ -63,6 +73,22 @@ export function mergeDshImBundle(
return { next, changed }
}

/** 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 } {
return mergeSeedPlugins(pkg, { dependencies: { [DSH_IM_PACKAGE]: version } })
}

export function seedExtraPins(seedPkg: WebProfilePackage): Array<{ name: string; version: string }> {
const pins: Array<{ name: string; version: string }> = []
for (const [name, version] of Object.entries(seedPkg.dependencies ?? {})) {
if (typeof version === "string" && version.trim() !== "") pins.push({ name, version })
}
return pins
}

export function mergeWorkspaceExclude(text: string, spec: string): string {
const quoted = `'${spec}'`
if (text.includes(quoted) || text.includes(spec)) return text
Expand Down Expand Up @@ -126,7 +152,7 @@ export interface EnsureDshImResult {
}

/**
* Make sure a tenant web profile lists and can load the baked IM plugin.
* Make sure a tenant web profile lists and can load every baked default plugin.
* Missing seed (dev / unit tests) is a no-op. Existing extra plugins stay.
*/
export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string): Promise<EnsureDshImResult> {
Expand All @@ -140,9 +166,9 @@ export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string):
} 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 pins = seedExtraPins(seedPkg)
if (pins.length === 0) {
return { applied: false, reason: "seed has no extra plugins" }
}

const web = tenantWebDir(dshHome)
Expand All @@ -156,7 +182,7 @@ export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string):
pkg = EMPTY_WEB_PROFILE
}
}
const merged = mergeDshImBundle(pkg, version)
const merged = mergeSeedPlugins(pkg, seedPkg)
let changed = merged.changed || !(await pathExists(pkgPath))
if (changed) {
await fsp.writeFile(pkgPath, `${JSON.stringify(merged.next, null, 2)}\n`, { mode: 0o644 })
Expand All @@ -165,17 +191,15 @@ export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string):
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 })
let workspace = (await pathExists(tenantWs))
? await fsp.readFile(tenantWs, "utf8")
: await fsp.readFile(seedWs, "utf8")
const before = workspace
for (const pin of pins) {
workspace = mergeWorkspaceExclude(workspace, `${pin.name}@${pin.version}`)
}
if (workspace !== before || !(await pathExists(tenantWs))) {
await fsp.writeFile(tenantWs, workspace.endsWith("\n") ? workspace : `${workspace}\n`, { mode: 0o644 })
changed = true
}
}
Expand Down
2 changes: 1 addition & 1 deletion host-agent/src/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ export class Supervisor {
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 (seeded.applied) log.info("default plugins 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)
Expand Down
38 changes: 36 additions & 2 deletions host-agent/test/dsh-im.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import * as os from "node:os"
import * as path from "node:path"
import { test } from "node:test"
import {
DSH_AUTOMATION_PACKAGE,
DSH_IM_PACKAGE,
EMPTY_WEB_PROFILE,
ensureDefaultDshIm,
mergeDshImBundle,
mergeSeedPlugins,
mergeWorkspaceExclude,
} from "../src/dsh-im.ts"

Expand All @@ -32,6 +34,25 @@ test("merge adds the pin and bundle without dropping other plugins", () => {
])
})

test("mergeSeedPlugins adds every seed extra without dropping others", () => {
const { next, changed } = mergeSeedPlugins(
{
...EMPTY_WEB_PROFILE,
dependencies: { leftover: "1.0.0" },
dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] } },
},
{
dependencies: { [DSH_IM_PACKAGE]: "4.8.0", [DSH_AUTOMATION_PACKAGE]: "0.1.25" },
},
)
assert.equal(changed, true)
assert.equal(next.dependencies?.[DSH_IM_PACKAGE], "4.8.0")
assert.equal(next.dependencies?.[DSH_AUTOMATION_PACKAGE], "0.1.25")
assert.equal(next.dependencies?.leftover, "1.0.0")
assert.ok(next.dsh?.profile?.bundles?.includes(DSH_IM_PACKAGE))
assert.ok(next.dsh?.profile?.bundles?.includes(DSH_AUTOMATION_PACKAGE))
})

test("merge keeps a user-chosen dsh-im version", () => {
const { next, changed } = mergeDshImBundle(
{
Expand Down Expand Up @@ -69,12 +90,17 @@ test("ensure writes the profile and copies only missing packages", async () => {
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, "@michengai", "dsh-automation"), { 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, "@michengai", "dsh-automation", "index.js"), "export default 3\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`,
`${JSON.stringify({
name: "dsh-profile-web",
dependencies: { [DSH_IM_PACKAGE]: "4.8.0", [DSH_AUTOMATION_PACKAGE]: "0.1.25" },
}, null, 2)}\n`,
)
await fsp.writeFile(
path.join(seedWeb, "pnpm-workspace.yaml"),
Expand All @@ -98,11 +124,19 @@ test("ensure writes the profile and copies only missing packages", async () => {
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[DSH_AUTOMATION_PACKAGE], "0.1.25")
assert.equal(pkg.dependencies.leftover, "1.0.0")
assert.ok(pkg.dsh.profile.bundles.includes(DSH_IM_PACKAGE))
assert.ok(pkg.dsh.profile.bundles.includes(DSH_AUTOMATION_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/)
assert.equal(
fs.readFileSync(path.join(tenantNm, "@michengai", "dsh-automation", "index.js"), "utf8"),
"export default 3\n",
)
const workspace = fs.readFileSync(path.join(tenant, "profiles", "web", "pnpm-workspace.yaml"), "utf8")
assert.match(workspace, /dsh-im@4\.8\.0/)
assert.match(workspace, /dsh-automation@0\.1\.25/)
} finally {
await fsp.rm(dir, { recursive: true, force: true })
}
Expand Down
Loading
Loading