# 沙箱 (code_exec) 与 Skill 资产 —— 设计与实现

> 配套设计文档：[design-big-lobster-code-sandbox-2026-04-22.md](../../big-lobster-doc/prd/design-big-lobster-code-sandbox-2026-04-22.md)
> （P0 MVP 设计立场、任务拆解、验收清单）
>
> 部署指南：[DEPLOY-SANDBOX.md](DEPLOY-SANDBOX.md)
> （从 0 装 Docker / 构建镜像 / 启动自检 / 故障排查）
>
> 本文档是后端实现侧的详细说明，回答三个问题：
> 1. 这套系统由哪些类 / 表 / 目录组成？
> 2. 后台服务启动之后，沙箱**怎么跑起来**的？
> 3. 每一步行为去哪儿看代码 / 去哪儿查运行数据？

---

## 1. 目标与范围

`code_exec` 是 LLM 的唯一代码执行入口。在隔离的 Docker 容器里跑 Python 脚本，产出
Office 文件（.docx / .xlsx / .pptx / .pdf）或 web artifact（单文件 HTML），以**二进制
Artifact** 形态回落到 Workspace，并可通过 `save_to_oa` 回写 OA。

Skill 是 LLM 的"方法模板"——既可纯 guidance（模型通过 `use_skill` 工具按需拉回方法论），
也可带"沙箱资源资产包"（SKILL.md + 可选 scripts/templates/fonts 等，挂载到沙箱 `/skill/<id>/` 供 Python
引用）。system prompt 始终只放薄索引（id/name/description），guidance 全文不入系统段——
详见 §5.2。

P0 已覆盖：docx 端到端闭环、office-docx/xlsx/pptx/pdf/web-artifacts-builder 5 个系统
skill、前置 4 项工程改造（ArtifactService binary API、WorkspaceResourceResolver、审计
脱敏、rate limit override、AgentRuntime → SandboxService cancel 传播）。P1 已叠加：
xlsx / pptx / pdf 三个 bundle、script_error 结构化回传、Admin bundle rollback / enable。

---

## 2. 类 / 表 / 目录索引

### 2.1 数据模型变更

| 实体 | 字段 | 说明 |
|---|---|---|
| `SkillDefinition` | `assetBundleRef: varchar(200)` | ContentStore ref 或 `fs://<abs-path>` 协议 |
| `SkillDefinition` | `pythonPackagesJson: varchar(600)` | skill 声明依赖；AdminSkillBundleApi 校验必须 ⊆ 镜像预装集 |
| `Artifact` | 沿用 `contentRef / contentSize / format` | 走 binary API 即可；无新字段 |
| `AI_TOOL_AUDIT` | `detailJson` | `code_exec` 落脱敏后的 map（含 code_sha256，不含原文） |

### 2.2 源码清单

#### 沙箱内核

| 文件 | 职责 |
|---|---|
| [SandboxException.java](../src/com/gzzm/lobster/sandbox/SandboxException.java) | 沙箱自身故障（非脚本异常）。继承 `LobsterException` |
| [SandboxRequest.java](../src/com/gzzm/lobster/sandbox/SandboxRequest.java) | 一次 `code_exec` 入参 builder |
| [SandboxResult.java](../src/com/gzzm/lobster/sandbox/SandboxResult.java) | 执行结果：exitCode / errorCategory / produced[] |
| [DockerRunner.java](../src/com/gzzm/lobster/sandbox/DockerRunner.java) | 封装 `docker run` 子进程，stdout/stderr 排空 (256KB 上限)，timeout/OOM 识别 |
| [SandboxService.java](../src/com/gzzm/lobster/sandbox/SandboxService.java) | 全流程编排：stage inputs / 挂 skill / docker run / 扫产物 / 回落 Artifact / 清理；runId→containerName 注册表 |

#### 工具层

| 文件 | 职责 |
|---|---|
| [CodeExecTool.java](../src/com/gzzm/lobster/tool/builtin/CodeExecTool.java) | `code_exec` 工具实现；含 `redactAuditDetail` 脱敏 |
| [ToolExecutor.java](../src/com/gzzm/lobster/tool/ToolExecutor.java) | 接口新增 default `redactAuditDetail` 钩子 |
| [ToolExecutorDispatcher.java](../src/com/gzzm/lobster/tool/ToolExecutorDispatcher.java) | 调 `redactAuditDetail` + 读 `rateLimitPerMinute` override |
| [BuiltinToolDefinition.java](../src/com/gzzm/lobster/tool/BuiltinToolDefinition.java) | 新字段 `rateLimitPerMinute` + builder `.rateLimitPerMinute(int)` |

#### Skill 资产

| 文件 | 职责 |
|---|---|
| [SkillDefinition.java](../src/com/gzzm/lobster/skill/SkillDefinition.java) | 新增 `assetBundleRef` + `pythonPackagesJson` |
| [SkillAssetService.java](../src/com/gzzm/lobster/skill/SkillAssetService.java) | bundle 下载 / 解压 / 缓存；§7.3 8 项安全校验；识别 `fs://` 协议 |
| [SystemSkillLoader.java](../src/com/gzzm/lobster/skill/SystemSkillLoader.java) | 启动扫描 `web/WEB-INF/lobster/deploy/skills` + `WEB-INF/lobster/skills`，upsert 系统 skill |
| [AdminSkillBundleApi.java](../src/com/gzzm/lobster/api/admin/AdminSkillBundleApi.java) | 用户 bundle 上传 / 回滚 / 启用禁用；所有路径过 §7.3 |

#### 前置改造（Track F）

| 文件 | 改动 |
|---|---|
| [ContentStore.java](../src/com/gzzm/lobster/storage/ContentStore.java) | 新增 `readBinary(ref, offset, limit)` 分段重载 |
| [FileSystemContentStore.java](../src/com/gzzm/lobster/storage/FileSystemContentStore.java) | 实现分段重载，避免大 docx 整文件入内存 |
| [ArtifactService.java](../src/com/gzzm/lobster/artifact/ArtifactService.java) | +`createBinary` / `overwriteBinary` / `readArtifactBytes(×2)` |
| [OaFileClient.java](../src/com/gzzm/lobster/oa/OaFileClient.java) | +`downloadBytes` / `writeNewBytes` / `overwriteBytes` |
| [OaFileClientStub.java](../src/com/gzzm/lobster/oa/OaFileClientStub.java) | 上述三个方法同步 stub 实现 |
| [ResolvedFile.java](../src/com/gzzm/lobster/workspace/ResolvedFile.java) | 统一解析结果 DTO（bytes / mimeType / displayName / extension / size） |
| [WorkspaceResourceResolver.java](../src/com/gzzm/lobster/workspace/WorkspaceResourceResolver.java) | 三前缀 (res_ / art_ / oa_) 统一解析为字节流 |
| [WorkspaceResourceTools.java](../src/com/gzzm/lobster/tool/builtin/WorkspaceResourceTools.java) | `save_to_oa` 按 format 分派到 binary 路径 |
| [AgentRuntime.java](../src/com/gzzm/lobster/runtime/AgentRuntime.java) | `cancel(runId)` 尾端 `sandboxService.killByRun(runId)` |
| [LobsterBootstrap.java](../src/com/gzzm/lobster/bootstrap/LobsterBootstrap.java) | `onStart()` 追加 `systemSkillLoader.loadAll()` |
| [LobsterConfig.java](../src/com/gzzm/lobster/config/LobsterConfig.java) | 新增 15 项沙箱/skill 相关配置 |

