# @botlearn-course/daemon

BotLearn Course 托管沙盒的内部 daemon 包。它只在 Agent Service 托管 sandbox 内运行：
`botlearn-sandbox-supervisor` 以固定 argv 启动 `botlearn-course-daemon agent-service session`，
daemon 通过 WebSocket 与 Agent Service 保持 session，接收 `turn.start` 后在隔离 workspace 内调用
托管 runtime，并把过程事件与产出文件候选经 Agent Service 回传课程服务。本包不提供学员本机安装、
登录或轮询入口。

- 零 production 依赖，只用 Node 内建模块（Node >= 20）。
- 每轮只接受服务端组装的 `runtime_prompt`；缺失或校验失败时本轮显式失败。

## 托管启动

生产环境只允许由 Agent Service 以固定 argv 启动 `botlearn-sandbox-supervisor`，通过 stdin 接收一次性
bootstrap。sandbox 中 daemon/runtime 分别降权到 `botlearn-control`/`user` UID；继承 `NoNewPrivs=1`、
无法使用 sudo 的平台保留 root control daemon，由 root-owned 固定 launcher 清空附加组后直接降权为
`user`。两条路径都为 DeepSeek 设置 `no_new_privs`，runtime 用户本身没有 sudo 权限。托管启动链只执行
`/opt` 下的固定 Node、supervisor、daemon、launcher 与 DeepSeek 文件，不信任 provider 会开放给 runtime
写入的 `/usr/local/bin`。
`agent-service session --bootstrap-stdin` 属于受 supervisor 保护的内部协议，不应直接暴露给
课程任务或 workspace shell。

veFaaS Origin 的托管 workspace 使用用户级 NAS backing image。root bootstrap 完成 NFS、marker、
ext4、`fuse2fs` mount 和固定 3 GiB 校验后，daemon 才发送 `sandbox.ready`。RuntimeSession 使用稳定路径
`/workspace/sessions/{runtime_session_id}/workspace`，Sandbox generation 只参与写入隔离。磁盘写满
统一上报 `workspace_user_quota_exceeded`。pause 和 cleanup 进入 drain 后，daemon 先停止 runtime
writer，再通过 root launcher 执行 ext4 sync、`fuse2fs` unmount、backing image 和 NFS parent fsync；完成后
才发送 `sandbox.drained` 并退出。

## 支持的 runtime

run 的 `runtime.id` 决定用哪个 adapter 执行，没有默认回退。runtime 不可用时 run 直接失败。

| runtime id | 名称 | 说明 |
| --- | --- | --- |
| `deepseek-tui` | DeepSeek TUI / Codewhale | 托管镜像内固定版本；注入零依赖 `report_progress` MCP，`botlearn` 是保留 server key |
| `pi-agent` | Pi Agent | 固定 `@earendil-works/pi-coding-agent@0.85.1`，要求 Node.js >=22.19.0；每轮使用独立 RPC（远程过程调用）进程 |
| `fake` | 假 runtime | 仅测试/演示用：产出确定性文本与一个 `draft.md`。只有 `BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME=1` 时才注册 |

模型凭据由 Agent Service 按 activation 签发短期代理授权，daemon 不保存长期 provider key。

### Pi Agent 会话与协议验证

每轮使用独立 RPC 进程，原生会话保存在当前工作区的 `.botlearn-pi-runtime` 隐藏目录。
daemon 持久化原生会话文件名，用显式 `--session` 续接；启动后先用 `get_state` 校验，再发送
本轮输入。正常续聊保留原生工具调用与结果，仅新建、文件明确缺失或重新生成时注入 Course
持久对话。空文件、损坏记录、权限错误和协议失败明确报错，不自动重跑任务。
系统提示、模型选择和授权技能随每轮进程刷新，继续禁用隐式发现；托管临时 HOME 清理不影响会话文件。
托管执行沿用固定启动器、短期模型代理授权及显式课程技能目录；需部署匹配的镜像和 Daemon，
并通过 `AGENT_SERVICE_RUNTIMES` 开放。

真实程序协议测试使用本地模型替身，不读取 `.env` 或调用付费模型：

```bash
BOTLEARN_TEST_PI_BIN=/absolute/path/to/pi \
BOTLEARN_TEST_DEEPSEEK_BIN=/absolute/path/to/codewhale \
BOTLEARN_TEST_DEEPSEEK_OLD_BIN=/absolute/path/to/deepseek-0.8.39 \
pnpm exec vitest run src/runtimes/__tests__/runtime-binaries.test.ts
```

