# Studio Goose 权限与 Session 排障 Runbook

> 目的：以后再遇到“库里有权限，但 Agent 在对话里说自己没有某能力”的问题，直接按这份 runbook 查，不要从头摸。

> 2026-06-22 已确认的稳定事实：

- Studio 上 Goose 是双实例：`18006` 主、`18007` 备用/第二实例。
- 105 不跑 Goose；105 只是无状态 H5 前端，代理回 Studio。
- Studio 可直接 SSH：`ssh john@58.38.22.103`。不要为了查 Studio 再绕 105。
- H5 会话权限不是只看数据库，还要看会话启动时实际下发给 Goose 的 `extension_overrides`。

## 这次案例结论

用户：

- `username`: `wx_ul610et8`
- `display_name`: `唐`
- `user_id`: `a70ff537-8908-486e-9b6c-042e07cc25db`

线上生产结论：

- `h5_capability_grants` 里该用户有 `aider=1`
- 角色默认 `role:user` 也有 `aider=1`
- `h5_user_policies` 里该用户没有单独禁用项
- 角色策略是：
  - `workspace_access=readwrite`
  - `network_egress=allow`
  - `goose_mode=auto`
  - `api_lockdown=true`

按当前代码实时计算 `getAgentSessionPolicy(userId)` 的结果：

- `capabilities.aider = true`
- `developer` 扩展应带 `write/edit/shell/tree/read_image`
- `extensionOverrides` 里应出现 `aider`

因此：

- 如果对话里 Agent 说“没有 `aider` / 没有 `write_file` / 没有 `edit_file`”，优先怀疑是 **旧会话扩展未同步**、**Goose 实例漂移** 或 **子会话能力不一致**，不是先怀疑 DB 权限没开。
- 注意：`aider` grant 为 true 只表示会话可挂 `aider` 平台扩展；Goose 的 `aider` 执行器仍会按任务/目录做运行时保护。MindSpace 用户工作区默认禁止 `aider` 直接执行，除非显式打开 `GOOSE_AIDER_ALLOW_MINDSPACE=1`。

## 2026-06-22 实际修复记录

用户贴出的异常话术对应 session：

- `agent_session_id`: `20260620_45`
- `title`: `西湖跑步喝茶计划`
- `goosed_node`: `0`
- 实例：Studio 主 Goose `18006`

异常表现：

- Goose session 里有 `aider` 扩展
- `developer.available_tools` 只有 `read_image`
- 缺少 `sandbox-fs`
- 因此 Agent 看不到 `write_file` / `edit_file`

已执行修复：

- 用生产代码的 `getAgentSessionPolicy(userId)` 重新计算该用户应有能力
- 对 `20260620_45` 执行 `reconcileAgentSession(...)`
- 修复后 `20260620_45` 已挂上 `sandbox-fs`
- `sandbox-fs.available_tools` 已包含：
  - `read_file`
  - `write_file`
  - `edit_file`
  - `create_dir`
  - `list_dir`
  - `private_data_info`
  - `private_data_schema`
  - `private_data_query`
  - `private_data_execute`

这说明原问题已经定位并修复到具体旧会话：不是用户 DB 权限缺失，而是旧 Goose session 的扩展集没有包含当前策略要求的 `sandbox-fs`。

同时确认：

- `aider__code` 对 MindSpace 页面类任务返回 `Selected engine: blocked` 是运行时保护策略，不是这个用户没有 `aider` grant。
- 相关保护在两处存在：
  - `/Users/john/Project/tkmind_go/crates/goose/src/agents/platform_extensions/aider.rs`
  - `/Users/john/Project/tkmind_go/deploy/coding_router.sh`
- 对 MindSpace 页面/HTML/游戏生成任务，应使用 `sandbox-fs` 的 `write_file/edit_file` 或发布/App 工具，不应强制走 `aider`。

## 2026-06-22 深入排查：其他用户影响面

排查口径：

- 不要只看 Goose SQLite 的 `sessions.extension_data`，它是历史快照/扩展记忆结构，实时状态以 Goose API 为准。
- 正确实时检查方式：按 `h5_user_sessions.goosed_node` 路由到 `18006` 或 `18007`，调用 `/sessions/{id}/extensions`。

最近 120 条 active 用户 session 的实时抽样结果：

- 可访问 session：120/120
- 缺 `sandbox-fs`：24/120
- node 0：89 条，缺 21 条
- node 1：31 条，缺 3 条
- 缺失集中在 2026-06-18 到 2026-06-20 的旧会话；2026-06-21 到 2026-06-22 新会话抽样未见缺失。

受影响样本用户：

