# 在精简 LXC 上部署 agentd(design §9 阶段 3) 调度器跑在一台**不装任何 agent CLI** 的 Debian 12 LXC(`agentd-01`)上;所有终端(win10、oregon、mac…)都是远程启动:经 Warpgate SSH(`transport: warpgate-ssh`),或经 Tailscale 直连并以 Warpgate 兜底(`transport: direct-ssh`,PAD-12,§9)。LXC 只做调度、回帖、通知。 实际部署(2026-10-21,PAD-11):`agentd-01` = PVE CT 600,Debian 12,tailnet `100.69.78.124`,Warpgate target `agentd-01`,登录 **root**(Vault skill `ssh-agentd-01`;没有另建部署用户,agentd 以 root 运行)。下面是实际做过的步骤;从 oregon 发命令都经 `agent-access.py … run --target agentd-01 --timeout N -- bash -c '…'`。 ## 1. 系统包 LXC 交付时已有 `python3`(3.11)、`python3-yaml`、`openssh-client`、`git`、`cron`(active);win10 helper 需要的 `iconv gzip base64 sha256sum` 也都在。从零装: ```bash apt-get update && apt-get install -y python3 python3-yaml openssh-client git cron ca-certificates ``` `jsonschema` 可选,没有就用内置子集校验器。`direct-ssh` 要用的 `ssh`、`ssh-agent`、`ssh-add`、`ssh-keygen` 都在 `openssh-client` 包里(Debian 12:OpenSSH 9.2)。 ## 2. 网络 | 去向 | 用途 | |---|---| | `nas.tail9531b.ts.net:8443`(tailnet) | Agent Vault 身份配置 `identity/agent.json`(含 vault token 与 Warpgate 主机公钥)→ LXC 已在 tailnet | | Vault(身份配置里的地址) | 取 Plane token、Warpgate 密码 | | Warpgate SSH `115.28.210.101:2222` | `warpgate-ssh` 终端的中继,以及 `direct-ssh` 的兜底(LXC → win10 / oregon) | | tailnet:win10 `100.123.209.19:22`、oregon `100.127.58.78:22` | `direct-ssh` 直连(PAD-12,§9) | | `https://plane.kappiee.com` | Plane API | | `https://git.kappiee.com` | `git pull`(公开仓库,无需凭据) | ## 2.1 到 NAS 的直连路径(OPNsense 静态端口 NAT,2026-10-21) PVE 站点是双层 NAT(OPNsense → 192.168.1.1),默认的随机源端口会让 tailscale 打洞失败、只能走 DERP(此前取 vault 身份端点 30/30 超时)。修法: - **OPNsense 出站 NAT 规则**(用户在 GUI 里添加,因为 opnsense 中继账号 kappiee 无权限,我们只读):描述 `Tailscale agentd-01 static-port`,接口 WAN,源 `10.77.10.249/32`,目标 any,协议 UDP,勾选"静态端口"(`staticnatport 1`)。从 kpe-edge-01 的 `Tailscale edge static-port`(10.77.10.110/32)规则克隆而来。加规则后要清掉旧状态(Firewall → Diagnostics → States,按源 IP)。 - **LXC tailscaled 用 UDP 41642**:`/etc/default/tailscaled` 里 `PORT="41642"`(备份 `.bak-20261021144838`),必须与 edge 的 41641 不同,否则两台机器的静态端口映射会在同一 NAS 端点上撞元组。 - **验证**(LXC 上):`tailscale ping 100.89.188.28` 应得 `pong from nas … via 223.102.82.206:1549`(不是 `via DERP`);`tailscale netcheck` 应 `UDP: true`、`MappingVariesByDestIP: false`;身份端点循环(注意 `agent.json` 含 bearer token,只看状态码和耗时):`for i in $(seq 30); do curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 15 https://nas.tail9531b.ts.net:8443/identity/agent.json; done`。2026-10-21 实测 30/30 成功、中位数 0.089 s,ping 5 ms。 - **回滚**:在 OPNsense GUI 删掉该规则(Firewall → NAT → Outbound)并清状态;LXC 上还原 `/etc/default/tailscaled.bak-20261021144838` 后 `systemctl restart tailscaled`。回滚后路径退回 DERP,调度器应同时切回 oregon(runbook)。 ## 3. Agent Vault 客户端 只复制程序文件(客户端没有落盘的 token 缓存;oregon 目录里的 `agent-service-check.py` 是运维工具,不需要): ```bash # 在 oregon 上:两个文件 base64 后经中继写到 LXC(一次 run 调用,sha256 与 oregon 一致) /root/.local/share/server-access-client/agent-access.py # 700 root /root/.local/share/server-access-client/agent-access-askpass.py # 700 root,必须与 agent-access.py 同目录且可执行 # 在 LXC 上验证: cd /root/.local/share/server-access-client python3 -I agent-access.py --config https://nas.tail9531b.ts.net:8443/identity/agent.json status # "authenticated": true python3 -I agent-access.py --config https://nas.tail9531b.ts.net:8443/identity/agent.json list # 含 win10、oregon、agentd-01 ``` - 身份是 tailnet 上的 URL(`--config https://…/identity/agent.json`),不落盘。 - **Warpgate known_hosts 不用配**:客户端每次用身份配置里的 `warpgate.host_key` 生成临时 known_hosts。LXC → oregon 一次中继约 9 s。 ## 4. 代码与配置 ```bash cd /opt/plane-agentd && git pull # 仓库已 clone 在 /opt/plane-agentd(https://git.kappiee.com/kappiee/plane-agentd.git) mkdir -p /var/lib/plane-agentd /var/log/plane-agentd && chmod 700 /var/lib/plane-agentd python3 -m unittest && python3 cli/agentd check-config ``` 不需要本地配置文件,全部在仓库里(部署可复现): - `config/agentd.yaml` = agentd-01 的实时配置:`state_dir: /var/lib/plane-agentd`(状态、心跳、锁),`log_dir: /var/log/plane-agentd`(`agentd.log`),`helpers_dir: tools/win10`,`vault.client: ~/.local/share/server-access-client/agent-access.py`(root 下即 `/root/...`)。终端 win10(direct-ssh + 兜底 warpgate-ssh/windows/schtasks)和 oregon(direct-ssh + 兜底 warpgate-ssh/linux/nohup,`run_root: /home/ubuntu/.gwent-board/agentd-runs`,`bins.claude`);没有 local 终端。`agentd.ssh_key: AGENTD_SSH_KEY`,`agentd.ssh_dir: /run/plane-agentd/ssh`(ControlMaster 套接字和生成的 known_hosts,agentd 自己建,权限 0700)。 - `tools/win10/win10-ps.sh`、`win10-put.sh`:win10 helper(vault 客户端目录取 `AGENT_ACCESS_HOME`,默认 `~/.local/share/server-access-client`;身份取 `AGENT_ACCESS_CONFIG`)。相对 `helpers_dir` 按仓库根解析。 - `config/protocols/gwent-board-protocol.md`:看板协议(拼进每轮 prompt,LXC 本机读取)。来源是 gwent 仓库 `design/board-protocol.md`,**协议更新时复制过来提交**,然后 LXC `git pull`。 - `config/agentd.oregon.yaml`:oregon 作调度器时的配置(回滚用,见 runbook)。 - 相对路径(`state_dir`/`log_dir`/`helpers_dir`/`protocol_file`)都按仓库根解析;远程终端的 `repo`/`run_root`/`bins` 必须是远程绝对路径。 ## 5. 冒烟与切换(实际顺序) 1. LXC 上手工跑一次(oregon cron 仍在跑,LXC 未排程;先确认 Plane 上没有 Todo 项可派,否则两个调度器会同时派单): ```bash cd /root/.local/share/server-access-client && python3 -I agent-access.py --config https://nas.tail9531b.ts.net:8443/identity/agent.json exec \ --secret-env PLANE_OREGON_API_TOKEN=PLANE_OREGON_API_TOKEN --secret-env PLANE_WINDOW10_API_TOKEN=PLANE_WINDOW10_API_TOKEN \ --timeout 250 -- python3 /opt/plane-agentd/cli/agentd tick --once # 空闲:无输出,heartbeat errors=[] ``` 2. oregon:备份 crontab(`~/.gwent-board/crontab.backup-20261021-pad11`),删掉 `agentd-tick.sh` 那一行;`flock ~/.gwent-board/agentd.lock true` 等正在跑的 tick 结束。 3. 状态:`~/.gwent-board/agentd/agentd-state.json`(oregon)→ `/var/lib/plane-agentd/agentd-state.json`(LXC,600;base64 经中继写入,比对 sha256)。在途运行的 handle 是终端上的绝对路径,新调度器照常 poll/collect。 4. LXC 上 root 的 crontab(只有这一行): ``` */2 * * * * /opt/plane-agentd/cron/agentd-tick-lxc.sh ``` `cron/agentd-tick-lxc.sh` = 拿 `/var/lib/plane-agentd/cron.lock`(`flock -n -E 75`)后执行 `timeout 900 python3 -I /root/.local/share/server-access-client/agent-access.py --config exec --secret-env PLANE_OREGON_API_TOKEN=… --secret-env PLANE_WINDOW10_API_TOKEN=… --secret-env AGENTD_SSH_KEY=AGENTD_SSH_KEY --timeout 850 -- python3 /opt/plane-agentd/cli/agentd --config /opt/plane-agentd/config/agentd.yaml tick --once`,输出追加到 `/var/log/plane-agentd/cron.log`;Vault 取不到 `AGENTD_SSH_KEY` 时不带它重跑一次(§9)。 注意:经中继装 crontab 时别在 `set -e` 下写 `(crontab -l | grep -v …; echo …) | crontab -`——没有旧 crontab 时 grep 返回 1,子 shell 提前退出,装进去的是空 crontab。直接 `printf '%s\n' '<行>' | crontab -`。 5. 验证:`/var/lib/plane-agentd/heartbeat.json` 的 `at` 每 2 分钟更新。 6. 冒烟经 LXC 调度器:在 oregon 上 `cli/agentd smoke GWENT oregon --watch`(`--watch` = 只建项并等待,不在本机 tick;oregon 上 `cli/agentd` 默认读 `config/agentd.yaml`,CLI 只需要 Plane token)。 - token 只经 `exec --secret-env` 进 agentd 进程内存;agentd 起的中继子进程环境里去掉了所有 `token_key`(`Config.scrubbed_env()`)。 - `--secret-env` 的完整列表:`cli/agentd check-config` 最后一行。 - **任何时候只能有一个调度器排程**(oregon 的 crontab 里不能有 `agentd-tick.sh`)。 ## 6. 日志与心跳(LXC) | 路径 | 内容 | |---|---| | `/var/log/plane-agentd/agentd.log` | 每 tick 的动作、错误 | | `/var/log/plane-agentd/cron.log` | cron 输出(空闲时为空;`skip: another tick holds …` 无害) | | `/var/lib/plane-agentd/heartbeat.json` | `at` 超过 10 分钟不更新 = 调度器没在跑;`down` = 本 tick 中继不通而跳过的终端;`launching` = 启动未确认的运行 | | `/var/lib/plane-agentd/agentd-state.json` | 全部工作项状态 | | `/var/lib/plane-agentd/agentd-state.json.lxc-smoke` | 切换前手工冒烟 tick 产生的空状态(可删) | | 终端上 `//` | 每轮的 `prompt.md run.sh/run.ps1 pid.txt launch.pid exit.txt stderr.txt` + 执行器输出;重新启动的运行在 `-r2/` | ## 7. 加一个终端(例:mac) 1. Warpgate 加 SSH target(如 `mac`),给 `agent` 身份授权;`agent-access.py … run --target mac --timeout 30 -- bash -c 'echo ok; uname'` 能通。 2. 在终端上装好 agent CLI 并登录(Claude Code / Codex),clone 工作仓库;记下二进制绝对路径。 3. 配置: ```yaml mac: transport: warpgate-ssh target: mac os: darwin # linux | darwin;darwin 用 perl POSIX::setsid 脱离会话(无 setsid 命令) launch: nohup repo: /Users/me/src/gwent run_root: /Users/me/.agentd-runs bins: {claude: /Users/me/.local/bin/claude} plane_user: mac token_key: PLANE_MAC_API_TOKEN concurrency: 1 executors: [claude-code] ``` 远程 shell 是目标账户的登录 shell(bash/zsh 均可,agentd 发的是 `bash -c '