### 2.3 部署资产

| 路径 | 用途 |
|---|---|
| `web/WEB-INF/lobster/deploy/sandbox/Dockerfile` | 沙箱镜像定义（pandoc + libreoffice + nodejs + pnpm + python 库 + web-artifacts 模板） |
| `web/WEB-INF/lobster/deploy/sandbox/build.sh` | 构建镜像 + 导出 `installed-packages.json` 白名单 |
| `web/WEB-INF/lobster/deploy/skills/docx/` | Claude 官方 docx skill（沙箱类） |
| `web/WEB-INF/lobster/deploy/skills/xlsx/` | Claude 官方 xlsx skill |
| `web/WEB-INF/lobster/deploy/skills/pptx/` | Claude 官方 pptx skill |
| `web/WEB-INF/lobster/deploy/skills/pdf/` | Claude 官方 pdf skill |
| `web/WEB-INF/lobster/deploy/skills/web-artifacts-builder/` | React 单文件 artifact 构造器 |
| `web/WEB-INF/lobster/deploy/skills/meeting-minutes/` | 会议纪要与督办事项表 |
| `web/WEB-INF/lobster/deploy/skills/data-cleaning-report/` | 数据质量审计与清洗报告 |
| `web/WEB-INF/lobster/deploy/skills/statistical-briefing/` | 统计分析简报与图表 |
| `web/WEB-INF/lobster/deploy/skills/materials-assembly/` | 材料目录与 PDF 汇编 |
| `web/WEB-INF/lobster/deploy/skills/budget-analysis/` | 预算 / 项目资金分析 |
| `web/WEB-INF/lobster/skills/doc-coauthoring/` | 文档协作流程（纯 guidance） |
| `web/WEB-INF/lobster/skills/frontend-design/` | 前端美学指引（纯 guidance） |
| `web/WEB-INF/lobster/skills/gov-writing/` | 政务材料写作与公文格式参考（纯 guidance） |
| `web/WEB-INF/lobster/skills/policy-brief/` | 政策解读与任务分解（纯 guidance） |
| `web/WEB-INF/lobster/skills/inspection-checklist/` | 督查检查清单（纯 guidance） |
| `web/WEB-INF/lobster/skills/procurement-review-assist/` | 采购材料辅助审查（纯 guidance） |
| `web/WEB-INF/lobster/skills/public-disclosure/` | 政务公开稿件（纯 guidance） |

---

## 3. 系统启动后的时间轴

**关键认知**：**沙箱不是常驻进程**。系统启动时沙箱不做任何事；每次 LLM 调 `code_exec`
时现起一个 Docker 容器、跑完就销毁。下面是**完整生命周期**。

### 阶段 1 — 后台服务启动（Tomcat 上下文加载时）

```
Tomcat 启动 WAR
  └─ cyan/arachne/thunwind/nest 框架初始化 (DI / DAO / Service)
      └─ BootstrapServlet.init()  (web.xml load-on-startup=1)
           └─ LobsterBootstrap.onStart()
                ├─ builtinToolRegistrar.registerAll()
                │    └─ codeExecTool.registerTo(toolRegistry)
                │         ← code_exec 定义进内存 ToolRegistry
                │           + 同步到 AI_TOOL_DEFINITION 治理表
                └─ systemSkillLoader.loadAll()
                     ├─ 扫 web/WEB-INF/lobster/deploy/skills/     （沙箱类系统 skill，fs:// 绑 SkillDefinition.assetBundleRef）
                     └─ 扫 WEB-INF/lobster/skills/    （guidance 类，SKILL.md 正文直接存 guidance 字段）
                     ← SkillDefinition 表 upsert
```

**此刻的状态**：0 个沙箱容器运行。只有 Tomcat JVM 在跑。Docker 作为宿主机系统服务独立
存在，Lobster 不启停它。

**启动前置（运维脚本，不是 Lobster 做）**：
- 宿主机装好 Docker daemon 并运行中
- `lobster-sandbox:py3.11-office-v3` 镜像已 `docker pull` / `docker load` 到本地
  （否则首次 `docker run` 在 `--network none` 下拉镜像会失败）
- `LobsterConfig.sandboxWorkDir`（默认 `/srv/sandbox` 或 Windows `d:/lobster/sandbox`）
  存在且 Tomcat 进程用户有写权限
- `LobsterConfig.sandboxBundleCacheDir` 存在且可写（skill bundle 解压缓存）
- 宿主存在 uid=10001 的 sandbox 用户（便于 bind-mount 的文件权限匹配）

### 阶段 2 — 用户第一次消息触发

```
用户 POST /ai/api/runs ──► AgentRuntime.run()
    └─ 进 ReAct 主循环
         └─ LlmRuntime 流式返回，含 tool_call: code_exec
              └─ ToolExecutorDispatcher.dispatch(ctx, call)
                   ├─ 取消检查 / 找 def / 解 args
                   ├─ 权限校验 (ToolPermissionChecker)
                   ├─ 限流 (10/min/user，来自 def.rateLimitPerMinute=10)
                   └─ 调 executor = CodeExecTool
                        └─ CodeExecTool.execute(ctx, args)
                             ├─ 校验 code / timeout / activated_skill 已激活
                             ├─ 构 SandboxRequest
                             └─ sandboxService.exec(req)   ◄── 真正起沙箱在这里
```

### 阶段 3 — SandboxService.exec：一次性容器生命周期

**Code 两种入口（对齐 Claude Code CLI 的 Write+Bash 双步）**：

- `code`（inline string）：仅给少量几十行的一次性短脚本用；工具 schema 标注 `maxLength=8000`，
  服务端再用 8000 字符 / 12KB 双阈值拦截，避免 LLM 把长源码塞进 tool_call 参数.