- `wx_ul610et8 / 唐`：3 条旧会话仍缺 `sandbox-fs`
- `wx_mk4zzaps / Mark 刘名章`：1 条旧会话缺 `sandbox-fs`
- `wx_5dljrg1a / 123`：1 条旧会话缺 `sandbox-fs`
- `john / John`：若干旧测试/管理会话缺 `sandbox-fs`
- `admin / 管理员`：若干管理会话缺 `sandbox-fs`

结论：

- 这不是“当前双 Goose 负载随机丢能力”的现象。
- 更像是 `sandbox-fs` 上线/策略迁移前后创建的旧 session 没有自动补齐扩展。
- 双 Goose 会放大排查复杂度，因为每个 session 固定在自己的 `goosed_node`，必须去对应节点查实时 extensions；但只要 `start/resume/reply` 都走策略同步，就不会因为双 Goose 本身产生跨节点同步问题。

代码兜底：

- `/agent/start` 已同步策略。
- `/agent/resume` 已同步策略。
- 2026-06-22 新增本地修复：`/sessions/:id/reply` 前也调用 `reconcileSessionPolicyForUser(...)`。
- 这样旧会话即使没有显式经过前端 `resumeSession()`，在下一次发消息前也会先补齐 `sandbox-fs` 等当前策略要求的扩展。

验证：

- `node --check server.mjs` 通过
- `node --check tkmind-proxy.mjs` 通过
- `node --test session-reconcile.test.mjs` 通过，6/6
- `node --test policies.test.mjs capabilities.test.mjs user-memory-profile.test.mjs` 通过，38/38
- 全量 `npm test -- session-reconcile.test.mjs` 会被项目脚本展开成全量测试，目前失败在既有 `llm-providers.test.mjs` 断言：实际返回 `Aider 未启用模型绑定`，不是本次 session policy sync 改动引入。

## 代码链路

关键代码：

- [user-auth.mjs](../user-auth.mjs)
  - `resolveUserCapabilities()`
  - `resolveUserPolicies()`
  - `getAgentSessionPolicy()`
- [policies.mjs](../policies.mjs)
  - `applyPoliciesToCapabilities()`
- [capabilities.mjs](../capabilities.mjs)
  - `buildAgentExtensionPolicy()`
- [tkmind-proxy.mjs](../tkmind-proxy.mjs)
  - `POST /agent/start`
  - `POST /agent/resume`
  - `resolveTarget(sessionId)`

关键判断：

- `workspace_access=readonly` 会强制关闭：
  - `shell`
  - `filesystem`
  - `static_publish`
  - `aider`
- `POST /agent/start` 会实时计算 `getAgentSessionPolicy()` 并下发 `extension_overrides`
- `POST /agent/resume` 恢复旧会话时，必须结合 session 所在 Goose 实例继续看，不要只看数据库

## 架构事实

Studio H5 侧：

- 运行中 Portal 的 `TKMIND_API_TARGET` 指向主 Goose：`https://127.0.0.1:18006`
- 运行中 Portal 的 `TKMIND_API_TARGET_1` 指向第二 Goose：`https://127.0.0.1:18007`
- `server.mjs` 会构造：
  - `API_TARGETS = [TKMIND_API_TARGET, TKMIND_API_TARGET_1]`
- 105 或外部机器可以通过 Tailscale/内网目标代理到 Studio，但排查 Studio 时优先直接 SSH 到 Studio。

Session 路由：

- `h5_user_sessions.goosed_node`
  - `0` 表示主 Goose
  - `1` 表示第二 Goose
- `tkmind-proxy.resolveTarget(sessionId)` 会按 `goosed_node` 把 session 路由回对应 Goose

## 标准排查顺序

### 1. 先查用户真实权限，不要先猜

查 `h5_capability_grants`：

- 用户级覆盖
- `role:user` 默认值

查 `h5_user_policies`：

- 用户级覆盖
- `role:user` 默认值

重点看：

- `aider`
- `filesystem`
- `shell`
- `code_browse`
- `static_publish`
- `workspace_access`

### 2. 再让线上代码实时算一次 session policy

不要只看表。

必须直接调用：

- `getAgentSessionPolicy(userId)`

重点看结果里的：

- `capabilities`
- `policies`
- `extensionOverrides`
- 是否真的有 `aider`
- `developer.available_tools` 是否真的包含 `write/edit`

### 3. 再看 session 落到哪个 Goose 实例

查：

- `h5_user_sessions.agent_session_id`
- `h5_user_sessions.goosed_node`

如果同一用户的不同 session 分布在 `0` 和 `1` 两个节点：

- 说明必须分别检查 `18006` 和 `18007`
- 不能只看一个 Goose 的状态就下结论

