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
2 changes: 1 addition & 1 deletion docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 删除语义

Expand Down
15 changes: 13 additions & 2 deletions host-agent/src/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ function workspaceKind(env: Record<string, string>): "organization" | "personal"
return env.APEMIND_ORG_ID ? "organization" : "personal"
}

function sameStringMap(left: Record<string, string>, right: Record<string, string>): 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, string>): Promise<void> {
private async writeInstanceEnv(inst: Instance, env: Record<string, string>): Promise<boolean> {
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;
Expand Down
38 changes: 38 additions & 0 deletions host-agent/test/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
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 {
Expand Down
Loading