From 38615303716bfae706737c934af8f91f8b35ac3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=81=E5=85=89=E8=BE=89?= Date: Wed, 30 Sep 2026 09:32:32 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=AD=E7=9A=84=E6=89=98?= =?UTF-8?q?=E7=AE=A1=E5=AE=9E=E4=BE=8B=E5=BA=94=E7=94=A8=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/lifecycle.md | 2 +- host-agent/src/supervisor.ts | 15 ++++++++++-- host-agent/test/supervisor.test.ts | 38 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 0b50f08..b9e2e0f 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -48,7 +48,7 @@ - **二进制位置**:`/usr/local/bin/apemind`,镜像层。dsh 进程的 `PATH` 继承自 host-agent,所以 agent 开箱就能跑 `apemind`。升级 CLI = 换镜像 tag,不改 PVC。 - **身份在何时注入**:`POST /api/v2/computer/open`。控制面确保绑定身份的托管 key(个人=用户本人,组织=该组织服务用户),把 `APEMIND_API_KEY`、`APEMIND_BASE_URL`(由 MCP URL 去掉 `/mcp`)、组织实例另加 `APEMIND_ORG_ID` 一并放进 ensure 的 `env`。宿主整体重写该实例 `.apemind/env.json`,并派生 `managed.cordis.yml`、`.dsh/AGENTS.md` 与 `.config/apemind/` CLI profile。 -- **会不会持久化**:会。权威投影是 `env.json`(PVC,0600)。同一轮 ensure/spawn 再写一份 CLI profile(`$XDG_CONFIG_HOME/apemind`,0700/0600),因为 dsh 工具子进程会剥掉 `APEMIND_API_KEY`,CLI 必须靠这份文件认证。MCP 与模型网关仍读 dsh **进程**环境里的 key,不受剥离影响。闲置唤醒再 spawn 时两份一起按 `env.json` 重写。下一次 open 整体覆盖(key 轮换走这条)。进程已 running 时 open 只改磁盘:CLI 下次调用即读到新 profile,不必等冷启动;MCP/LLM 的进程环境要等下一次 spawn。 +- **会不会持久化**:会。权威投影是 `env.json`(PVC,0600)。同一轮 ensure/spawn 再写一份 CLI profile(`$XDG_CONFIG_HOME/apemind`,0700/0600),因为 dsh 工具子进程会剥掉 `APEMIND_API_KEY`,CLI 必须靠这份文件认证。MCP 与模型网关仍读 dsh **进程**环境里的 key,不受剥离影响。闲置唤醒再 spawn 时两份一起按 `env.json` 重写。下一次 open 整体覆盖(key 轮换走这条);如果托管环境快照发生变化,host 会在这次 ensure 中重启正在运行的 dsh,再返回新的实例状态,因此 MCP/LLM 和 CLI 一起采用新投影。相同快照不会触发重启。 ### 1.3 删除语义 diff --git a/host-agent/src/supervisor.ts b/host-agent/src/supervisor.ts index f34cf92..b649c59 100644 --- a/host-agent/src/supervisor.ts +++ b/host-agent/src/supervisor.ts @@ -205,6 +205,13 @@ function workspaceKind(env: Record): "organization" | "personal" return env.APEMIND_ORG_ID ? "organization" : "personal" } +function sameStringMap(left: Record, right: Record): boolean { + const leftKeys = Object.keys(left).sort() + const rightKeys = Object.keys(right).sort() + if (leftKeys.length !== rightKeys.length) return false + return leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]) +} + /** Managed workspace guide, loaded by dsh from $DSH_HOME/AGENTS.md. Tells the * agent which identity this instance is bound to and which ApeMind channels * exist. Derived from env.json before every spawn (managed file: manual edits @@ -403,13 +410,14 @@ export class Supervisor { if (this.instances.size >= this.settings.snapshot().max_instances) throw new CapacityError("max instances reached") inst = await this.createInstance(userId) } - if (env) await this.writeInstanceEnv(inst, env) + const envChanged = env ? await this.writeInstanceEnv(inst, env) : false if (inst.meta.desired !== desired) { inst.meta.desired = desired await this.persistMeta(inst) } if (desired === "running") { inst.consecutiveFailures = 0 + if (envChanged && inst.status === "running") await this.stopProcess(inst) await this.start(inst) } else { await this.stopProcess(inst) @@ -504,18 +512,21 @@ export class Supervisor { await fsp.writeFile(target, `${JSON.stringify(inst.meta, null, 2)}\n`, { mode: 0o600 }) } - private async writeInstanceEnv(inst: Instance, env: Record): Promise { + private async writeInstanceEnv(inst: Instance, env: Record): Promise { for (const [key, value] of Object.entries(env)) { if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(key) || typeof value !== "string") { throw new Error(`invalid env entry: ${key}`) } } renderManagedPatch(env) + const previous = await this.readInstanceEnv(inst) + const changed = !sameStringMap(previous, env) await fsp.writeFile(this.envPath(inst.userId), `${JSON.stringify(env, null, 2)}\n`, { mode: 0o600 }) await this.syncManagedFiles(inst, env) if (inst.meta.uid !== undefined) { await chownTree(path.join(this.homeDir(inst.userId), ".apemind"), inst.meta.uid, inst.meta.uid) } + return changed } /** Patch yaml, workspace guide, and CLI profile are derived from env.json; diff --git a/host-agent/test/supervisor.test.ts b/host-agent/test/supervisor.test.ts index 484cda3..b69e448 100644 --- a/host-agent/test/supervisor.test.ts +++ b/host-agent/test/supervisor.test.ts @@ -58,6 +58,44 @@ test("env with mcp settings renders the managed patch and passes --patch", async } }) +test("changed managed env restarts a running dsh before ensure returns", async () => { + const env = await makeEnv() + try { + const first = await env.sup.ensure("refresh", "running", { + APEMIND_API_KEY: "sk-first", + APEMIND_BASE_URL: "https://first.test", + APEMIND_MCP_URL: "https://first.test/mcp", + }) + const firstPid = env.sup.get("refresh")?.proc?.pid + assert.equal(first.status, "running") + assert.ok(firstPid) + + const same = await env.sup.ensure("refresh", "running", { + APEMIND_API_KEY: "sk-first", + APEMIND_BASE_URL: "https://first.test", + APEMIND_MCP_URL: "https://first.test/mcp", + }) + assert.equal(same.started_at, first.started_at) + assert.equal(env.sup.get("refresh")?.proc?.pid, firstPid) + + const refreshed = await env.sup.ensure("refresh", "running", { + APEMIND_API_KEY: "sk-second", + APEMIND_BASE_URL: "https://second.test", + APEMIND_MCP_URL: "https://second.test/mcp", + }) + assert.equal(refreshed.status, "running") + assert.notEqual(refreshed.started_at, first.started_at) + assert.notEqual(env.sup.get("refresh")?.proc?.pid, firstPid) + const persisted = JSON.parse( + fs.readFileSync(path.join(env.cfg.dataDir, "users", "refresh", ".apemind", "env.json"), "utf8"), + ) as Record + assert.equal(persisted.APEMIND_BASE_URL, "https://second.test") + assert.equal(persisted.APEMIND_API_KEY, "sk-second") + } finally { + await env.cleanup() + } +}) + test("env with llm projection renders the apemind provider row", async () => { const env = await makeEnv() try {