Files
plane-agentd/docs/deploy-lxc.md
T

176 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 在精简 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 <identity> 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 产生的空状态(可删) |
| 终端上 `<run_root>/<run_id>/` | 每轮的 `prompt.md run.sh/run.ps1 pid.txt launch.pid exit.txt stderr.txt` + 执行器输出;重新启动的运行在 `<run_id>-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 '<script>'`);终端需有 `bash base64 nohup pgrep pkill`(Linux 另需 `setsid`,macOS 需 `perl`)。
4. Plane:建用户 `mac`,token 存 Vault 键 `PLANE_MAC_API_TOKEN`;项目 `routing.by_assignee` 加 `mac: mac`,`default_executor` 加 `mac: claude-code`;`cron/agentd-tick-lxc.sh` 加 `--secret-env PLANE_MAC_API_TOKEN=PLANE_MAC_API_TOKEN`。
5. 提交、推送、LXC `git pull`;`cli/agentd check-config`,然后 `cli/agentd smoke GWENT mac --watch`。
## 8. Webhook 接收端(PAD-7)
1. 网络:Plane VM(PVE VM 506 plane-02,10.77.10.69,prime-cli 安装;没有 VM 505)与 LXC 10.77.10.249 同在 10.77.10.0/24,接收端只听 `10.77.10.249:8790`(LXC 内无防火墙)。**Plane 侧前提**:Plane 默认拒绝内网 webhook 地址("The webhook URL is not allowed. Provide a publicly reachable HTTP or HTTPS URL."),要在 Plane 的 api(创建时检查)与 worker(每次投递检查)容器环境里设 `WEBHOOK_ALLOWED_IPS=10.77.10.249/32`(逗号分隔 IP/CIDR)并重建相关容器(design §10)。Plane 3.3.3 没有 compose override 文件:prime-cli 由 `/opt/plane/plane.env` 渲染 `/var/plane/docker-compose.yml`,两处都已有空的 `WEBHOOK_ALLOWED_IPS`(compose 里每个服务一行 `WEBHOOK_ALLOWED_IPS: ""`)。做法:备份两个文件为 `.bak-pad7-<时间戳>` → 把 `plane.env` 的 `WEBHOOK_ALLOWED_IPS=` 与渲染 compose 里的空值改为 `10.77.10.249/32` → `docker compose -p plane -f /var/plane/docker-compose.yml up -d --no-deps api worker webhook-consumer outbox-poller automation-consumer beat-worker`(先 `--dry-run`,确认只重建这 6 个;商业版的 v2 投递走 webhook-consumer/worker,创建时校验在 api;不要用 prime-cli,它要 TTY。compose 里 `WEBHOOK_ALLOWED_HOSTS: ${WEB_URL}` 不带 env 文件时为空,prime-cli 起的容器也是空值,属现状)。**状态(2026-10-22):已应用**(备份 `*.bak-pad7-20261022-011453`,6 个容器 `WEBHOOK_ALLOWED_IPS=10.77.10.249/32`),webhook `c039c471-e78a-49c9-b009-5cf77a389ad6` 已建并启用(v2;签名与接收端一致,见 design §10),接收端 active/enabled。纯 http 本身是允许的(http/https 都行)。
2. 密钥:Plane 建 webhook 时生成、只显示一次 → `tools/plane-webhook.py create http://10.77.10.249:8790/plane` 直接写进 Vault `PLANE_WEBHOOK_SECRET`(建出来是停用状态;在 oregon 上以 kappiee 管理员会话运行,见 runbook "Webhook")。核对长度:`agent-access.py … exec --secret-env S=PLANE_WEBHOOK_SECRET -- python3 -c 'import os;print(len(os.environ["S"]))'`。
3. 代码与服务(LXC):
```bash
cd /opt/plane-agentd && flock /var/lib/plane-agentd/cron.lock git pull
cp systemd/plane-agentd-webhook.service /etc/systemd/system/ && systemctl daemon-reload
systemctl enable --now plane-agentd-webhook
curl -s http://10.77.10.249:8790/healthz # {"ok": true, ...}
tail /var/log/plane-agentd/webhook.log # listening on http://10.77.10.249:8790/plane (tenants: GWENT)
```
单元文件复用 cron 脚本的 vault 客户端调用方式:`python3 -I /root/.local/share/server-access-client/agent-access.py --config <identity> exec --secret-env PLANE_WEBHOOK_SECRET=PLANE_WEBHOOK_SECRET --timeout 315360000 -- python3 /opt/plane-agentd/cli/agentd … webhook-serve --bind 10.77.10.249 --port 8790`,`Restart=always`。tick 由接收端经 `systemd-run --scope … cron/agentd-tick-lxc.sh` 拉起(同一把 `cron.lock`,`flock -n -E 75`)。
4. 启用 Plane 侧:`tools/plane-webhook.py patch <id> is_active=true`,然后 `systemctl restart plane-agentd-webhook`(若第 2 步之后密钥变过)。
5. 验证:随便改一个 GWENT 工作项 → `webhook.log` 出现 `decision=tick` 和 `tick start`/`tick done`;错签名请求得 401 且没有 tick;`journalctl -u cron` 里 cron 仍每 2 分钟一次;`systemctl restart plane-agentd-webhook` 后 `/healthz` 恢复。
## 9. 直连 SSH(PAD-12)
LXC 经 Tailscale 直连终端的 sshd,不经过 Warpgate(design §9"终端通道")。每个终端单独配置,Warpgate 留作兜底。前提:终端在 tailnet 上,LXC(`100.69.78.124`)能连到它的 22 端口。实测:win10 `100.123.209.19`(Windows OpenSSH,账号 administrator,直连 RTT 27 ms),oregon `100.127.58.78`(账号 ubuntu,RTT 233 ms)。
**上线顺序**:`cron/agentd-tick-lxc.sh` 带着 `--secret-env AGENTD_SSH_KEY=AGENTD_SSH_KEY`。agent-access.py 没有可选密钥,取不到键时报 `credential_unavailable`,整个 exec 失败;cron 脚本认出这种情况后会去掉这个参数再跑一次(cron.log 记一行 `AGENTD_SSH_KEY not available from Vault …`),所以先 pull 也不会停派单,只是直连终端一直走兜底。仍然建议第 1–3 步做完再 pull,免得每个 tick 多取一次 Vault。
1. **生成专用密钥**(在 LXC 上;私钥只进 Vault,不留在磁盘上):
```bash
umask 077; d=$(mktemp -d -p /dev/shm) # tmpfs:私钥不落到磁盘(磁盘上 shred 在日志式/写时复制文件系统上不可靠)
ssh-keygen -q -t ed25519 -N '' -C plane-agentd@agentd-01 -f "$d/agentd"
cat "$d/agentd.pub" # 公钥,下面两步要用
```
私钥 `$d/agentd` 原样(多行文本)写进 Vault 键 **`AGENTD_SSH_KEY`**:在 Vault UI 里粘贴,或者用与 `tools/plane-webhook.py` 的 `vault_store()` 相同的接口(`POST /v1/credentials`,agent 身份),读文件直接写,不打印。值里的换行被转义成 `\n` 也可以,agentd 会还原。写完删掉:`rm -rf "$d"`(在 tmpfs 上,删掉即可)。核对时只看长度:
`agent-access.py --config <identity> exec --secret-env K=AGENTD_SSH_KEY --timeout 60 -- python3 -c 'import os;print(len(os.environ["K"]))'`(ed25519 私钥约 400 字符)。
2. **oregon**(经 Warpgate 操作):在 `~ubuntu/.ssh/authorized_keys` 追加一行(目录 700、文件 600):
```
restrict,from="100.69.78.124" ssh-ed25519 AAAA…(上一步的公钥) plane-agentd@agentd-01
```
`restrict` 关掉所有转发、pty 和 `~/.ssh/rc`(agentd 用 `-T`,不需要 pty),不必再单独写 `no-port-forwarding` 等;`from=` 只放行 LXC 的 tailnet 地址。sshd 不认识 `restrict`(OpenSSH 7.2 之前)时会把整行判为无效:认证失败、终端走兜底,不会放宽权限;第 5 步的 ping 能看出来(`sshd -V` 或 Windows 上 `sshd.exe -V` 看版本)。可选的再一层:Tailscale ACL 只允许 LXC(`100.69.78.124` 或它的 tag)访问 win10/oregon 的 `:22`,这样密钥泄露了也没用。
3. **win10**(经 Warpgate,`tools/win10/win10-ps.sh`):Administrator 属于 Administrators 组,所以 sshd 读的是 `C:\ProgramData\ssh\administrators_authorized_keys`,不是用户目录下的文件。用 ASCII 追加同样的一行(别覆盖已有的行),再把 ACL 收紧到只有 Administrators 和 SYSTEM(用 SID,不受系统语言影响),否则 sshd 会忽略这个文件:
```powershell
$f = 'C:\ProgramData\ssh\administrators_authorized_keys'
$line = 'restrict,from="100.69.78.124" ssh-ed25519 AAAA… plane-agentd@agentd-01'
if ((Test-Path $f) -and (Get-Content -Raw $f) -and -not (Get-Content -Raw $f).EndsWith("`n")) { $line = "`r`n" + $line } # 原文件末尾没换行时,别把新行粘到最后一把钥匙后面
Add-Content -Path $f -Encoding ascii -Value $line
icacls $f /inheritance:r /grant '*S-1-5-32-544:F' /grant '*S-1-5-18:F'
```
顺便确认 sshd 的默认 shell 是 cmd.exe(`HKLM:\SOFTWARE\OpenSSH` 下没有 `DefaultShell`,或它指向 cmd.exe)。agentd 发的远程命令是 `powershell.exe -NoProfile -NonInteractive -EncodedCommand <引导>`,真正的脚本经 stdin 传过去。外层如果被改成 PowerShell,stdin 可能转不到内层;第 5 步的 ping 能直接发现这个问题。
4. **钉住的主机公钥**已写在 `config/agentd.yaml` 的 `terminals.<t>.ssh.host_key`,经两个通道核对过:Warpgate 读终端上的 `ssh_host_ed25519_key.pub`,与 tailnet 上 `ssh-keyscan -t ed25519` 的结果一致。新加终端照此办理:
```bash
ssh-keyscan -t ed25519 <tailnet ip> # LXC 上,直连
agent-access.py … run --target <t> -- cat /etc/ssh/ssh_host_ed25519_key.pub # 第二通道(Windows:type C:\ProgramData\ssh\ssh_host_ed25519_key.pub)
```
两边一致才能写进配置。agentd 每次调用都用它生成 known_hosts(`<ssh_dir>/known_hosts.<终端>`,别名 `agentd-<终端>`),全局和用户的 known_hosts 都不读。
5. **上线与验证**(在 LXC 上;拿着 cron 锁做,期间 cron 和 webhook 的 tick 会跳过,webhook 会自动重试):
```bash
cd /root/.local/share/server-access-client
flock /var/lib/plane-agentd/cron.lock bash -c '
git -C /opt/plane-agentd pull &&
for t in win10 oregon; do
python3 -I agent-access.py --config https://nas.tail9531b.ts.net:8443/identity/agent.json exec \
--secret-env AGENTD_SSH_KEY=AGENTD_SSH_KEY --timeout 120 -- \
python3 /opt/plane-agentd/cli/agentd ping $t --transport direct-ssh -n 3
done'
```
预期每个终端打印 3 行 `term=<t> via=direct op=ping ms=… rc=0` 和 `ping n: … ms PONG=…`。第 1 次要握手(win10 约 0.5–1.5 s,oregon 约 2–3 s),第 2、3 次复用连接,应明显更快。win10 若是 `timeout`,多半是 stdin 没到内层 PowerShell,见第 3 步。`--transport warpgate-ssh` 可顺带确认兜底仍然通(约 9 s)。
`cli/agentd check-config` 会多出 `direct-ssh …` 行,`cron/exec secret args` 末尾多了 `--secret-env AGENTD_SSH_KEY=AGENTD_SSH_KEY`。
6. **观察**:下一个 tick 后看 `/var/log/plane-agentd/remote.log`,应该只有 `via=direct` 行;出现 `via=fallback` 说明直连失败了,同时 `agentd.log` 会有一行原因。在 oregon 上 `cli/agentd smoke GWENT win10 --watch`,量"建项 → `[worker] 第 1 轮开始`",目标 ≤ 15 s(原来 91 s)。
7. **回滚**:把终端的 `transport` 改回 `warpgate-ssh`(提交、推送、LXC 上 `git pull`;`ssh`/`fallback` 可以留着)。cron 脚本里的 `--secret-env AGENTD_SSH_KEY=…` 可以保留:agentd 总会把它从子进程环境里去掉。Vault 键被删掉也不会停 tick(脚本会不带它重跑),但每个 tick 会多取一次 Vault,届时最好也删掉这一行。紧急情况下可在 LXC 上 `git -C /opt/plane-agentd checkout 83bc0f2 -- config/agentd.yaml`(旧配置,全部走 Warpgate,新代码照常能用)。要彻底撤销直连授权:删掉两台终端 authorized_keys 里那一行,再在 LXC 上关掉已经建好的 ControlMaster 连接(删 authorized_keys 不会断开它们,紧挨着的 webhook tick 能让它们活过 60 s):`for s in /run/plane-agentd/ssh/*-*; do [ -S "$s" ] && ssh -O exit -o ControlPath="$s" x; done`。
8. **轮换密钥**:照第 1 步生成新钥,先把新公钥加到两台终端,再更新 Vault `AGENTD_SSH_KEY`,然后删掉旧公钥的行,再照第 7 步关掉已有的 ControlMaster 连接(否则它们空闲 60 s 后才退出)。之后新连接就用新钥。改了某个终端钉住的 `host_key` 不需要这一步:套接字名里带着 pin 的指纹,新 pin 自动用新连接。