- `code_ref`（workspace ref）：长脚本 / 需要复用或迭代的脚本，LLM 先调
  `write_file(artifactType=CODE_SCRIPT, content=<脚本>)` 落为 Artifact，再调
  `code_exec(code_ref=art_xxx)`。`SandboxService.resolveCodeRefIfNeeded()` 在 validate 前
  通过 `WorkspaceResourceResolver.resolve()` 取出脚本内容，代替 `code` 字段走后续流程.
  绕开 tool_call JSON 参数的尺寸天花板，且脚本以 Artifact 形式在前端工作区可见/可下载/可迭代.

两者**互斥**：同时给 / 都不给 → `code_exec.invalid`. 解析后的脚本仍受 `sandboxCodeMaxBytes` 限制.

```
T0  Host 端准备：
    <sandboxWorkDir>/<runId>-<shortId>/
    ├─ inputs/      ← WorkspaceResourceResolver.resolve 把 input_refs 解成字节落盘
    │                ├─ 00-foo.docx  (safeName，防路径穿越)
    │                ├─ 01-bar.pdf
    │                └─ manifest.json (原始 displayName / ref / mimeType / size / path 对照)
    ├─ outputs/     ← 空；容器写产物
    └─ work/
        └─ entry.py ← 脚本源码（来自 code inline / 或 code_ref 解析）

T1  Skill bundle 解压（有激活 skill 时）：
    SkillAssetService.ensureExtracted(skillId)
    ├─ assetBundleRef = "fs://..."  → 直接返回目录（系统 skill，已解压态）
    └─ assetBundleRef = ContentStore ref
         → 读字节 → tar.gz 流式解压到
           <sandboxBundleCacheDir>/<id>/v<n>/
         → 下次同版本命中 extractedCache 直接返回

T2  DockerRunner.run 起容器：
    ProcessBuilder("docker", "run",
        "--rm",                          ← 退出即删
        "--read-only",                   ← rootfs ro
        "--network", "none",             ← 断网
        "--cap-drop", "ALL",
        "--security-opt", "no-new-privileges",
        "--user", "10001:10001",
        "--name", "lobster-sbx-<shortId>",
        "--memory", "512m",
        "--cpus", "0.5",
        "--pids-limit", "128",
        "--ulimit", "nofile=256:256",
        "-v", "<host>/inputs:/inputs:ro",
        "-v", "<host>/outputs:/outputs",
        "-v", "<host>/work:/work",
        "-v", "<skill-bundle-host-path>:/skill/<id>:ro",
        "--tmpfs", "/tmp:size=128m",
        "lobster-sandbox:py3.11-office-v3",
        "/work/entry.py")
    ▶ 容器启动 → ENTRYPOINT `tini -- python /work/entry.py`

T3  并行：
    └─ 主线程 proc.waitFor(timeoutSec)              ← 阻塞
    ├─ stdout-drainer 线程 (最多 256KB)
    └─ stderr-drainer 线程 (最多 256KB)
    runToContainer.put(runId, containerName)       ← 供 AgentRuntime.cancel 外部 kill

T4  容器内执行：
    tini → python /work/entry.py   (冷启 ≈ 500ms)
    LLM 代码运行，写产物到 /outputs/
    可能 subprocess 调 libreoffice / pandoc / web-artifacts-bundle...

T5  四种结束路径：
    (a) 正常退出     ─► exitCode 收集, waitFor 返 true
    (b) walltime 到 ─► DockerRunner 主动 `docker kill`, exitCode = -1 (EXIT_TIMEOUT)
    (c) OOM          ─► 内核 kill 容器退 137 → 转 -2 (EXIT_OOM)
    (d) 外部 cancel  ─► AgentRuntime.cancel → SandboxService.killByRun(runId)
                        → dockerRunner.kill(containerName)
                        → waitFor 醒，exit code 是 signal
    容器 --rm 自动清理

T6  harvestOutputs(outputs, result)：
    Files.walk(outputs) → 每个文件：
      ├─ 累计 > 50MB (sandboxOutputMaxBytes) → errorCategory=rejected，不回落
      ├─ artifactService.createBinary(...)     ← 字节走 ContentStore.writeBinary
      └─ workspaceService.registerArtifact()   ← 加一条 AI_WORKSPACE_RESOURCE
    Produced 列表填回 SandboxResult.produced

T7  cleanup：
    deleteRecursive(<sandboxWorkDir>/<runId>-<shortId>)   ← 整个 runRoot 全删
    runToContainer.remove(runId)
```

### 阶段 4 — 结果回到 AgentRuntime

```
SandboxResult ─► CodeExecTool 转 ToolResult
                 ├─ ok          → ToolResult.ok(data={produced, stdout, stderr, walltime_ms, exit_code})
                 └─ 非 ok       → ToolResult.error(msg, diagnostics={...})
                 │                 errorCategory=timeout/oom/script_error/rejected
                 │                 stderr 截断回传（8K 上限），让 LLM 下一轮修 bug
                 │
ToolExecutorDispatcher
  ├─ redactOrDefault(executor, args, result)
  │    └─ CodeExecTool.redactAuditDetail ← 脱敏
  │         code_sha256 / code_length / input_refs / activated_skill
  │         timeout_seconds / exit_code / walltime_ms
  │         output_artifact_ids / error_category
  │         (code 原文永不入审计)
  └─ auditLogger.record() ─► AI_TOOL_AUDIT

ToolResult ─► AgentRuntime 主循环
              ├─ 写 tool_result 到 thread transcript
              ├─ 下一轮调用 LLM，context 带着这条 tool_result
              └─ LLM 看产物 / 修错 / 发 assistant 最终消息
```

### 关键时序点

| 时刻 | 关键事件 | 代码位置 |
|---|---|---|
| Server boot | 工具注册 + 系统 skill 扫描 | `LobsterBootstrap.onStart` |
| `code_exec` 发起 | 限流检查 | `ToolExecutorDispatcher:95` 的 `rateLimitFor` / override |
| 容器启动前 | runToContainer.put | `SandboxService:110` |
| Docker 子进程 fork | ProcessBuilder.start | `DockerRunner:91` |
| walltime 超时触发 | `proc.waitFor(timeout, SECONDS)` 返 false | `DockerRunner:108` |
| 外部 cancel 生效 | `AgentRuntime.cancel` → `killByRun` → `docker kill` | `SandboxService:156` |
| 产物回落 | `harvestOutputs` 扫 /outputs | `SandboxService:202` |
| 运行记录落审计 | `auditLogger.record` 带脱敏 detail | `ToolExecutorDispatcher:125` |

