From c43207cfde864dc741100098c7349d8ed515cc2b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 3 Sep 2026 15:15:08 +0800 Subject: [PATCH 1/2] Persist the bound org on hosted CLI profiles so agents keep writing the right workspace. Organization id used to live only in process env and got scrubbed, so collection create silently targeted the personal namespace. Write org_id and workspace kind next to the key, and tell agents that platform ro is not an org write ban. --- docs/apemind-integration.md | 21 ++++++++------ docs/lifecycle.md | 16 +++++++++-- host-agent/src/supervisor.ts | 45 ++++++++++++++++++++++++++++-- host-agent/test/fake-dsh.mjs | 1 + host-agent/test/supervisor.test.ts | 27 ++++++++++++++++++ 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/docs/apemind-integration.md b/docs/apemind-integration.md index 7c30793..0a7809b 100644 --- a/docs/apemind-integration.md +++ b/docs/apemind-integration.md @@ -105,12 +105,15 @@ harness 密钥不漏进 bash)。因此: - **执行(读 profile)**:agent 跑 `apemind` 时环境里没有 key,CLI 读 该实例 HOME 下的 profile 完成 Bearer 认证。 -注入的前提:部署配置了 MCP 端点(`APEMIND_BASE_URL` 由 MCP URL 推导)。 -未配 MCP 的部署不注入 CLI 上下文,也不写 profile。 - -CLI profile 的字段只使用 CLI 已经公开的 `base_url` / `api_key`;目录权限 -0700、文件 0600,与 CLI 自己 `Save` 写出的形态一致。key 轮换 = 下一次 -ensure 覆盖 `env.json` 和 profile。不在 host-agent 里调用 `apemind login` +`APEMIND_BASE_URL` 与 MCP 解耦:控制面在有托管 key 时写入 API 源站(优先从 +MCP URL 去掉 `/mcp`,否则从 LLM 网关去掉 `/v1/llm`,再退回站点公钥 URL)。 +未配 MCP、只配了模型时,CLI profile 和 `AGENTS.md` 仍然生成。 + +CLI profile 写入 `base_url` / `api_key` / `workspace_kind`,组织实例另有 +`org_id`。另有一份不含密钥的 `$HOME/.apemind/workspace.json` +(`kind` / `org_id` / `instance_id` / `data_plane_username`)。目录权限 +0700、文件 0600。key 轮换 = 下一次 ensure 覆盖 `env.json`、profile 与 +`workspace.json`。不在 host-agent 里调用 `apemind login` (那会走会话 cookie,不是托管 key)。 ### 逐实例隔离:每个 dsh 一份独立凭证 @@ -136,9 +139,9 @@ dsh 进程继承宿主 `PATH`,agent 直接跑 `apemind`。升级 = 换镜像 t ### 上下文缺省(CLI 小改) -组织实例里 agent 的每条命令都该默认作用于绑定组织。CLI 增加:`--org-id` 未显式 -提供时读 `APEMIND_ORG_ID` 环境变量。个人实例不注入该变量,行为不变。这是唯一 -影响命令语义的 CLI 改动。 +组织实例里 agent 的每条命令都该默认作用于绑定组织。默认顺序:显式 `--org-id` > +`APEMIND_ORG_ID` > CLI profile 的 `org_id`。个人实例写入 `workspace_kind=personal`, +CLI 拒绝 `--org-id`。组织实例拒绝打到别的组织。 其余 CLI 改动按需推进,不预铺:`doctor` 识别托管环境(检测到注入 env 时报告 绑定身份与通道健康);`skills` 文本补充托管 dsh 场景说明;OpenAPI 长尾命令 diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 38aae78..50e5603 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -135,8 +135,9 @@ dsh {patch} --profile web --no-open --port {port} | `USER` | 实例键 | | `DSH_HOME` | `$HOME/.dsh` | | `XDG_CONFIG_HOME` / `XDG_CACHE_HOME` / `XDG_DATA_HOME` | `$HOME/.config` / `.cache` / `.local/share` | -| `APEMIND_USER_ID` | 实例键(对 host 不透明的租户字符串) | -| `env.json` 里的全部键值 | 控制面注入。当前契约:`APEMIND_API_KEY`(绑定身份托管 key)、`APEMIND_BASE_URL`(MCP URL 去掉 `/mcp`,CLI 用)、`APEMIND_MCP_URL`、`APEMIND_LLM_BASE_URL`、`APEMIND_LLM_MODELS`;组织实例另有 `APEMIND_ORG_ID`。键名限 `^[A-Z][A-Z0-9_]{0,63}$` | +| `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}$` | uid 隔离开启时以分配的 uid/gid 运行;stdout/stderr 进 `.apemind/dsh.log`。 @@ -148,7 +149,16 @@ uid 隔离开启时以分配的 uid/gid 运行;stdout/stderr 进 `.apemind/dsh 两段都只携带环境变量名,密钥不落在 yaml 里,文件泄露不等于密钥泄露(`env.json` 仍含密钥本体,0600 + uid 隔离保护)。patch 对 dsh 的实际生效行为按锁定的 dsh 版本在 staging 验收(与 MCP 行同一口径)。 -`$DSH_HOME/AGENTS.md`(工作区引导,官方 `dsh-agent-instructions` 自动加载)同样按 env 条件渲染:`APEMIND_API_KEY` + `APEMIND_BASE_URL` 齐全时生成,包含绑定身份入口(`apemind whoami` / `apemind skills`)、`APEMIND_ORG_ID` 存在时的默认组织行、MCP 与模型行。它是托管文件——每次拉起 dsh 前按 `env.json` 重写,手工编辑不保留;只出现 env 变量名与 id,不出现密钥。镜像内置 `apemind` CLI(`/usr/local/bin/apemind`,构建时锁版本 + sha256 校验),实例进程经继承的 `PATH` 直接可用,配合注入的 `APEMIND_BASE_URL`/`APEMIND_API_KEY`/`APEMIND_ORG_ID` 免登录工作。 +磁盘上有四份托管文件,权威如下: + +| 文件 | 权威 | 含密钥 | +| --- | --- | --- | +| `.apemind/env.json` | 控制面注入的进程环境;spawn 白名单读它 | 是(`APEMIND_API_KEY`) | +| `$XDG_CONFIG_HOME/apemind/profiles/default/state.json` | CLI 在 env 被 scrub 后的默认凭据与 `org_id` / `workspace_kind` | 是 | +| `.apemind/workspace.json` | 工作区类型、组织 id、实例键、数据面用户名;给 agent / `doctor` 读 | 否 | +| `$DSH_HOME/AGENTS.md` | 给模型看的工作区说明;组织版与个人版文案不同 | 否 | + +`$DSH_HOME/AGENTS.md` 在 `APEMIND_API_KEY` + `APEMIND_BASE_URL` 齐全时生成。组织版写明没有个人空间、`whoami` 里的 `ro` 是平台账号角色、不要用 `org role list` 判断写权限;个人版写明不要传 `--org-id`。它是托管文件——每次拉起 dsh 前按 `env.json` 重写,手工编辑不保留;只出现 env 变量名与 id,不出现密钥。镜像内置 `apemind` CLI(`/usr/local/bin/apemind`,构建时锁版本 + sha256 校验),实例进程经继承的 `PATH` 直接可用。`APEMIND_ORG_ID` 同时写入 CLI profile 和 `workspace.json`,不依赖 bash 子进程继承。 ### 3.3 再次打开 / 换人打开(实例已存在) diff --git a/host-agent/src/supervisor.ts b/host-agent/src/supervisor.ts index de0c79b..42f2b77 100644 --- a/host-agent/src/supervisor.ts +++ b/host-agent/src/supervisor.ts @@ -175,6 +175,10 @@ function renderManagedPatch(env: Record): string | undefined { return `${sections.join("\n")}\n` } +function workspaceKind(env: Record): "organization" | "personal" { + return env.APEMIND_ORG_ID ? "organization" : "personal" +} + /** 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 @@ -184,12 +188,20 @@ function renderAgentsGuide(env: Record): string | undefined { const lines = [ "# ApeMind Hosted Workspace", "", - "This dsh instance is managed by ApeMind and bound to one ApeMind identity.", + "This dsh instance is managed by ApeMind and bound to one workspace.", `- ApeMind API base URL: ${env.APEMIND_BASE_URL}.`, - "- The `apemind` CLI is preinstalled and pre-authenticated (local profile under `$XDG_CONFIG_HOME/apemind`). Run `apemind skills` for full usage and `apemind whoami` for the bound identity. Do not print credentials.", + "- The `apemind` CLI is preinstalled and pre-authenticated (local profile under `$XDG_CONFIG_HOME/apemind`). Run `apemind whoami` and `apemind skills`. Do not print credentials.", + "- Workspace contract also lives in `$HOME/.apemind/workspace.json` (no secrets).", ] if (env.APEMIND_ORG_ID) { - lines.push(`- Bound organization: ${env.APEMIND_ORG_ID}. Org-scoped CLI commands (collection, document, bot, org) default to it.`) + lines.push( + `- Bound organization: ${env.APEMIND_ORG_ID}. You do not have a personal workspace on this instance.`, + "- Create collections and bots in the bound organization. Omit `--org-id`; the CLI profile already has it.", + "- `whoami.role` / `account_role=ro` is the platform account role, not organization permission. Use `whoami` or `org get` for `org_role` and `permissions`.", + "- Do not run `org role list` to decide whether you can write.", + ) + } else { + lines.push("- This is a personal workspace. Operate only personal knowledge bases. Do not pass `--org-id`.") } if (env.APEMIND_MCP_URL) { lines.push('- The MCP server "apemind" provides knowledge search/read tools; prefer it for retrieval and use the CLI for everything else (creating collections, uploading documents, bots, chats).') @@ -239,6 +251,10 @@ export class Supervisor { return path.join(this.homeDir(userId), ".dsh", "AGENTS.md") } + private workspacePath(userId: string): string { + return path.join(this.homeDir(userId), ".apemind", "workspace.json") + } + /** Per-instance CLI state root. Spawn sets XDG_CONFIG_HOME to $HOME/.config, * so this is where `apemind` looks when the bash tool env has been scrubbed. */ private cliConfigDir(userId: string): string { @@ -455,6 +471,7 @@ export class Supervisor { await fsp.rm(this.guidePath(inst.userId), { force: true }) } await this.syncCliProfile(inst, env) + await this.syncWorkspaceFile(inst, env) } /** dsh's bash/tool children are spawned from scrubbedParentEnv(), which drops @@ -473,15 +490,36 @@ export class Supervisor { await fsp.chmod(path.join(root, "profiles"), 0o700) await fsp.chmod(profileDir, 0o700) await writePrivateJson(path.join(root, "config.json"), { current_profile: "default" }) + const kind = workspaceKind(env) await writePrivateJson(path.join(profileDir, "state.json"), { base_url: env.APEMIND_BASE_URL, api_key: env.APEMIND_API_KEY, + workspace_kind: kind, + ...(kind === "organization" ? { org_id: env.APEMIND_ORG_ID } : {}), }) if (inst.meta.uid !== undefined) { await chownTree(root, inst.meta.uid, inst.meta.uid) } } + /** Non-secret workspace binding for CLI/doctor/agents. Key stays in the profile. */ + private async syncWorkspaceFile(inst: Instance, env: Record): Promise { + const file = this.workspacePath(inst.userId) + if (!env.APEMIND_API_KEY) { + await fsp.rm(file, { force: true }) + return + } + await writePrivateJson(file, { + kind: workspaceKind(env), + org_id: env.APEMIND_ORG_ID || "", + instance_id: inst.userId, + data_plane_username: env.APEMIND_DATA_PLANE_USERNAME || "", + }) + if (inst.meta.uid !== undefined) { + await fsp.chown(file, inst.meta.uid, inst.meta.uid) + } + } + private async readInstanceEnv(inst: Instance): Promise> { try { return JSON.parse(await fsp.readFile(this.envPath(inst.userId), "utf8")) as Record @@ -528,6 +566,7 @@ export class Supervisor { XDG_CACHE_HOME: path.join(home, ".cache"), XDG_DATA_HOME: path.join(home, ".local", "share"), APEMIND_USER_ID: inst.userId, + APEMIND_INSTANCE_ID: inst.userId, ...extraEnv, } const hasPatch = fs.existsSync(this.patchPath(inst.userId)) diff --git a/host-agent/test/fake-dsh.mjs b/host-agent/test/fake-dsh.mjs index dcf7423..4f6eaf6 100644 --- a/host-agent/test/fake-dsh.mjs +++ b/host-agent/test/fake-dsh.mjs @@ -14,6 +14,7 @@ if (process.env.HOME) { argv: args, env: { APEMIND_USER_ID: process.env.APEMIND_USER_ID ?? null, + 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, }, diff --git a/host-agent/test/supervisor.test.ts b/host-agent/test/supervisor.test.ts index ecf120c..d34b70c 100644 --- a/host-agent/test/supervisor.test.ts +++ b/host-agent/test/supervisor.test.ts @@ -21,6 +21,7 @@ test("ensure running starts dsh with the per-user environment", async () => { const probePath = path.join(env.cfg.dataDir, "users", "alice", ".apemind", "probe.json") 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.ok(probe.env.DSH_HOME.endsWith("/.dsh")) const again = await env.sup.ensure("alice", "running") @@ -107,6 +108,8 @@ test("full apemind env renders the workspace guide into DSH_HOME", async () => { assert.match(guide, /org12345678/) assert.match(guide, /apemind skills/) assert.match(guide, /MCP server "apemind"/) + assert.match(guide, /platform account role/) + assert.match(guide, /Do not run `org role list`/) assert.doesNotMatch(guide, /sk-test-guide/, "the key must stay out of the guide") const statePath = path.join( @@ -122,10 +125,24 @@ test("full apemind env renders the workspace guide into DSH_HOME", async () => { const profile = JSON.parse(fs.readFileSync(statePath, "utf8")) as { base_url: string api_key: string + org_id?: string + workspace_kind?: string } assert.equal(profile.base_url, "https://main.test") assert.equal(profile.api_key, "sk-test-guide") + assert.equal(profile.org_id, "org12345678") + assert.equal(profile.workspace_kind, "organization") assert.equal(fs.statSync(statePath).mode & 0o777, 0o600) + const workspace = JSON.parse( + fs.readFileSync(path.join(env.cfg.dataDir, "users", "lena", ".apemind", "workspace.json"), "utf8"), + ) as { kind: string; org_id: string; instance_id: string } + assert.equal(workspace.kind, "organization") + assert.equal(workspace.org_id, "org12345678") + assert.equal(workspace.instance_id, "lena") + assert.doesNotMatch( + fs.readFileSync(path.join(env.cfg.dataDir, "users", "lena", ".apemind", "workspace.json"), "utf8"), + /sk-test-guide/, + ) const cfg = JSON.parse( fs.readFileSync( path.join(env.cfg.dataDir, "users", "lena", ".config", "apemind", "config.json"), @@ -182,7 +199,17 @@ test("guide without base url is absent and personal guide has no org line", asyn }) const guide = fs.readFileSync(guidePath, "utf8") assert.doesNotMatch(guide, /Bound organization/) + assert.match(guide, /personal workspace/) + assert.match(guide, /Do not pass `--org-id`/) assert.doesNotMatch(guide, /MCP server/) + const personalProfile = JSON.parse( + fs.readFileSync( + path.join(env.cfg.dataDir, "users", "mike", ".config", "apemind", "profiles", "default", "state.json"), + "utf8", + ), + ) as { workspace_kind?: string; org_id?: string } + assert.equal(personalProfile.workspace_kind, "personal") + assert.equal(personalProfile.org_id, undefined) } finally { await env.cleanup() } From a89a8ada82d277be0f2b644903649b67fedd6321 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 3 Sep 2026 16:13:52 +0800 Subject: [PATCH 2/2] Bake apemind CLI v0.3.4 into the computer image so hosted agents get workspace identity. The new CLI keeps the bound org on disk after env scrub. Pin the published checksums and move the documented image tag to the release this change ships with. --- Dockerfile | 6 +++--- README.md | 8 ++++---- compose.example.yml | 2 +- deploy/Chart.yaml | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index d34309b..0592539 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,9 +66,9 @@ RUN mkdir -p /opt/dsh-seed \ # apemind CLI is baked in (no runtime download): pinned version, pinned # per-arch sha256, fetched from the public immutable release route. ARG TARGETARCH -ARG APEMIND_CLI_VERSION=v0.3.3 -ARG APEMIND_CLI_SHA256_AMD64=1ae3cddb7ce7e6fdf607537f04886d44dc4ec1c9728b990aa0c4c42edf7174c5 -ARG APEMIND_CLI_SHA256_ARM64=0c482cad10b5023994cf510be117db35b92e507a8dddb480100ae9518f468b40 +ARG APEMIND_CLI_VERSION=v0.3.4 +ARG APEMIND_CLI_SHA256_AMD64=bf00371d726a8ad8df146fa8cc4c9e268f6fa71cc50ab1ebe3958265a023cb87 +ARG APEMIND_CLI_SHA256_ARM64=95f5f7be81db464047522f7ed1b4c0369e3e82a8c5e92325659b84e0f02bbfaa RUN set -eu; \ arch="${TARGETARCH:-amd64}"; \ case "$arch" in \ diff --git a/README.md b/README.md index 63fa13c..b9da8ca 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.8`。离线机先在联网环境导出镜像再 `docker load`: +当前发布 tag 是 `v0.2.11`。离线机先在联网环境导出镜像再 `docker load`: ```bash -docker pull apecloud/apemind-computer:v0.2.8 -docker save apecloud/apemind-computer:v0.2.8 -o apemind-computer-v0.2.8.tar -docker load -i apemind-computer-v0.2.8.tar +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 ``` Compose 样例见 `compose.example.yml`。ApeMind 离线交付把本组件放在 diff --git a/compose.example.yml b/compose.example.yml index 8218174..721efde 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -8,7 +8,7 @@ # (两边配同一个值)加一道门。 services: computer-host: - image: apecloud/apemind-computer:v0.2.8 + image: apecloud/apemind-computer:v0.2.11 restart: unless-stopped environment: # 浏览器访问网关用的对外地址(决定 Origin 校验与 cookie Secure 属性) diff --git a/deploy/Chart.yaml b/deploy/Chart.yaml index 86cc742..0b260e8 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.8 -appVersion: "v0.2.8" +version: 0.2.11 +appVersion: "v0.2.11"