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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ Dockerfile 的 `DSH_VERSION` 中锁定;`pnpm` 由 `corepack` 按 `PNPM_VERSION
(运行期零下载)。升级 dsh、pnpm、默认插件或 CLI 一律走新镜像 tag 加回归验证。
租户 HOME 与 CLI 身份注入见 [docs/lifecycle.md](docs/lifecycle.md) §1。

当前发布 tag 是 `v0.2.11`。离线机先在联网环境导出镜像再 `docker load`:
当前发布 tag 是 `v0.2.12`。离线机先在联网环境导出镜像再 `docker load`:

```bash
docker pull apecloud/apemind-computer:v0.2.11
docker save apecloud/apemind-computer:v0.2.11 -o apemind-computer-v0.2.11.tar
docker load -i apemind-computer-v0.2.11.tar
docker pull apecloud/apemind-computer:v0.2.12
docker save apecloud/apemind-computer:v0.2.12 -o apemind-computer-v0.2.12.tar
docker load -i apemind-computer-v0.2.12.tar
```

Compose 样例见 `compose.example.yml`。ApeMind 离线交付把本组件放在
Expand Down
2 changes: 1 addition & 1 deletion compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# (两边配同一个值)加一道门。
services:
computer-host:
image: apecloud/apemind-computer:v0.2.11
image: apecloud/apemind-computer:v0.2.12
restart: unless-stopped
environment:
# 浏览器访问网关用的对外地址(决定 Origin 校验与 cookie Secure 属性)
Expand Down
4 changes: 2 additions & 2 deletions deploy/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ apiVersion: v2
name: apemind-computer
description: Multi-tenant hosted dsh (computer-host) gateway, control API, and supervisor.
type: application
version: 0.2.11
appVersion: "v0.2.11"
version: 0.2.12
appVersion: "v0.2.12"
4 changes: 3 additions & 1 deletion docs/apemind-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
- **MCP 工具**:官方 `dsh-mcp-client` 插件指向 ApeMind 的 MCP 端点,带 Bearer key。
内置工具全部是知识读面:列集合/文档、读文档与分块、知识检索、图谱查询、网页读取等。
- **模型网关**:`llm-pi-ai` 上投影一个 `apemind` provider,`baseURL` 指 ApeMind 的
OpenAI 兼容网关,模型列表是绑定用户当时可用的 chat 模型。
OpenAI 兼容网关,模型列表是绑定用户当时可用的 chat 模型;工作区默认 chat 模型
会覆盖 dsh 的 `agent-default-model` 路由。
- **实例控制**:ApeMind 控制面负责 open/stop/状态与会话撤销,dsh 与宿主对此无感知。

没打通的是其余全部:agent 无法建知识库、上传文档、管理 Bot 与对话、查组织成员、
Expand Down Expand Up @@ -207,6 +208,7 @@ dsh 官方 `dsh-agent-instructions` 插件会自动加载 `$DSH_HOME/AGENTS.md`
| `APEMIND_API_KEY` | 已有 | 三通道共用凭证 |
| `APEMIND_MCP_URL` | 已有 | MCP 端点 |
| `APEMIND_LLM_BASE_URL` / `APEMIND_LLM_MODELS` | 已有 | 模型网关投影 |
| `APEMIND_LLM_DEFAULT_MODEL` | 新增 | 工作区默认 chat 模型 id;宿主用来覆盖 dsh `agent-default-model` |
| `APEMIND_BASE_URL` | 新增 | CLI / OpenAPI 主站地址 |
| `APEMIND_ORG_ID` | 新增,仅组织实例 | CLI 默认组织上下文;引导文件渲染 |

Expand Down
8 changes: 5 additions & 3 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,19 @@ dsh {patch} --profile web --no-open --port {port}
| `XDG_CONFIG_HOME` / `XDG_CACHE_HOME` / `XDG_DATA_HOME` | `$HOME/.config` / `.cache` / `.local/share` |
| `APEMIND_USER_ID` | 实例键(对 host 不透明的租户字符串)。**不是** ApeMind 用户 id |
| `APEMIND_INSTANCE_ID` | 与 `APEMIND_USER_ID` 相同的实例键;新代码读这个名字 |
| `env.json` 里的全部键值 | 控制面注入。当前契约:`APEMIND_API_KEY`(绑定身份托管 key)、`APEMIND_BASE_URL`(API 源站,不依赖 MCP)、`APEMIND_MCP_URL`、`APEMIND_LLM_BASE_URL`、`APEMIND_LLM_MODELS`;组织实例另有 `APEMIND_ORG_ID`;可选 `APEMIND_DATA_PLANE_USERNAME`。键名限 `^[A-Z][A-Z0-9_]{0,63}$` |
| `DSH_PERMISSION_MODE` | 托管实例拉起时写入 `danger-full-access`。这是 dsh 官方的进程级部署覆盖,新会话默认 Full access;用户之后在 Settings 改 `permission.defaultPreset` 仍作用于以后的会话。已有会话的权限钉死不变。控制面若在 `env.json` 显式给出该键,以其为准。 |
| `env.json` 里的全部键值 | 控制面注入。当前契约:`APEMIND_API_KEY`(绑定身份托管 key)、`APEMIND_BASE_URL`(API 源站,不依赖 MCP)、`APEMIND_MCP_URL`、`APEMIND_LLM_BASE_URL`、`APEMIND_LLM_MODELS`、可选 `APEMIND_LLM_DEFAULT_MODEL`(工作区默认 chat 模型 id,且必须出现在 `APEMIND_LLM_MODELS` 里);组织实例另有 `APEMIND_ORG_ID`;可选 `APEMIND_DATA_PLANE_USERNAME`。键名限 `^[A-Z][A-Z0-9_]{0,63}$` |

uid 隔离开启时以分配的 uid/gid 运行;stdout/stderr 进 `.apemind/dsh.log`。

`managed.cordis.yml` 有三段内容。IM 段始终写出;其余两段按 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。
- **默认模型路由**(`APEMIND_LLM_DEFAULT_MODEL` 且该 id 已在投影列表里):覆盖组合层 `agent-default-model` 为 `{provider: apemind, model: <id>}`,替换出厂的 `deepseek-official` / `deepseek-v4-flash`。用户之后在 dsh 里另选模型仍写 `$DSH_HOME/settings.yaml`,覆盖组合默认值。宿主不写 settings.yaml。

两段都只携带环境变量名,密钥不落在 yaml 里,文件泄露不等于密钥泄露(`env.json` 仍含密钥本体,0600 + uid 隔离保护)。patch 对 dsh 的实际生效行为按锁定的 dsh 版本在 staging 验收(与 MCP 行同一口径)。
这些段落都只携带环境变量名,密钥不落在 yaml 里,文件泄露不等于密钥泄露(`env.json` 仍含密钥本体,0600 + uid 隔离保护)。patch 对 dsh 的实际生效行为按锁定的 dsh 版本在 staging 验收(与 MCP 行同一口径)。

磁盘上有四份托管文件,权威如下:

Expand Down
17 changes: 16 additions & 1 deletion host-agent/src/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ function renderModelProviderLines(baseUrl: string, models: ManagedModel[]): stri
return lines
}

function renderDefaultModelLines(modelId: string): string[] {
return [
"- id: agent-default-model",
" config:",
" provider: apemind",
` model: ${yamlSingleQuote(modelId)}`,
]
}

/** 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. */
Expand All @@ -165,12 +174,17 @@ function renderManagedPatch(env: Record<string, string>): string | undefined {
" Authorization: !!js '`Bearer ${process.env.APEMIND_API_KEY}`'",
)
}
let models: ManagedModel[] = []
if (env.APEMIND_LLM_BASE_URL && env.APEMIND_API_KEY) {
const models = parseManagedModels(env.APEMIND_LLM_MODELS ?? "[]")
models = parseManagedModels(env.APEMIND_LLM_MODELS ?? "[]")
if (models.length > 0) {
sections.push(...renderModelProviderLines(env.APEMIND_LLM_BASE_URL, models))
}
}
const defaultModel = (env.APEMIND_LLM_DEFAULT_MODEL ?? "").trim()
if (defaultModel && models.some((model) => model.id === defaultModel)) {
sections.push(...renderDefaultModelLines(defaultModel))
}
if (sections.length === 0) return undefined
return `${sections.join("\n")}\n`
}
Expand Down Expand Up @@ -568,6 +582,7 @@ export class Supervisor {
APEMIND_USER_ID: inst.userId,
APEMIND_INSTANCE_ID: inst.userId,
...extraEnv,
DSH_PERMISSION_MODE: extraEnv.DSH_PERMISSION_MODE || "danger-full-access",
}
const hasPatch = fs.existsSync(this.patchPath(inst.userId))
const argv: string[] = []
Expand Down
1 change: 1 addition & 0 deletions host-agent/test/fake-dsh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ if (process.env.HOME) {
APEMIND_INSTANCE_ID: process.env.APEMIND_INSTANCE_ID ?? null,
APEMIND_API_KEY: process.env.APEMIND_API_KEY ?? null,
DSH_HOME: process.env.DSH_HOME ?? null,
DSH_PERMISSION_MODE: process.env.DSH_PERMISSION_MODE ?? null,
},
}
fs.mkdirSync(path.join(process.env.HOME, ".apemind"), { recursive: true })
Expand Down
50 changes: 50 additions & 0 deletions host-agent/test/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ test("ensure running starts dsh with the per-user environment", async () => {
const probe = JSON.parse(fs.readFileSync(probePath, "utf8"))
assert.equal(probe.env.APEMIND_USER_ID, "alice")
assert.equal(probe.env.APEMIND_INSTANCE_ID, "alice")
assert.equal(probe.env.DSH_PERMISSION_MODE, "danger-full-access")
assert.ok(probe.env.DSH_HOME.endsWith("/.dsh"))

const again = await env.sup.ensure("alice", "running")
Expand Down Expand Up @@ -82,12 +83,61 @@ test("env with llm projection renders the apemind provider row", async () => {
assert.match(patch, /name: 'O''Neil'/, "single quotes must be yaml-escaped")
assert.match(patch, /displayName: 'ApeMind'/)
assert.doesNotMatch(patch, /displayName: 'GPT X'/)
assert.doesNotMatch(patch, /id: agent-default-model/)
assert.doesNotMatch(patch, /sk-test-456/, "the key must stay out of the patch file")
} finally {
await env.cleanup()
}
})

test("default model id overrides agent-default-model when it is in the projection", async () => {
const env = await makeEnv()
try {
await env.sup.ensure("opal", "stopped", {
APEMIND_API_KEY: "sk-test-default",
APEMIND_LLM_BASE_URL: "https://main.test/v1/llm",
APEMIND_LLM_MODELS: JSON.stringify([
{ id: "model0123", name: "GPT X" },
{ id: "model0456", name: "Default Chat" },
]),
APEMIND_LLM_DEFAULT_MODEL: "model0456",
})
const patch = fs.readFileSync(
path.join(env.cfg.dataDir, "users", "opal", ".apemind", "managed.cordis.yml"),
"utf8",
)
assert.match(patch, /- id: agent-default-model/)
assert.match(patch, /provider: apemind/)
assert.match(patch, /model: 'model0456'/)
assert.equal(
fs.existsSync(path.join(env.cfg.dataDir, "users", "opal", ".dsh", "settings.yaml")),
false,
"host must not write the user settings document",
)
} finally {
await env.cleanup()
}
})

test("unknown default model id does not override agent-default-model", async () => {
const env = await makeEnv()
try {
await env.sup.ensure("pearl", "stopped", {
APEMIND_API_KEY: "sk-test-missing",
APEMIND_LLM_BASE_URL: "https://main.test/v1/llm",
APEMIND_LLM_MODELS: JSON.stringify([{ id: "model0123", name: "GPT X" }]),
APEMIND_LLM_DEFAULT_MODEL: "model-missing",
})
const patch = fs.readFileSync(
path.join(env.cfg.dataDir, "users", "pearl", ".apemind", "managed.cordis.yml"),
"utf8",
)
assert.doesNotMatch(patch, /id: agent-default-model/)
} finally {
await env.cleanup()
}
})

test("full apemind env renders the workspace guide into DSH_HOME", async () => {
const env = await makeEnv()
try {
Expand Down
Loading