---

## 4. 沙箱镜像内容

镜像 tag：`lobster-sandbox:py3.11-office-v3`

### 4.1 安装矩阵

| 层 | 包 / 二进制 | 用途 |
|---|---|---|
| base | `python:3.11-slim-bookworm` | Python 运行时 |
| apt | `tini` | PID 1，防 zombie |
| apt | `pandoc` | docx/html 互转（官方 docx skill 依赖） |
| apt | `libreoffice` + `-l10n-zh-cn` + `-help-zh-cn` | soffice headless 渲染 docx/xlsx/pptx → pdf/图像 |
| apt | `poppler-utils` | `pdftoppm` / `pdftotext`（`pdf2image` 依赖） |
| apt | `chromium` | Plotly/kaleido 等图表库导出静态图片时使用 |
| apt | `fonts-noto-cjk` + `fonts-wqy-zenhei` + `fonts-wqy-microhei` | 中文字体，防 LibreOffice 渲染乱码 |
| apt | `nodejs 20.x`（nodesource） | Claude 官方 pptx/web-artifacts skill 依赖 JS 方案；必须 20+ 因为 `create-vite` 用了 `util.styleText`（Node 20.12+ 才有） |
| pip | `python-docx 1.2.0` / `openpyxl 3.1.5` / `xlsxwriter 3.2.9` / `python-pptx 1.0.2` / `pypdf 6.10.2` | 基础 Office 库 |
| pip | `pdfplumber 0.11.9` / `pdf2image 1.17.0` / `Pillow 12.2.0` / `defusedxml 0.7.1` | 官方 pdf/docx skill 脚本依赖 |
| pip | `numpy 2.4.4` / `pandas 3.0.2` / `pyarrow 24.0.0` / `duckdb 1.5.2` / `polars 1.40.1` | 数据分析、列式文件、本地 SQL |
| pip | `matplotlib 3.10.9` / `seaborn 0.13.2` / `plotly 6.7.0` / `kaleido 1.3.0` / `pyecharts 2.1.0` / `altair 6.1.0` / `vl-convert-python 1.9.0.post1` | 静态和交互图表生成 |
| pip | `scipy 1.17.1` / `statsmodels 0.14.6` / `scikit-learn 1.8.0` / `tabulate 0.10.0` | 统计分析、轻量建模、表格输出 |
| npm 全局 | `pnpm 9.15.0` | 包管理器（供 web-artifacts 用） |
| npm 全局 | `docx 9.6.1` / `pptxgenjs 4.0.1` | 官方 docx / pptx skill 的 Node 路径 |
| npm 全局 | `parcel 2.16.4` + `@parcel/config-default 2.16.4` + `parcel-resolver-tspaths` + `html-inline` | web-artifacts-builder 打包工具链 |

### 4.2 预构建模板（Path B 关键）

在 `web/WEB-INF/lobster/deploy/sandbox/Dockerfile` 构建期：

```
COPY skills/web-artifacts-builder/scripts/ /tmp/wab-scripts/
RUN cd /opt && \
    bash /tmp/wab-scripts/init-artifact.sh vite-react-shadcn-template && \
    cd vite-react-shadcn-template && \
    pnpm add -D parcel @parcel/config-default parcel-resolver-tspaths html-inline && \
    printf '{"extends":"@parcel/config-default","resolvers":["parcel-resolver-tspaths","..."]}' > .parcelrc
```

镜像里 `/opt/vite-react-shadcn-template/` 是一个完整的 Vite + React + Tailwind + 40+
shadcn/ui + parcel 打包工具链已安装的项目，node_modules ≈ 400MB。

`/etc/pnpmrc` 配置 `store-dir=/opt/pnpm-store` + `offline=true`，运行时 `pnpm add/install`
只用本地 store，绝不触网。

### 4.3 运行时包装脚本

- `/usr/local/bin/web-artifacts-init <name>` —— `cp -a /opt/vite-react-shadcn-template $name`
  + 改写 `package.json.name` / `<title>`
- `/usr/local/bin/web-artifacts-bundle` —— 跳过 `pnpm add`（依赖已全装），
  直接 `parcel build` + `html-inline`

脚本以独立文件 `web/WEB-INF/lobster/deploy/sandbox/web-artifacts-init` / `web-artifacts-bundle` 维护，Dockerfile
通过 `COPY sandbox/web-artifacts-* /usr/local/bin/` 进镜像（避开 Dockerfile heredoc 兼容性坑）。

原版 `scripts/init-artifact.sh` / `bundle-artifact.sh` 仍保留在 skill 里，但**沙箱里不要用**
（要网）。镜像外（开发机 / CI）按原流程。

### 4.4 挂载 / 用户

| 位置 | 权限 | 提供者 |
|---|---|---|
| `/inputs` | ro bind | `<sandboxWorkDir>/<runId>/inputs` |
| `/outputs` | rw bind | `<sandboxWorkDir>/<runId>/outputs`（产物在此） |
| `/work` | rw bind | `<sandboxWorkDir>/<runId>/work`（含 entry.py + scratch） |
| `/skill/<id>` | ro bind | skill bundle 解压目录（可能是 fs:// 系统 skill，可能是 cache） |
| `/tmp` | tmpfs 128m | pnpm state / XDG cache / matplotlib |
| rootfs | ro | `--read-only` |
| user | `10001:10001` | `sandbox`（非 root，无权限） |

---

## 5. 系统 skill vs 用户上传 skill

| 维度 | 系统 skill | 用户/组织 skill |
|---|---|---|
| 来源 | 文件系统（`web/WEB-INF/lobster/deploy/skills/` 或 `WEB-INF/skills/`） | Admin UI 上传 tar.gz |
| 存储 | `assetBundleRef = "fs://<abs-path>"` | `assetBundleRef = "skill-bundle/<ref>"`（ContentStore） |
| 加载 | `SystemSkillLoader.loadAll()` 启动时扫描 upsert | `AdminSkillBundleApi.upload` 经 §7.3 校验落 ContentStore |
| 分类 | `deploy/skills` 下均为沙箱资源类；`WEB-INF/lobster/skills` 下为 guidance 类 | 同（基于 bundle 内 SKILL.md 目录结构） |
| 解压 | 不解压，挂载原目录 | `SkillAssetService` 解压到 bundle cache |
| ID | `sys_<name>` | `sk_<uuid>` |
| scope | `system` | `org` |
| 回滚 | 文件系统替换即可 | `AdminSkillBundleApi.rollback(skillId, oldRef)` |
| 禁用 | `AdminSkillApi.toggleEnabled` 或 `AdminSkillBundleApi.setEnabled` | 同 |