未提供对应二进制路径时跳过该项。2026-09-08 的进程内系统指令刷新门禁在 `0.8.39`
和 `0.9.12` 均失败，详见[历史实测报告](../../docs/qa/2026-09-08-pi-runtime-and-deepseek-upgrade.md)。
该结果不能作为新版升级验收通过的证据。

## 环境变量

托管 sandbox 的变量由 supervisor 与 Agent Service 设置，不应手工配置。

| 变量 | 说明 |
| --- | --- |
| `BOTLEARN_AGENT_SERVICE_WS_URL` / `BOTLEARN_AGENT_SERVICE_SANDBOX_ID` / `BOTLEARN_AGENT_SERVICE_SANDBOX_TOKEN` | 未使用 `--bootstrap-stdin` 时的 session 连接参数 |
| `BOTLEARN_DAEMON_HOME` | 状态根目录；托管时由 supervisor 设为 control home，未设置时为 `~/.botlearn-course/daemon` |
| `BOTLEARN_DAEMON_DEBUG` | 非空时输出 debug 级日志 |
| `BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME` | 设为 `1` 时启用测试用 `fake` runtime |

## 安全说明

- 日志、transcript、错误信息在写出前都会经过脱敏（token / api key / 密码等字段与常见
  token 形态一律替换为 `[REDACTED]`）。
- 文件扫描只访问 workspace 内路径；包含 `..`、绝对路径或反斜杠的路径会被拒绝。daemon
  只上报相对路径、大小、sha256 和有界脱敏 preview，文件正文始终留在托管 sandbox
  的持久 workspace，不上传 Course Service 或对象存储。扫描或 metadata 上报失败不影响 run 终态。
- 托管 Practice Agent 的学员输入附件由 Course Service 为当前 activation 签发单对象、短时效的
  存储源站 URL；daemon 直接下载并复核 grant 集合、大小、SHA-256、MIME 与图片签名。URL 只留在
  control process 内存，不进入 runtime env、prompt、workspace state、transcript、事件或日志。
- 每个 run 在独立 workspace 内执行并有超时上限；runtime 进程在取消/超时后会被终止
  （SIGTERM → SIGKILL）。
- `report_progress` MCP 子进程以 clean environment 启动，不继承 Course 控制字段或模型凭据；
  transcript 与 Course event 只保留严格归一化后的 `summary` / `status`，不保存原始 tool arguments。
- 浏览器执行流使用 `agent-stream/0.1`。推理文本经过凭据过滤和长度限制；工具在服务端投影为
  学员可见的安全动作。教师审计分别使用有界 `run.trace` 和 `run.observation`，权限由 Course
  Service 控制。原始工具参数、结果和供应商事件包不会直接进入浏览器。
- 本包 production 依赖为零，发布 tarball 只包含 `dist/`、`README.md`、`package.json`、
  `LICENSE`（CI 发布前强制校验）。
- 托管 session 的 control token/reconnect state 位于 runtime UID 不可访问的 `0700` control home；
  DeepSeek 原生线程与事件目录位于学员 `workspace` 的同级目录
  `workspace.botlearn-deepseek-runtime`，继承既有父目录的激活访问栅栏，避免混入学习文件扫描。
  首次启动会将旧 `workspace/.botlearn-deepseek-runtime` 在同一卷内原子迁移；符号链接或新旧
  目录并存时明确失败，不覆盖数据。该一次性迁移由 daemon 维护，在存量目录迁移完且旧版本
  退出后可移除。第三方运行时仍可能生成 `.codewhale/state` 等项目状态，不能将其作为学习材料。
  runtime workspace 独立可写。activation-scoped Skill Provider binding 只留在 daemon control
  process 内存；DeepSeek 通过每轮注册的 `courseskills_*` 原生外部工具按需读取当前授权
  `SKILL.md`/reference。0.8.39 保留同步 MCP 目录的 clean-env Unix Socket relay；其他版本
  必须声明 `external_tools` 能力，缺失时明确失败。两条路径复用同一 Course 授权与读取逻辑，
  endpoint/token 不进入 runtime env、MCP config、state、workspace 或日志。
  原生工具分页契约 `course-skills-native/0.2`：正文与引用按 Unicode 码点、目录按完整条目
  读取。`nextOffset` 非空时继续读取完整内容，每页完整 JSON 不超过 10000 个 JavaScript 字符。
  小正文与小目录未指定分页参数时保留原结果形状；字段与边界见
  `docs/specs/agent-service-sandbox-daemon-websocket.md` §9.2.2。
  系统提示词只使用服务端下发的 `runtime_prompt`。长期模型 provider key 留在 Agent Service，runtime
  只拿 generation-scoped proxy grant。

