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" 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() }