### 5.1 frontmatter 解析

`SystemSkillLoader.parseFrontmatter` 只认两个字段：

```yaml
---
name: docx
description: Use this skill whenever the user wants to create, read, edit...
---
```

- `description` → `SkillDefinition.triggerCondition`（进 system 的薄索引；字段已扩到 `varchar(2000)` 对齐 Claude Code 1536 字符 spec）
- 全量 SKILL.md body → `SkillDefinition.guidance`（**不**进 system；模型按 description 判断后调 `use_skill` 工具拉回，作为 tool result 落入历史）
- LICENSE、其它字段一律忽略

### 5.2 激活机制（对齐 Claude Code progressive disclosure）

**旧模型（已废弃）**：`use_skill` 是激活开关，下一轮 `ContextAssembler` 把 guidance 注入 system。多个 skill 叠加会让 system prompt 线性膨胀，还每轮都在系统段重写浪费 prompt-cache。

**新模型**（当前实现）：

1. **索引段始终极薄**：system prompt 的"可用 Skill"只有 `id | name | description`，不含 guidance 正文。
2. **`use_skill(skillId)` = 读 SKILL.md 的 tool 调用**：返回值就是完整 guidance；结果以 `role=tool` message 落入对话历史，之后模型直接按历史里的方法论执行。
3. **重复调用语义**：同 thread 内重复 `use_skill(sameId)` 仍返回完整 guidance，同时记录 `duplicate=true`。这样即使此前 tool result 已被压缩、折叠或不在当前 send-view，模型仍能重新获得完整方法论。判重 key 仅 `skillId`，不含 version；对话进行中 admin 改 guidance 不会改变已激活标记，新 thread 才自然看到新版本。
4. **压缩保护**：`use_skill` 的 tool result 列入 `WIDTH_EXEMPT_TOOLS`，不被 `ToolResultWidthPolicy` 截短。
5. **Bundle 清单兜底**：`use_skill` 返回时调 `SkillAssetService.ensureExtracted`，把 SKILL.md 未提及的 bundle 文件列成 `## 可用资源`（最多 50 条、单文件 <10 MB、扫描深度 3）附在 guidance 末尾，作者漏写也不会错过资源。
6. **门禁复用**：`SkillService.activateForThread` 仍记账，但不再影响 system prompt，仅作：① 重复调用 telemetry ② `CodeExecTool` 校验"读过 guidance 才允许挂 asset bundle"。

**guidance 类 vs 沙箱类的区别（挂载行为）**：

- **guidance 类**（如 `sys_doc-coauthoring`）：`use_skill` 后只拿到 guidance，没有 bundle 挂载。
- **沙箱类**（如 `sys_docx`）：`use_skill` 拿到 guidance；之后 `code_exec(activated_skill=sys_docx)` 会把 `/skill/sys_docx/` 挂进容器。门禁由 `SkillService.activatedForThread().contains(sys_docx)` 判断——未 `use_skill` 过就挂不了。

### 5.3 Description 写作规范（给 Skill 作者）

Description 是模型召回的唯一依据，写不好 = skill 从不被调用。`AdminSkillApi.create/update` 返回 `warnings` 数组提醒：

| 警告 | 触发条件 | 建议 |
|---|---|---|
| `为空` | 缺 description | 至少写一句话 |
| `< 40 字符` | 太短 | 写清楚场景 + 关键词 + 例外 |
| `缺触发提示词` | 未命中 `Use this skill` / `Use when` / `Trigger when` / `Triggers include` / `触发条件` / `用于` / `用来` | 显式写"什么时候该用" |
| `≥ 1800 字符` | 接近 2000 字段上限 | 关键内容前置，次要场景迁 guidance |

软校验不阻塞保存，只在返回里挂 warnings 让前端 flash。

### 5.4 Skill 调用 Telemetry

`AI_SKILL_INVOCATION` 表（每次 `use_skill` 一条）：`invocationId / skillId / threadId / userId / runId / duplicate / invokedAt`。

后台端点 `GET /ai/api/admin/skills/{skillId}/invocations`：

```json
{
  "skillId": "sys_docx",
  "total": 128,
  "duplicates": 17,
  "duplicateRatio": 0.133,
  "recent": [{ "threadId": "...", "userId": "...", "duplicate": false, "invokedAt": "..." }]
}
```

观察指标：

- **`duplicateRatio` 偏高**（> 30%）= 模型反复读同一 skill，说明 SKILL.md 结构不够清晰，作者需要改写（例如 Quick Reference 前置、步骤编号化）。
- **`total` 长期为 0** = description 没被模型匹配到，考虑重写或下线。

---

## 6. Bundle 解压安全（§7.3）

`SkillAssetService.validateBundle(bytes, allowedPkgs)` 在 AdminSkillBundleApi.upload 时调，
不通过则整包拒绝 + 审计 `skill_bundle.upload_rejected`。

| 校验项 | 阈值 | 错误码 |
|---|---|---|
| 必含 `SKILL.md` 且 ≤ 1MB | 强制 | `bundle.missing_skill_md` / `bundle.skill_md_too_large` |
| 路径含 `..` / 以 `/` 开头 / 含 `\` `:` | 强制 | `bundle.path_traversal` |
| 符号链接 entry | 强制 | `bundle.symlink_forbidden` |
| 单文件解压 > 5MB | 强制 | `bundle.file_too_large` |
| 总解压 > 50MB | 强制 | `bundle.bomb_size` |
| 文件数 > 1000 | 强制 | `bundle.too_many_files` |
| 压缩比 (uncompressed/compressed) > 100 | 强制 | `bundle.bomb_ratio` |
| `pythonPackages` ⊄ 镜像预装集 | 强制 | `bundle.pip_not_in_image` |

镜像预装集来自 `build.sh` 输出的 `installed-packages.json`；AdminSkillBundleApi 启动时
从 `<sandboxWorkDir>/../installed-packages.json` 读入为白名单 `Set<String>`。读不到则跳过
pip 校验（开发期）。

---

## 7. 审计与脱敏

### 7.1 code_exec 专用脱敏

[CodeExecTool.redactAuditDetail](../src/com/gzzm/lobster/tool/builtin/CodeExecTool.java) 返 Map：

```json
{
  "code_sha256": "abc123...",
  "code_length": 4021,
  "input_refs": ["res_xxx", "oa_yyy"],
  "activated_skill": "sys_docx",
  "timeout_seconds": 30,
  "exit_code": 0,
  "walltime_ms": 1234,
  "error_category": "ok",
  "output_artifact_ids": ["art_aaa"]
}
```

`code` 原文永远不入 `AI_TOOL_AUDIT`——需要溯源时只能靠 sha256 定位。如未来合规要保留
原文，按设计 §5.6 单独落加密审计存储 `sandbox_execution_log`（P1 未做，留给 P2）。

### 7.2 其他工具的审计

`ToolExecutor.redactAuditDetail` 默认返 null → dispatcher 用原 args。任何工具都可以
override 做局部脱敏，不需要改 dispatcher。

---

## 8. 取消传播

```
用户在前端按 Stop
  ↓