## 流式缓冲约束

引擎适配器在入队前按 100ms 或 512 个 UTF-16 单位合并同类推理、正文增量；不会拆开完整的
Unicode 码点。工具、阶段、进度、正文/推理切换及最终消息都会触发刷新，保持原顺序。
本地 transcript 保存合并后的增量文本，不再逐个保留这些增量的原始事件包；工具事件包仍按
既有路径处理。

单个消费循环最多保留 256 个待处理块，另有一个在途块和不足 512 单位的文本缓冲。
达到上限会取消引擎，停止接收新增块，已有块收尾后以 `runtime_error` 和 `runtime_event_backlog`
说明明确失败。正常执行不丢文本，取消时停止新增实时展示事件，并刷新已有的有界推理审计。
已经发送的事件仍等待原有传输确认；网络失联时仍受传输层超时约束。

这些值由 Daemon 适配器代码统一拥有，随版本生效和回滚，不增加环境变量或平台设置。
影响学员练习展示及教师审计交付时效；Course Service 的 AgentRun 状态权威、用户权限、
Web/App 事件契约和人机决策权保持不变。发布仅需更新 Daemon，无数据库或服务端协议迁移。

## 发布

在 GitHub Actions 中手动运行 `Publish Course Daemon`。`version` 留空时，CI 读取 npm 的
`latest` 版本并自动增加 patch；包尚不存在时首次发布为 `0.0.1`。只有需要指定
major、minor 或 prerelease 时才填写精确 SemVer（例如 `1.0.0` 或 `1.1.0-rc.1`）。

所有正式版发布到 npm dist-tag `latest`，prerelease 发布到 `next`。CI 会串行化发布并在
上传前确认目标版本尚不存在，避免并发任务重复发布同一个版本。

## 托管 Codewhale 代理凭据

`deepseek-tui` 流式事件解析同时支持 0.8.39 和 0.9.12，以及既有平铺事件格式。
0.9.12 的外层 `kind` 表示事件名；推理类型从内层 `payload.kind` 或
`payload.item.kind` 读取。推理增量持续推送并按段保存，完成摘要不重复追加正文。

托管启动前为 DeepSeek 0.8 的 `.deepseek` 与 Codewhale 0.9 的 `.codewhale`
准备状态目录。目录通过 `1770` 权限允许运行组创建任务状态；临时配置根目录保持
`0750`，配置文件保持 `0640`。重复初始化保留已有状态，不改写课程进度或工作区文件。

托管 `deepseek-tui` 每轮从 activation 的 `DEEPSEEK_BASE_URL` 生成临时
`[providers.deepseek]` 路由，并用 `api_key_env = "DEEPSEEK_API_KEY"` 显式绑定当轮凭据。
子进程不再接收 URL 环境覆盖，避免 Codewhale 0.9.12 将其判定为未绑定的新路由。
未启用视觉模型时同样生成该配置；配置随本轮进程清理，凭据不会写入供应商路由或命令行。
托管进程固定关闭 Codewhale 自带遥测，BotLearn 原有执行事实记录继续使用既有通道。
这些规则由适配器代码拥有，更新 daemon 后生效，无需新增部署配置或数据库迁移。

镜像内的真实二进制回归入口是 `scripts/smoke-codewhale-managed.mjs`：构建 daemon 后，
以 `0:2000` 运行并挂载整个包目录，添加 `--network none` 和
`--add-host model-proxy.test:127.0.0.1`。验证非 localhost 代理、凭据轮换、UID 1001
文件写入、会话恢复和第二轮系统提示词更新；模型使用本地确定性替身。


### 文件元数据批量登记

运行收尾以 `files` 数组批量登记本轮新增、修改的文件；手动资源刷新使用同一接口全量扫描。
每批最多 50 项、元数据数组最多 192 KiB，长文本预览按字节大小分批而不裁短；预览仍最多 4000 字符。
整个扫描后上报阶段默认最多等待 10 秒。每批服务端事务提交后确认，普通运行保留上报失败日志并完成。
详情与三组件配套发布要求见 `docs/specs/agent-service-sandbox-daemon-websocket.md` 第 12.3 节。