### 4. 如果用户说“这条对话里没有能力”

优先排查：

1. 这条对话是否是旧 session
2. 这条对话是否落在第二 Goose
3. 两个 Goose 的扩展配置是否漂移
4. 是否进入了能力被收窄的子会话

## 这次已确认的反常点

- 线上 DB 与 `getAgentSessionPolicy()` 都显示：`wx_ul610et8` 应有 `aider` 和 `write/edit`
- 但用户贴出的某条对话话术显示：Agent 自述没有 `write_file / edit_file`
- 因为 Studio 是双 Goose，不是单 Goose，所以这类现象优先按“实例间配置漂移”处理

## 下次直接复用的命令清单

### 查 105 H5 当前指向的 Goose / RDS

优先查 Studio：

```bash
ssh -o BatchMode=yes -o ConnectTimeout=8 john@58.38.22.103 \
  "cd /Users/john/Project/Memind && ps eww -p \$(pgrep -f 'node .*server.mjs' | head -1) | tr ' ' '\n' | grep '^TKMIND_API_TARGET'"
```

如果确实要查 105：

```bash
ssh -i ~/.ssh/id_ed25519 root@120.26.184.105 \
  "sed -n '1,80p' /root/tkmind_go/ui/h5/.env"
```

### 查生产用户权限

```bash
ssh john@58.38.22.103
cd /Users/john/Project/Memind

node -e 'process.loadEnvFile(".env"); const mysql=require("mysql2/promise"); (async()=>{ const pool=mysql.createPool(process.env.DATABASE_URL); const userId="a70ff537-8908-486e-9b6c-042e07cc25db"; const [userCaps]=await pool.query("SELECT capability_key, allowed FROM h5_capability_grants WHERE subject_type=\"user\" AND subject_id=? ORDER BY capability_key",[userId]); const [roleCaps]=await pool.query("SELECT capability_key, allowed FROM h5_capability_grants WHERE subject_type=\"role\" AND subject_id=\"user\" ORDER BY capability_key"); const [userPolicies]=await pool.query("SELECT policy_key, policy_value FROM h5_user_policies WHERE subject_type=\"user\" AND subject_id=? ORDER BY policy_key",[userId]); const [rolePolicies]=await pool.query("SELECT policy_key, policy_value FROM h5_user_policies WHERE subject_type=\"role\" AND subject_id=\"user\" ORDER BY policy_key"); console.log(JSON.stringify({userCaps,roleCaps,userPolicies,rolePolicies},null,2)); await pool.end(); })();'
```

### 让线上代码直接算最终 session policy

```bash
ssh john@58.38.22.103
cd /Users/john/Project/Memind

node --input-type=module -e 'import process from "node:process"; process.loadEnvFile(".env"); const { createDbPool } = await import("./db.mjs"); const { createUserAuth } = await import("./user-auth.mjs"); const pool = createDbPool(); const userAuth = createUserAuth(pool, { h5Root: process.cwd() }); const policy = await userAuth.getAgentSessionPolicy("a70ff537-8908-486e-9b6c-042e07cc25db"); console.log(JSON.stringify(policy, null, 2)); await pool.end();'
```

### 查用户最近 session 及节点分布

```bash
ssh john@58.38.22.103
cd /Users/john/Project/Memind

node -e 'process.loadEnvFile(".env"); const mysql=require("mysql2/promise"); (async()=>{ const pool=mysql.createPool(process.env.DATABASE_URL); const [rows]=await pool.query("SELECT agent_session_id, user_id, goosed_node, created_at FROM h5_user_sessions WHERE user_id = ? ORDER BY created_at DESC LIMIT 20", ["a70ff537-8908-486e-9b6c-042e07cc25db"]); console.log(JSON.stringify(rows,null,2)); await pool.end(); })();'
```

### 查某条 Goose session 实际扩展

先用 `h5_user_sessions.goosed_node` 判断端口：

- `0` -> `18006`
- `1` -> `18007`

然后查扩展：

```bash
ssh john@58.38.22.103 \
  'curl -sk -H "X-Secret-Key: local-dev-secret" https://127.0.0.1:18006/sessions/20260620_45/extensions | python3 -m json.tool'
```

## 必记事项

- 以后再看到“Agent 说自己没有 `aider` / `write_file` / `edit_file`”，不要直接重新从数据库查起。
- 先看：
  - 这条 session 在哪个 `goosed_node`
  - 那个 Goose 实例是不是 `18006` 还是 `18007`
  - 那个实例实际拿到的 `extension_overrides`
- 如果文档还没补全第二 Goose 的运行环境注入方式，优先继续补这里，不要让同样的排查再来一遍。