POST /ai/api/runs/<runId>/cancel
  ↓
AgentRuntime.cancel(runId, CancelReason.USER)
  ├─ cancelFlags[runId].compareAndSet(null, USER)
  │    ← 主循环下一个工具边界会看到，跳出
  ├─ runDao.cancel(runStatus=cancelled, ...)
  └─ Tools.getBean(SandboxService.class).killByRun(runId)   ◄── Track F4 接入点
       └─ runToContainer.remove(runId) 拿 containerName
       └─ dockerRunner.kill(containerName)
            └─ ProcessBuilder("docker", "kill", containerName)
            ← 容器立即 SIGKILL，--rm 自动清理
```

因为用 `Tools.getBean` 软查找而不是 `@Inject`，`AgentRuntime` 对 `SandboxService` 无
编译依赖——沙箱整个子系统可单独移除而不破坏 AgentRuntime。

---

## 9. 限流

| 维度 | 位置 | 默认 |
|---|---|---|
| 用户级 code_exec | `BuiltinToolDefinition.rateLimitPerMinute` → `ToolRateLimiter` | 10/min |
| 其它工具 | `ToolExecutorDispatcher.rateLimitFor(risk)` | READ=120 / WRITE=60 / BATCH=20 / DESTRUCTIVE=10 |
| 组织级配额 | `QuotaService` 钩子 | 仅预留，P0 未接入 |

限流命中后 dispatcher 返 `tool.rate_limit`，不走 executor，不起容器。

---

## 10. Admin API

| 端点 | 用途 | 鉴权 |
|---|---|---|
| `POST /ai/api/admin/skills/{skillId}/bundle/upload` | 新上传 tar.gz（multipart: bundleFile）；skill.version +1 | `AdminGuard.requireAdmin()` |
| `POST /ai/api/admin/skills/{skillId}/bundle/rollback` | 回滚到旧 bundleRef（需再过一次 §7.3） | 同 |
| `POST /ai/api/admin/skills/{skillId}/bundle/enabled` | 启用/禁用 skill（同时 invalidate 解压缓存） | 同 |

**系统 skill 不可写**：`scope=system` 或 `assetBundleRef=fs://...` 的 skill 对 upload / rollback 返
`admin.bundle.system_readonly`。修改系统 skill 必须改文件系统里的 `web/WEB-INF/lobster/deploy/skills/<name>/` 然后重启
（`SystemSkillLoader` 会按 SKILL.md 内容 hash 决定是否 bump version）。`setEnabled` 仍可用。
| `GET/POST /ai/api/admin/skills/*` | 原有 SkillDefinition CRUD；已扩展返回 `assetBundleRef` / `pythonPackagesJson` | 同 |

所有 bundle 操作经 `AuditService.record(...)` 落 `AI_AUDIT_LOG`，actionType 形如
`skill_bundle.upload_ok` / `skill_bundle.upload_rejected`。

---

## 11. 配置项（lobster.xml / LobsterConfig）

| 字段 | 默认 | 说明 |
|---|---|---|
| `sandboxImage` | `lobster-sandbox:py3.11-office-v3` | docker run 的 image 名 |
| `sandboxDefaultTimeoutSec` | 30 | `code_exec` 默认 timeout |
| `sandboxMaxTimeoutSec` | 120 | 上限，入参超过被 clamp |
| `sandboxMemoryMb` | 512 | `--memory` |
| `sandboxCpus` | 0.5 | `--cpus` |
| `sandboxPidsLimit` | 128 | `--pids-limit` |
| `sandboxOutputMaxBytes` | 52428800 (50MB) | 产物总大小上限 |
| `sandboxCodeMaxBytes` | 20480 (20KB) | LLM code 原文上限 |
| `sandboxRatePerMinute` | 10 | 用户级 code_exec 限流 |
| `sandboxWorkDir` | Linux `/srv/sandbox` / Win `d:/lobster/sandbox` | host 端临时工作目录根 |
| `sandboxBundleCacheDir` | Linux `/var/cache/lobster/skill-bundles` / Win `d:/lobster/skill-bundles` | bundle 解压缓存根 |
| `sandboxUid` | 10001 | 容器非 root UID |
| `sandboxDockerBin` | `docker` | docker CLI 路径 |
| `systemSandboxSkillDir` | `web/WEB-INF/lobster/deploy/skills` | 系统 skill（沙箱资源类）扫描根 |
| `systemGuidanceSkillDir` | `WEB-INF/lobster/skills` | 系统 skill（guidance 类）扫描根 |
| `sandboxInstalledPackagesFile` | `WEB-INF/lobster/sandbox-installed-packages.json` | `build.sh` 产物白名单路径；`AdminSkillBundleApi` 校验 bundle.pythonPackages 用 |

**路径解析顺序**（相对路径依次尝试，命中首个 `isDirectory()` 为真的）：
1. webapp 根（生产 Tomcat 下 `ServletContext.getRealPath("/")`）
2. `catalina.base`
3. `user.dir`（开发态）

---

## 12. 运维与故障排查

### 12.1 首次部署 Checklist

- [ ] `docker version` 能跑，daemon 运行中
- [ ] `docker image inspect lobster-sandbox:py3.11-office-v3` 成功（已 pull / load）
- [ ] `LobsterConfig.sandboxWorkDir` 存在且 Tomcat 用户有写权限
- [ ] `LobsterConfig.sandboxBundleCacheDir` 存在且可写
- [ ] 宿主存在 uid=10001 或 bind mount 权限能匹配（`chown 10001:10001 <workDir>` 预先做好）
- [ ] `web/WEB-INF/lobster/deploy/sandbox/out/installed-packages.json` 放到 `<sandboxWorkDir>/../installed-packages.json`
      （bundle pip 校验用）
- [ ] Tomcat 进程能执行 `docker run`（用户在 `docker` 组，或走 socket 权限）

### 12.2 常见故障

