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
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
5 changes: 3 additions & 2 deletions docs/apemind-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 优先,其次读
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 配置与用户自己的配置文件互不覆盖。

Expand Down Expand Up @@ -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 加自己的工具链。
Expand Down
5 changes: 4 additions & 1 deletion docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

```
/usr/local/bin/apemind 镜像内置 CLI(构建时锁版本 + sha256),所有实例进程经 PATH 共用
/opt/dsh-seed/.dsh 镜像内置 web profile seed(含锁版本 @xmanrui/dsh-im),只读,不进 PVC
/data/users/<instance_key>/ HOME,0700(防跨租户遍历,与 uid 隔离无关,恒开)
workspace/ dsh 进程的 cwd;agent 读写的文件都在这里
.dsh/ DSH_HOME:dsh 自己的会话、缓存、settings
Expand All @@ -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 重启都保留;只在显式删除实例时销毁 |
Expand Down Expand Up @@ -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。

Expand Down
6 changes: 6 additions & 0 deletions host-agent/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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",
}
}
197 changes: 197 additions & 0 deletions host-agent/src/dsh-im.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
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<boolean> {
try {
await fsp.access(target)
return true
} catch {
return false
}
}

async function readJson(target: string): Promise<unknown> {
return JSON.parse(await fsp.readFile(target, "utf8"))
}

async function copyMissing(src: string, dest: string): Promise<boolean> {
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<number> {
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<EnsureDshImResult> {
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" }
}
16 changes: 13 additions & 3 deletions host-agent/src/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, string>): 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:",
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading