diff --git a/README.md b/README.md index b9da8ca..1c9a26e 100644 --- a/README.md +++ b/README.md @@ -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 离线交付把本组件放在 diff --git a/compose.example.yml b/compose.example.yml index 721efde..4ae0b5b 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -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 属性) diff --git a/deploy/Chart.yaml b/deploy/Chart.yaml index 0b260e8..6e18101 100644 --- a/deploy/Chart.yaml +++ b/deploy/Chart.yaml @@ -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" diff --git a/docs/apemind-integration.md b/docs/apemind-integration.md index 0a7809b..f5c597f 100644 --- a/docs/apemind-integration.md +++ b/docs/apemind-integration.md @@ -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 与对话、查组织成员、 @@ -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 默认组织上下文;引导文件渲染 | diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 50e5603..dd4d563 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -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: }`,替换出厂的 `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 行同一口径)。 磁盘上有四份托管文件,权威如下: diff --git a/host-agent/src/supervisor.ts b/host-agent/src/supervisor.ts index 42f2b77..990a461 100644 --- a/host-agent/src/supervisor.ts +++ b/host-agent/src/supervisor.ts @@ -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. */ @@ -165,12 +174,17 @@ function renderManagedPatch(env: Record): 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` } @@ -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[] = [] diff --git a/host-agent/test/fake-dsh.mjs b/host-agent/test/fake-dsh.mjs index 4f6eaf6..d1d553c 100644 --- a/host-agent/test/fake-dsh.mjs +++ b/host-agent/test/fake-dsh.mjs @@ -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 }) diff --git a/host-agent/test/supervisor.test.ts b/host-agent/test/supervisor.test.ts index d34b70c..e3dd5f1 100644 --- a/host-agent/test/supervisor.test.ts +++ b/host-agent/test/supervisor.test.ts @@ -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") @@ -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 {