| 症状 | 诊断 | 处理 |
|---|---|---|
| `sandbox.docker_start: failed to start docker` | docker 不在 PATH 或没权限 | 改 `sandboxDockerBin` / 把 Tomcat 用户加 docker 组 |
| 容器启动秒退且 stderr 空 | image 不存在 | `docker pull` / 重 build |
| exit_code=137, errorCategory=oom | 脚本内存超 512MB | 调 `sandboxMemoryMb`，或优化脚本 |
| 产物回落但 0 字节 | 脚本写到 `/tmp` 而不是 `/outputs` | SKILL.md 提示 LLM 用 `/outputs` |
| bundle.html 生成失败 (`pnpm add` offline 报错) | 未走 `web-artifacts-bundle` 包装器 | SKILL.md 明确走离线 wrapper |
| `AdminSkillBundleApi` 全部 bundle 被拒 `bundle.pip_not_in_image` | 白名单未部署 | 把 `installed-packages.json` 放到预期路径 |
| cancel 后容器还在跑 | 某个老 runId 未清 | `docker ps \| grep lobster-sbx` 手工 kill；检查 `runToContainer` map 是否残留 |

### 12.3 观测点

| 看什么 | 在哪 |
|---|---|
| 每次 code_exec 的脱敏明细 | `AI_TOOL_AUDIT WHERE action_type='tool.code_exec'` |
| 产物回落的 Artifact | `AI_ARTIFACT WHERE source_run_id=<runId>` 或 `WHERE format IN (docx,xlsx,pptx,pdf)` |
| skill bundle 上传/回滚历史 | `AI_AUDIT_LOG WHERE action_type LIKE 'skill_bundle.%'` |
| 容器残留 | `docker ps --filter name=lobster-sbx-` |
| host 临时目录残留 | `ls <sandboxWorkDir>` 应只有正在跑的 run |

---

## 13. 已知限制（P0 + P1）

1. **单节点**：所有沙箱在同一台 AI 服务节点跑；CPU 饱和即瓶颈。建议 ≤ 20 并发
2. **冷启动 ~500ms**：默认无容器池；可选 `sandboxPoolEnabled=true` 开启 slot 预热池
3. **不可装新 pip / npm 包**：`--network none` 硬约束；升级镜像 = 重发布
4. **无流式回显**：stdout/stderr 等容器结束才一次性传回
5. **审计 code 原文不留**：只 sha256；合规要溯源需 P2 加密存储
6. **Path B 项目体积大**：每次 `web-artifacts-init` cp 模板 ≈ 400MB 到 `<sandboxWorkDir>`——
   确保 host 磁盘空间与 cleanup 正常
7. **stub OA 客户端**：生产环境需替换 `OaFileClientStub` 为真实 OA HTTP 客户端，支持
   `downloadBytes` / `writeNewBytes` / `overwriteBytes`

---

### 13.1 slot 预热池执行模型

`sandboxPoolEnabled=true` 后，沙箱不再为每次执行分配随机 run 目录，而是从固定 slot 中
借一个工作区：

```text
<sandboxPoolRoot>/slot-000/inputs
<sandboxPoolRoot>/slot-000/outputs
<sandboxPoolRoot>/slot-000/work
```

每个 slot 同一时刻只允许一个 `code_exec` 使用。容器仍然按 Docker 隔离运行，不通过
`docker exec` 把多个任务塞进同一个 running 容器。执行流程：

1. Tomcat 启动时按默认 Python / no-skill profile 异步预热每个 slot 的容器；初始预热不把 slot 从可用队列里扣住，请求到达时可直接借出并在必要时同步创建。
2. acquire 一个空闲 slot。
3. 清空 `inputs/outputs/work`，写入本次输入、manifest 和 entry script。
4. 如果 slot 中已有预热容器、profile 匹配且 `docker inspect` 显示容器仍处于 `created`，则直接 `docker start -a`；否则先删除旧容器并按当前 profile `docker create`。
5. 扫描 `/outputs` 并回落 Artifact。
6. 删除本次容器，清空目录，在后台按同一 profile 重新 `docker create`，作为下次预热容器。

profile 包含镜像、用户、资源限制、entrypoint/args、tmpfs 和 mount 列表，因此
`activated_skill` 不同会触发重建，不会错误复用旧的 `/skill/<id>` 挂载。

这个池的目标是减少用户请求路径上的 `docker create` 等待，不改变安全边界。异常、取消、
超时和 OOM 后同样删除旧容器；预热失败只影响下一次冷启动，不影响本次产物回落。若预热容器被外部
`docker rm` 删除，复用前的 inspect 会发现并自动重建，不把一次失败暴露给用户请求。

---

## 14. 扩展 / 变更指南

### 14.1 加新 pip 包到沙箱

1. 改 `web/WEB-INF/lobster/deploy/sandbox/Dockerfile` 的 pip 安装段
2. `bash web/WEB-INF/lobster/deploy/sandbox/build.sh` 重建，自动刷新 `installed-packages.json`
3. 同步到生产 `<sandboxWorkDir>/../installed-packages.json`
4. 任何 skill 新声明 `pythonPackages` 必须 ⊆ 新集合

### 14.2 加新系统 skill

**沙箱资源类**（要跑代码，或要把字体 / 模板 / 脚本挂进 `/skill/<id>/`）：
1. `web/WEB-INF/lobster/deploy/skills/<name>/` 下建目录
2. 放 `SKILL.md`（必须有 frontmatter `name:` + `description:`），按需放 `scripts/`、`templates/`、`canvas-fonts/` 等资源目录
3. 重启 Tomcat → `SystemSkillLoader` 自动落 `SkillDefinition`（`sys_<name>`）

**guidance 类**（纯 prompt）：
1. `web/WEB-INF/lobster/skills/<name>/` 下建目录
2. 只放 `SKILL.md`（无 scripts/）
3. 重启 Tomcat 自动加载

### 14.3 升级沙箱镜像

1. `web/WEB-INF/lobster/deploy/sandbox/Dockerfile` 改动
2. `build.sh` 重建 → 产出新 tag（建议 `py3.11-office-vN+1`）
3. `lobster.xml` 改 `<sandboxImage>` 指向新 tag
4. 同步 `installed-packages.json`
5. 滚动重启 Tomcat 节点；旧容器若在跑不受影响（image 按 name:tag 拉）

### 14.4 切换到别的容器引擎（podman / containerd）

`DockerRunner` 当前直接 shell-out `docker`。换引擎：
- 改 `LobsterConfig.sandboxDockerBin` 指向 `podman` 或其它 CLI
- CLI 参数兼容性校验（--rm / --read-only / --network / -v / --tmpfs 语义要一致）
- OOM 退码 / `kill` 行为若有差异，改 `DockerRunner.run` 的状态转换

### 14.5 加新内置工具

不涉及沙箱，跟已有 WorkspaceResourceTools 风格一致即可：实现 `ToolExecutor`
(可选 override `redactAuditDetail`)，在 `BuiltinToolRegistrar.registerAll()` 里补一行。

---

## 15. Skill 加载与执行语义补充（2026-05-11）

本节记录当前实现里的稳定语义，用于维护 `SKILL.md`、脚本和调用示例时对齐。

### 15.1 加载语义

| 来源目录 | 类型 | runtimeKind | assetBundleRef | 执行含义 |
|---|---|---|---|---|
| `web/WEB-INF/lobster/deploy/skills/<name>/` | 沙箱资源型 skill | `iterative` | `fs://<absolute-path>` | `use_skill` 后可被 `code_exec(activated_skill=...)` 挂载到 `/skill/<skillId>/` |
| `web/WEB-INF/lobster/skills/<name>/` | 纯指导型 skill | `single_shot` | `null` | 只返回 guidance，不挂载沙箱资源 |

`SystemSkillLoader` 启动时扫描两类目录，读取每个子目录的 `SKILL.md`。`name` 来自 frontmatter，缺省使用目录名；ID 为 `sys_<name>`。`description` 与 `when_to_use` 会合并到 `triggerCondition`，作为 `list_skills` 暴露给模型的薄索引。完整 `SKILL.md` 始终保存在 `SkillDefinition.guidance`，不会直接进入 system prompt。

系统 skill 只在文件内容变化时 bump version。`deploy/skills` 下的系统资源目录不会被复制或解压，`SkillAssetService.ensureExtracted` 遇到 `fs://` 时直接返回原始目录。

启动同步以代码目录为准：扫描完成后，`scope=system`、`skillId` 以 `sys_` 开头、但本次没有在 `deploy/skills` 或 `skills` 目录中发现的旧 skill 会被自动置为 `enabled=false`，不会物理删除；后续如果同名 `SKILL.md` 又回到代码目录，重启时会重新启用并按文件内容更新。`org` skill 以及管理端创建的 `sk_` skill 不参与这类文件系统对账。

### 15.2 `use_skill` 与资源读取

当前实现中，`use_skill(skillId)` 每次都返回完整 `SKILL.md` guidance。重复调用仍会记录 `AI_SKILL_INVOCATION.duplicate=true`，但不会省略正文，因为历史 tool result 可能已被压缩、折叠或不在当前 send-view。

`use_skill` 会尝试扫描 skill bundle，给 guidance 追加“可用资源”索引。模型需要读取 bundle 内的 Markdown、脚本或配置文件时，应调用 `read_skill_resource(skillId, path)`。

约束：

- 调用前必须已经 `use_skill(skillId)`。
- `path` 必须是 bundle 内安全相对路径，不能是 `/skill/...` 绝对路径，也不能包含 `..`。
- 该工具只读文本资源，不用于读用户上传文件、workspace artifact 或 OA 文件。
- 要执行脚本时，不用 `read_skill_resource`，而是在 `code_exec` 里使用 `/skill/<skillId>/...` 容器绝对路径。

### 15.3 `code_exec(activated_skill=...)`

`activated_skill` 只接受已经在当前 thread 中 `use_skill` 过的 skill。未激活会返回：

```text
code_exec.skill_not_activated: call use_skill('<skillId>') first
```

执行时只挂载一个 skill bundle 到 `/skill/<skillId>/`。因此脚本示例必须写容器内路径，例如：

```bash
python /skill/sys_data-cleaning-report/scripts/profile_table.py ...
```

不要在 `SKILL.md` 中让模型拼 host 路径，也不要把 `read_skill_resource` 当作执行入口。

### 15.4 `/inputs`、`/work`、`/outputs` 约定

每次 `code_exec` 都是独立容器：

- `/inputs`：只读。由 `input_refs` staging 得到，包含系统生成的 `/inputs/manifest.json`。
- `/work`：可写。放业务 JSON spec、中间脚本临时文件、scratch 数据。
- `/outputs`：可写。只放最终要回落为 artifact 的交付物。

`input_refs` 的真实文件路径必须以 `/inputs/manifest.json` 的 `path` 字段为准。不要在 skill 示例中写死 `/inputs/00-data.xlsx`、`/inputs/spec.json`、`/inputs/meeting.json` 这类路径。`00-xxx` 是当前 staging 的安全文件名实现细节，业务脚本应通过 manifest 选择文件。

业务参数 JSON 不属于用户上传输入，应写到 `/work/*.json`。表格分析类脚本应默认读取 `/inputs/manifest.json` 并自动选择表格文件；只有用户明确指定某个上传文件时才传 `--input <manifest 中的 path>`。

### 15.5 当前系统 skill 清单

完整清单见 [SYSTEM-SKILLS.md](SYSTEM-SKILLS.md)。该清单按“沙箱资源型”和“纯指导型”列出当前内置 skill、ID、类型和功能说明。

---

## 16. 变更纪要

| 日期 | 变更 |
|---|---|
| 2026-04-22 | P0 初稿（设计文档定稿） |
| 2026-04-23 | Track F + A + B + C 全部落地；office-X 薄 skill 替换为 Claude 官方 skill；SystemSkillLoader 启动扫描；Dockerfile 扩容支持 web-artifacts-builder 离线工作流；frontend-design guidance 加 Path A/B 交付约束 |
| 2026-04-23 (review) | 修 7 处关键 bug：<br>1. `SkillAssetService.invalidate` 误删 fs:// 系统 skill 源目录 → 加 cacheRoot 校验<br>2. `DockerRunner.StreamDrainer` 满 buf 后 break 导致容器 stdout 管道堵塞 → 改 drain-discard<br>3. `SandboxService.harvestOutputs` 超限后部分 Artifact 落库未回滚 → 改两阶段（先统计再落）<br>4. `SystemSkillLoader` 每次启动 version+1 → 加 `contentChanged` 判断<br>5. 相对路径 `WEB-INF/lobster/skills` 在生产 Tomcat 找不到 → 传入 `ServletContext.getRealPath("/")` 做 webappRoot 候选<br>6. `AdminSkillBundleApi` 放任系统 skill 被覆盖上传 → `rejectIfSystem` 拒绝<br>7. 安装包白名单路径隐晦（sandboxWorkDir/..）→ 独立 `sandboxInstalledPackagesFile` 配置<br>外加：Dockerfile 中多行 RUN 夹 `# 注释` + 混用 heredoc 两个会让 build 失败的经典错误 → 拆出 `web-artifacts-init/-bundle` 为独立文件 COPY |
