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
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 离线交付把本组件放在
Expand Down
2 changes: 1 addition & 1 deletion compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 属性)
Expand Down
4 changes: 2 additions & 2 deletions deploy/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 12 additions & 9 deletions docs/apemind-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 一份独立凭证
Expand All @@ -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 长尾命令
Expand Down
16 changes: 13 additions & 3 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

Expand All @@ -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 再次打开 / 换人打开(实例已存在)

Expand Down
45 changes: 42 additions & 3 deletions host-agent/src/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ function renderManagedPatch(env: Record<string, string>): string | undefined {
return `${sections.join("\n")}\n`
}

function workspaceKind(env: Record<string, string>): "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
Expand All @@ -184,12 +188,20 @@ function renderAgentsGuide(env: Record<string, string>): 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).')
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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<string, string>): Promise<void> {
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<Record<string, string>> {
try {
return JSON.parse(await fsp.readFile(this.envPath(inst.userId), "utf8")) as Record<string, string>
Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions host-agent/test/fake-dsh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
27 changes: 27 additions & 0 deletions host-agent/test/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand All @@ -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"),
Expand Down Expand Up @@ -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()
}
Expand Down
Loading