# Provider Gateway Reference

Provider Gateway 是计算节点侧容器。它只主动连出到主 server 暴露的 provider ingress WebSocket，不要求计算节点有公网 IP，适合 NAT、内网和防火墙后的机器。

## Main Server Self Provider

当前主 server 也运行一个 provider-gateway，`providerId` 固定来自 `config.json` 的 `providerGateway.id`。这让单机环境也能验证完整的分布式调度闭环：frontend 发起任务，core 写数据库并通过 provider ingress WebSocket 下发，provider gateway 执行后回传状态。

## Upgrade Safety Gate

计算节点 `provider-gateway` 容器的重建和升级权威路径是 `provider.upgrade` 的 `mode: "schedule"`，或 frontend 中等价的显式升级调度。该路径由在线 provider 通过本地 Docker socket 启动 detached updater 容器，让升级动作脱离当前 WebSocket 与 SSH 透传会话的生命周期；重建目标只能是 `provider-gateway` service，并且必须带 `--no-deps` 与 `--force-recreate`，不得牵连 database、backend-core、frontend 或业务用户服务，也不得因为镜像 tag 未变而 no-op。

远程升级必须采用 sleep-and-validate 回滚保护：旧 gateway 在成功调度 updater 后关闭当前 WebSocket 并进入最长 5 分钟的助眠期；updater 先构建新镜像，再用旧容器的环境变量、挂载、网络和 `extra_hosts` 拉起候选 gateway；候选 gateway 必须在日志中出现 `connect_open` 和 register ack 成功，才允许删除旧 service 容器和候选容器，并用原 Compose service 重新创建最终 provider-gateway 容器。候选验证成功不是升级终态；最终 Compose 容器必须本地重新验证 `restart=always`、`pid=host` 和 backend-core reconnect，并且 backend-core 还必须看到同一 Provider ID、目标 `providerGatewayVersion`、非 candidate 容器名、足够新的真实 heartbeat（`providerGatewayHeartbeatAt`）后，`provider.upgrade` 任务才能进入 `succeeded`。最终容器未在窗口内重连时，updater 应优先恢复 last-known-good 容器，backend-core 必须把任务写成结构化 failure/blocker，不能把 candidate 曾经成功当作最终成功。backend-core 必须在同一 Provider ID 被新 WebSocket 替换后忽略旧 WebSocket 的 close 事件，避免候选已上线后又被旧连接关闭标记为 offline。

禁止通过 UniDesk 自己的 Host SSH / WSL SSH 透传同步执行 `docker compose up -d --build provider-gateway`、`docker compose restart provider-gateway`、`docker rm -f <provider-gateway>` 后再启动等自重建命令。原因是这条 SSH 透传连接正由被重建的旧 `provider-gateway` 容器承载；旧容器停止后会切断控制通道，可能把节点留在旧容器已停、新容器未起的不可达状态。SSH 透传只允许用于诊断、修复升级前置条件、查看本地状态和升级后验证，不允许作为计算节点 `provider-gateway` 正式重建/升级通道。

只有旧节点尚不支持 `provider.upgrade mode=schedule` 时，才允许使用节点本地终端、节点自有 Web terminal、systemd、计划任务或 detached shell 做一次 bootstrap；bootstrap 完成后必须立刻回到 `provider.upgrade mode=schedule` 做真实升级验证，并通过公网 frontend 或 remote CLI 确认节点重新上线、远程更新可用性和 SSH 透传可用性。

## Deployment Method

当前主 server 公网 IP 是 `152.53.229.148`，`config.json` 中的 `network.publicHost` 必须保持为该地址；公网 frontend 入口是 `http://152.53.229.148:18081/`，provider gateway 对外接入入口是 `ws://152.53.229.148:18082/ws/provider`，provider ingress 健康检查是 `http://152.53.229.148:18082/health`。主 server 本机 provider 由根目录 `docker-compose.yml` 的 `provider-gateway` 服务启动，容器内使用 Docker 内网地址 `ws://backend-core:8081/ws/provider` 自接入；外部计算节点部署 provider-gateway 时必须改用公网 provider ingress URL。

新增计算节点推荐使用两项输入的简化挂载流程：在目标节点的 UniDesk 仓库根目录运行 `bun scripts/cli.ts provider attach <PROVIDER_ID> --master-server http://152.53.229.148/ --up`；如果主 server 仍是默认地址，`--master-server` 可省略。该命令生成 `.state/provider-<ID>.env` 和 `provider-<ID>.yml`，env 文件记录 master 地址、Provider 身份、升级坐标和 Host SSH 目标，Compose 固定 `restart: always`、`pid: "host"`、Docker socket、只读 `/workspace` 仓库挂载、日志目录、`/run/host-ssh` 维护私钥目录和 `127.0.0.1:18789->18789/tcp` loopback egress proxy 映射。provider-gateway 会从 `UNIDESK_MASTER_SERVER=http://152.53.229.148/` 自动派生 `ws://152.53.229.148:18082/ws/provider`，并自动补齐默认 labels、心跳/重连参数、`DOCKER_SOCKET_PATH`、`MONITOR_DISK_PATH` 和日志路径；远程升级所需的宿主仓库路径会优先通过 Docker inspect 反查当前容器 `/workspace` 挂载源，避免手写 `/home/ubuntu/unidesk`、Compose project、env file 或 runner image 时出错。显式环境变量仍可覆盖这些默认值；如果主 server 的 provider token 已改成非默认值，挂载时只额外传一次 `--provider-token <token>` 或在 env 文件中加入 `PROVIDER_TOKEN`。

手写 Compose 仍必须满足同一部署约束：挂载 `/var/run/docker.sock:/var/run/docker.sock` 作为 Docker 状态采集、任务执行和远程升级的唯一自动化通道，并让 provider-gateway 容器运行在宿主 PID namespace；Compose 写法是 `pid: "host"`，`docker run` 写法是 `--pid host`。缺少该配置时只能看到 provider 容器命名空间内的进程，不能视为完整节点资源监控。provider-gateway 容器必须使用 Docker restart policy `always`；`unless-stopped`、空 restart policy、手动 `docker stop` 后期待 Docker daemon 重启自动拉起，都不符合长期接入要求。长期接入节点必须保留只读 `/workspace` 仓库挂载，使 `provider.upgrade mode=schedule` 能构建候选 gateway 且只重建 `provider-gateway` service，不影响 database、backend-core、frontend 或业务用户服务。provider-gateway 部署必须同时交付 Host SSH / WSL SSH 透传维护桥；WSL 节点默认把私钥目录挂载到 `/run/host-ssh` 后，gateway 会在发现 `/run/host-ssh/id_ed25519` 时自动使用 `host.docker.internal:22`、从仓库路径推断 WSL 用户与默认工作目录，必要时仍可用 `HOST_SSH_HOST`、`HOST_SSH_PORT`、`HOST_SSH_USER`、`HOST_SSH_KEY`、`HOST_REMOTE_CWD` 显式覆盖。

## Mandatory SSH Passthrough Bundle

新增 provider-gateway 或 legacy bootstrap 后，Docker socket、远程升级和 SSH 透传必须作为同一个部署包验收，不能只让 provider 在线就认为完成。节点侧应先启动目标宿主或 WSL 的 sshd，把维护公钥写入目标用户 `authorized_keys`，再用只读目录挂载私钥到 provider-gateway 容器内 `/run/host-ssh`；provider-gateway 环境变量必须包含 `HOST_SSH_HOST`、`HOST_SSH_PORT`、`HOST_SSH_USER`、`HOST_SSH_KEY` 和 `HOST_REMOTE_CWD`。注册成功后，主 server 看到的 labels 必须同时满足 `hostSshConfigured=true`、`hostSshKeyPresent=true`、`hostSshTarget` 指向目标 sshd，且 `unideskCapabilities` 包含 `host.ssh`。

`provider attach --up` 必须把容器内私钥 presence 和真实 SSH probe 纳入 `containerValidation.ok`；只有 `hostSshKeyPresent=true` 且 `hostSshProbeOk=true` 才能接受部署。Docker Desktop 使用 WSL 私钥目录时，必须从拥有该目录的 WSL distro 发起 Compose；从 Windows Docker CLI 直接挂载 `/home/<user>/...` 可能落到 Docker Desktop VM 的空目录，容器仍会 running，但维护桥实际不可用。

使用初始密码把新增外部节点引导成密钥登录时，本机缺少 `sshpass` 应先安装 `sshpass`，再用受控脚本写入维护公钥；不要长期保留交互式密码登录流程，也不要把密码写入命令输出、文档、Compose env 或仓库文件。

计算节点镜像必须包含 `ssh` 客户端，否则 provider 虽然可以在线并执行 Docker 任务，但 `host.ssh` 调度会在节点侧失败。标准 `src/components/provider-gateway/Dockerfile` 已安装 `openssh-client`；如果节点使用本地兜底镜像或复制 Bun/Docker CLI 的自定义镜像，也必须同时安装或复制可用的 OpenSSH client，并在容器内通过 `ssh -V`、`test -r /run/host-ssh/id_ed25519`、`ssh -i /run/host-ssh/id_ed25519 ... <target> hostname` 做本地烟测后再接入主 server。

SSH 透传只作为维护桥，不作为普通计算任务和自动升级的执行通道。普通任务、Docker 状态采集和 `provider.upgrade` 仍必须走本地 Docker socket；SSH 透传用于节点诊断、人工修复和验证 WSL/宿主维护入口是否可达。

## SSH Data TCP Pool

Provider WebSocket 是注册、heartbeat、dispatch、`provider.upgrade` 和短控制消息的主管理通道，不再承载 `trans`/`tran`/`ssh` 的大块 stdin/stdout/stderr 数据面。Host SSH 透传的数据面必须走 provider 主动连到 main server 的 TCP warm pool；provider 仍然只主动连出，不要求计算节点暴露入站端口，也不改变公网 frontend/provider ingress 之外的业务入口。

标准能力名是 `host.ssh.tcp-pool`。支持该能力的 provider-gateway 会在连接 WebSocket 后按 `PROVIDER_DATA_HOST`、`PROVIDER_DATA_PORT` 和 `PROVIDER_DATA_POOL_SIZE` 建立常驻 TCP 通道池，默认 10 条通道；main server 默认公开 provider data port `18084`，backend-core 容器内监听 `8082`。每条 SSH 会话或文件传输占用一条 data channel，大文件传输不能阻塞其他已空闲通道上的 SSH、小文件或脚本请求；这里不再做应用层队列、fair scheduling 或把大文件分发到控制 WebSocket 的 fallback。池耗尽时必须返回结构化错误，例如 `provider-data-pool-exhausted`，调用方按短查询/重试语义处理，而不是让单个 `trans` 长挂。

旧 provider 没有声明 `host.ssh.tcp-pool` 时，backend-core 必须让 `/ws/ssh` 快速失败并返回 `provider-gateway-upgrade-required`，提示升级 provider-gateway；不得保留旧的 WebSocket 数据路径作为兼容分支。`provider.upgrade mode=schedule` 本身仍走 provider WebSocket 和 Docker socket，因为升级动作必须在旧 provider 上可达，用于把节点推进到支持 TCP pool 的版本。

可见性必须跟随通道池。provider-gateway labels 应上报 `providerGatewaySshDataTransport=tcp-pool`、目标 host/port、desired pool size、ready/claimed/connecting/total channel 数和最近错误；`debug health`、frontend raw JSON 和 issue 证据应优先用这些字段判断是 provider 未升级、data port 不通、池耗尽还是目标 host SSH 本身失败。只看到 provider 在线或 `host.ssh` 可用，不等于 TCP data pool 已经可用。

### TCP Pool Development Notes

TCP pool 的长期方向是把 `trans`/`tran` 变成真正并发的短连接工具，而不是给单条 Provider WebSocket 继续叠队列。backend-core 只用 provider WebSocket 下发 open/dispatch/exit 等控制帧，stdin/stdout/stderr 数据帧必须走已预热的 TCP channel；每个 SSH 会话、脚本、文件传输或 Windows 透传命令独占一条 channel，结束后释放回池。池耗尽、channel 丢失、data port 不可达和 provider 版本过旧都必须是结构化快速失败；禁止把请求排进应用层队列后长时间不返回。

当前默认池大小是 10 条，设计上优先覆盖高频短 SSH、并发小文件和单个大文件不阻塞其他请求的场景。已验证的目标状态是：D601 这类 WSL provider 上 10 路并发 `trans ... sh -- 'sleep 2'` 不再出现 `provider ssh tcp data pool has no idle channel`、stderr 为空、每一路 stdout 都包含命令开始和结束输出，结束后 labels 回到 `ready=desired`、`claimed=0`。当前仍存在端到端固定开销，10 路并发短命令的墙钟可能明显高于远端命令自身耗时；这属于后续连接建立、broker 调度、WSL SSH spawn 或 provider 启动路径的性能优化范围，不能用队列、门禁或隐藏重试掩盖。

开发中最容易踩的坑是把“依赖层在线”误判成“数据面可用”。`host.ssh` 只证明 provider 能执行维护 SSH；`host.ssh.tcp-pool`、`providerGatewaySshDataPoolReady`、`providerGatewaySshDataPoolClaimed` 和 `providerGatewaySshDataPoolLastError` 才能证明 TCP 数据池状态。另一个坑是输出尾部丢失：backend-core broker 在收到 `ssh.data` 后必须把 stdout/stderr 写入并 flush，再处理 `ssh.exit`，否则短命令可能 rc=0 但最后一段 stdout 没到调用端。第三个坑是 session 释放：`ssh.exit`、错误和超时路径都必须释放 claimed channel，避免下一批并发请求看到假性的池耗尽。第四个坑是 core/provider 池状态漂移：如果 provider 通过控制 WebSocket 返回 `host_ssh_error` 且提示 `requested ssh tcp data channel is not ready`，说明 core 侧 claim 到的 channel 已经不被 provider 认可，backend-core 必须 drop 该 `providerId + dataChannelId`，不能把它 release 回 idle pool 后继续重复 claim。第五个坑是短命令 session 生命周期竞态：channel 已 claim 但 provider 还没登记 session 时到达的 `input`、`eof`、`resize`、`close` 应进入有界 pending buffer；session 已正常退出、异常结束或被客户端 close 后短时间内到达的同 channel 控制帧应按 completed-session 归档并降为 debug，不应继续产生 `ssh_data_session_missing` warn；真正未知 session 或跨 channel 的数据帧仍必须保留 warn，避免掩盖真实数据丢失。

## WSL Compute Node Deployment

WSL 计算节点和普通外部计算节点使用同一套 provider ingress 协议：节点不暴露入站端口，只需要从 WSL 主动连出到 `ws://152.53.229.148:18082/ws/provider`。已验证的 WSL 节点命名口径是短设备编号，例如 `PROVIDER_ID=D601` 与 `PROVIDER_NAME=D601` 保持一致；新增 WSL 节点必须替换为唯一 ID，避免把旧节点状态覆盖到同一条 `unidesk_nodes.provider_id` 记录。

WSL 节点应优先使用 WSL 内部原生 Docker Engine 和 `/var/run/docker.sock`，让资源采样、Docker 状态和任务执行都反映 WSL 计算环境本身，而不是 Windows 或 Docker Desktop 的代理上下文。如果当前 Docker CLI 实际连接 Docker Desktop daemon，也可以先作为可用计算节点接入，但必须在 `PROVIDER_LABELS_JSON` 中显式标注 `dockerContext=docker-desktop` 或等价标签，避免在 frontend 中把 Docker Desktop 资源误判为 WSL 原生资源。UniDesk 仓库建议放在 WSL 原生文件系统，例如 `/home/ubuntu/unidesk`，再按需从 Windows 工作区同步源码；长期运行和升级挂载应使用 WSL 原生路径只读挂到容器内 `/workspace`，减少 `/mnt/c` 权限、性能和路径转换问题。

WSL provider 的最小环境文件应放在节点本地私有路径，例如 `/home/ubuntu/unidesk/.state/provider-<ID>.env`，并由生成的 `provider-<ID>.yml` 或 `docker run --env-file` 读取。新挂载默认只写 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`；如果需要覆盖 labels，`PROVIDER_LABELS_JSON` 在 Docker env-file 中可以写成单行 JSON，临时用 shell `source` 调试时必须对整段 JSON 加引号，否则 shell 会按 `{}` 和逗号拆分导致 JSON 解析失败。不写 labels 时 provider-gateway 会根据 Provider ID、Docker、WSL 内核和 `/etc/os-release` 自动生成 `host`、`role`、`docker`、`wsl`、`distro` 与 `attachMode=simple`，并在运行时追加 `runtime`、`dockerSocketPresent`、gateway 版本和 `gatewayUptimeSeconds`。`.state/provider-<ID>.env`、`provider-<ID>.yml`、`logs/provider-<ID>/` 和容器日志属于节点本地运行态，必须保持在 `.gitignore` 覆盖范围内，不能提交 provider token、登录态或运行日志。

长期运行推荐用 systemd 管理 provider-gateway 容器，而不是只在交互 shell 中运行 Bun 进程。systemd unit 的稳定形态是：`ExecStartPre=-docker rm -f unidesk-provider-gateway-<ID>` 清理同名旧容器，`ExecStart=docker run --restart always --pid host --name unidesk-provider-gateway-<ID> --env-file ... -p 127.0.0.1:18789:18789 -v /var/run/docker.sock:/var/run/docker.sock -v /home/ubuntu/unidesk:/workspace:ro -v /home/ubuntu/unidesk/logs/provider-<ID>:/var/log/unidesk -v <ssh-key-dir>:/run/host-ssh:ro unidesk_provider-gateway:<id>`，`ExecStop=docker stop unidesk-provider-gateway-<ID>`，并设置 `Restart=always`。临时部署也必须使用 `docker run -d --restart always --pid host -p 127.0.0.1:18789:18789`，并保证容器名、env 文件、日志目录、SSH 私钥只读挂载和镜像 tag 都带上节点 ID，便于 frontend、Docker 状态、SSH 透传、进程资源表和本地排障互相对应。`provider.upgrade` 是长期接入节点的必备能力，provider-gateway 不提供 `PROVIDER_UPGRADE_ENABLED` 或等价禁用开关；如果节点缺少升级环境变量或 SSH 透传环境变量，必须修正节点部署，而不是在服务端接受只能预检、不能升级或不能维护透传的半成品状态。

WSL 本身会在没有前台进程时被 Windows 回收；如果该节点要作为长期在线算力，必须通过 Windows 启动项、计划任务或后台 `wsl.exe -d <distro> -u root -- bash -lc "systemctl start docker unidesk-provider-gateway-<ID>.service; exec sleep infinity"` 这类 keepalive 进程保持发行版运行。仅启用 WSL 内 systemd service 不等价于 Windows 层面的常驻守护。

Docker daemon 重启后的恢复验收必须看两个层级。第一层是 Docker 自身：`docker inspect --format '{{.HostConfig.RestartPolicy.Name}} {{.HostConfig.PidMode}} {{.State.Status}}' unidesk-provider-gateway-<ID>` 必须返回 `always host running`；D601/D518 这类 Docker Desktop daemon 上还要记录 `docker info --format '{{.Name}} {{.LiveRestoreEnabled}}'`，当前 `LiveRestore=false` 意味着 daemon 重启会停止容器，恢复完全依赖 restart policy，因此不能把容器曾经是 running 当作已具备重启恢复能力。第二层是宿主/WSL 常驻：节点应有 systemd、Windows 计划任务、Docker Desktop 自启动或等价 keepalive 证明 Docker daemon 会随机器/WSL 启动；如果 `systemctl list-unit-files '*unidesk*'` 为空，必须在验收记录中明确该节点暂时只依赖 Docker Desktop daemon 自身，不能声称有 WSL 内 systemd watchdog。provider-gateway 新版本会在启动与 heartbeat 中自检当前容器 restart policy，发现不是 `always` 时通过 Docker socket 尝试 `docker update --restart always <self>` 并在 labels 中上报 `providerGatewayRestartPolicyOk`、`providerGatewayPidModeOk` 与 `providerGatewayRuntimeGuardOk`；这只是最后一道自愈，不能替代 Compose/systemd 的正确配置。

D601 的已验证常驻链路是 Windows 登录计划任务 `UniDesk-D601-Autostart` -> `C:\Users\liang\AppData\Local\UniDesk\d601-autostart.cmd` -> `wsl.exe -d Ubuntu -u ubuntu -- /bin/bash -lc "/home/ubuntu/.local/bin/unidesk-d601-autostart task"`。WSL 脚本负责启动 sshd、等待 Docker Desktop daemon、确保 `unidesk-provider-gateway-D601` 为 `restart=always` 且 running，并调用 `/home/ubuntu/.local/bin/unidesk-microservice-autorecover` 恢复 D601 用户服务。后续 WSL 节点可以复用这个模式，但必须把 Windows 用户、distro 名、provider ID、业务 autorecover 脚本和日志目录替换为节点自己的值；该模式只用于节点启动后的守护恢复，provider-gateway 版本升级仍必须走 `provider.upgrade mode=schedule`。

## WSL Network And Proxy Bootstrap

WSL 节点出网异常时，先把代理设置固化到目标 WSL 用户的 `~/.bashrc`，而不是只在当前 shell 临时 `export`。长期可复用的写法是在每次交互 shell 启动时从 `/etc/resolv.conf` 读取 Windows 宿主在 WSL NAT 中的 nameserver IP，再导出 `http_proxy`、`https_proxy`、`HTTP_PROXY`、`HTTPS_PROXY`、`all_proxy` 和 `ALL_PROXY` 指向 `http://<windows-host-ip>:7890` / `socks5://<windows-host-ip>:7890`，并保留 `no_proxy=localhost,127.0.0.1,::1,host.docker.internal`。不要把某次启动看到的宿主 IP 当成永远不变的常量；动态读取可以避免 WSL 网络重建后代理失效。

Windows 代理程序常见默认只监听 `127.0.0.1:7890`，此时 WSL 访问 `<windows-host-ip>:7890` 会失败。可选修复是让代理程序监听 WSL 可达网卡，或在 Windows 用户态启动一个幂等 TCP relay，把 `<windows-host-ip>:7890` 转发到 `127.0.0.1:7890`；`netsh interface portproxy` 通常需要管理员权限，不应作为普通节点 bootstrap 的唯一方案。`.bashrc` 可以在发现 `<windows-host-ip>:7890` 不可达时调用 Windows 侧脚本启动该 relay，但 relay 脚本、临时 apt source、日志和节点私钥都属于本地运行态，必须留在 `.state` 或用户目录，不能提交到仓库。

apt 或镜像构建遇到 `archive.ubuntu.com` 超时、代理 `503` 或 registry metadata 拉取失败时，优先使用节点侧临时镜像源或临时 apt sources 完成 bootstrap，例如把 Ubuntu 源临时替换到可达镜像后安装 `openssh-server`。这类网络兜底只解决节点初始化，不改变 `UNIDESK_MASTER_SERVER`、provider token、Docker socket、Host SSH 透传和 remote CLI 验收标准。

Docker build 的 `HTTP_PROXY` / `HTTPS_PROXY` build args 只覆盖 Dockerfile 中的 `RUN` 步骤，不能保证 BuildKit 拉取 `FROM` metadata 时使用代理。若 `auth.docker.io` metadata 超时，应先让 Docker daemon 使用受控代理，或通过 `host.docker.internal:7890` 和可信 image transport 预热精确 base image，再执行标准 Dockerfile 构建；不得把旧 provider 最终镜像冒充 base image。预热完成后仍要用 `--no-cache` 验证 `apk` 与 `bun install` 经代理成功，并由最终 Compose heartbeat 和 Host SSH 原入口验收收口。

## WSL Image Build Fallback

标准镜像构建路径使用 `src/components/provider-gateway/Dockerfile` 及其中声明的精确 `oven/bun` 基础镜像，并在镜像内安装 Docker CLI、Compose plugin、OpenSSH client 和 provider-gateway 源码。WSL 或 Docker Desktop 环境中的 registry mirror、DNS 或代理配置可能导致 `oven/bun` 元数据拉取失败；此时不要修改 provider ingress 协议或服务端配置，应改用节点侧镜像交付兜底。

可复用的兜底方式是使用本机已存在的 Debian/Node 基础镜像，复制 WSL 本地 `bun`、`docker` 和 `docker-compose` plugin 到镜像内，再复制 `src/components/provider-gateway/src` 与 `src/components/shared/src`。该镜像必须能在容器内执行 `bun --version`、`docker version`、`docker compose version`、`ssh -V`，并通过挂载的 `/var/run/docker.sock` 执行 `docker info` 和 `docker ps`；只有这些命令通过后才能作为 `unidesk_provider-gateway:<provider-id>` 运行。该兜底镜像只改变节点侧交付方式，不改变 `UNIDESK_MASTER_SERVER`、token、heartbeat、labels、Docker socket、SSH 私钥只读挂载和服务端验收标准。

如果兜底镜像还要承载 WSL SSH 透传，必须同时复制 `/usr/bin/ssh`、`/etc/ssh/ssh_config`、`/etc/ssh/ssh_config.d` 以及 `ldd /usr/bin/ssh` 输出的全部动态库，尤其不能漏掉 `/lib64/ld-linux-x86-64.so.2` 这类 ELF loader。只在 rootfs 中看到 `/usr/bin/ssh` 文件不代表可执行；验收必须运行 `docker run --rm <image> /bin/sh -lc '/usr/bin/ssh -V && /usr/bin/docker compose version'`，再以实际 provider 容器执行 `ssh -T -i /run/host-ssh/id_ed25519 -p 22 ubuntu@host.docker.internal hostname`。若 Compose 使用 Docker bridge 网络，WSL 节点必须在 compose 文件中加入 `extra_hosts: ["host.docker.internal:host-gateway"]`，否则容器内可能无法把 `host.docker.internal` 解析到 WSL 宿主。

## Deployment Verification

provider-gateway 部署是否成功必须以 UniDesk frontend 中可见的 Provider 信息为准，不能只看节点容器 `running`。验证时访问 `http://152.53.229.148:18081/`，使用配置中的账号密码登录，进入 `资源节点 / 节点清单`，确认目标 `PROVIDER_ID`、`PROVIDER_NAME`、`online` 状态、`lastHeartbeat` 和 labels 可见；点击该节点的 `查看原始JSON`，确认 raw payload 中的 `providerId`、`name`、`status`、`labels` 与部署环境变量一致。随后进入 `资源节点 / 资源监控`，确认该 Provider 有 CPU、实际内存和硬盘采样曲线；进入 `资源节点 / Docker 状态`，确认 Docker daemon、containers、images、volumes、networks 已渲染出来，且 `dockerSocketPresent` 或 Docker ready 状态与预期一致。只有这些前端信息都能通过 UniDesk 正常读取，才说明 provider-gateway 已经真正挂载到主 server。

WSL 节点还应补充一次真实调度验证：向该 `PROVIDER_ID` 下发 `docker.ps`，任务必须从 `dispatched` 进入 `succeeded`，并在结果中看到 WSL Docker daemon 返回的容器列表；对于容器化运行的 provider-gateway，列表中通常应包含 `unidesk-provider-gateway-<PROVIDER_ID>`。这一步可以同时证明 provider WebSocket、服务端任务路由、节点侧 Docker socket 和结果回传链路都已贯通。

SSH 透传自测是 provider-gateway 部署验收的一部分。目标 Provider 在线后，先确认 frontend 节点清单或 `debug health` 中该节点 labels 显示 `hostSshConfigured=true`、`hostSshKeyPresent=true` 且能力包含 `host.ssh`；再运行 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000`，任务必须 `succeeded`，result 中 `probeLine` 必须包含 `UNIDESK_SSH_TEST` 且 `exitCode=0`；最后运行 `trans <PROVIDER_ID> hostname`，输出必须是目标宿主或 WSL 的 hostname，进程退出码必须为 0。任何 provider 在线但不声明 `host.ssh` 的状态都只能算未完成部署。

如果该节点承载用户服务，还必须声明 `microservice.http` capability，并通过 `bun scripts/cli.ts microservice health <id>` 或 remote CLI 等价命令验证 backend-core 能经 provider-gateway 访问节点本机后端。新版 provider-gateway 还应声明 `microservice.http.tunnel`，表示 backend-core 可以在同一 provider WebSocket 上发起短生命周期 HTTP tunnel request 并等待 provider 直接回传 upstream 响应；这一路径避免为每个 HTTP 代理请求创建、轮询并读取任务结果，降低高频只读接口出现 “upstream socket closed” 或 pending task 清理竞态导致 502 的概率。旧 gateway 未声明 tunnel 时，backend-core 可以继续使用原 `microservice.http` dispatch 兼容路径，但运行健康验收应以 tunnel capability 为目标状态。

用户服务后端端口不得映射到公网；provider-gateway 只允许代理节点本地 HTTP 地址或主 server 显式 Compose 服务名，业务 API 路径和 HTTP 方法还要受 backend-core `allowedPathPrefixes` 与 `allowedMethods` 限制。无论走 tunnel 还是旧 dispatch，响应必须保留 upstream status、content-type、截断标记和代理模式诊断头；provider WebSocket 断开时 backend-core 必须显式返回 504/502，不得静默 fallback 到公网直连、旧缓存或 provider-gateway 以外的业务 HTTP 入口。

自动化验证必须访问公网 frontend 用户入口，而不是在容器内直接调 core API 代替浏览器验收。标准交付门禁仍是 `bun scripts/cli.ts e2e run`；浏览器执行形态、截图、断言和失败 artifact 统一见 `$unidesk-webdev`。外部新增节点的人工验收应复用同一套前端路径：先确认 Provider 信息出现在节点清单，再确认资源监控和 Docker 状态页面有该节点的数据，最后通过任务调度向该 Provider 下发 `echo`、`docker.ps` 或维护专用 `host.ssh` probe，并在任务历史中查看耗时、状态、stdout/stderr 摘要和失败原因。

## Provider Ingress

provider ingress 是唯一允许公网暴露的 provider 连接接口，当前由 backend-core 容器的独立端口提供 `/ws/provider` 和 `/health`。backend-core REST API 仍只在 Docker 内网开放，外部计算节点只应连接 provider ingress。

## Docker Socket Path

自动任务执行只允许走本地 Docker socket。Compose 将 `/var/run/docker.sock` 挂入 provider-gateway，provider 标签会报告 `dockerSocketPresent`，`docker.ps` 调试任务会通过该 socket 查询宿主 Docker 容器。

## User Service HTTP Proxy

`microservice.http` 是 provider-gateway 给 `deployment.mode=unidesk-direct` 用户服务使用的私有后端访问能力。新 provider 必须同时声明 `microservice.http.tunnel`：backend-core 对 UI 高频读请求优先复用既有 provider WebSocket 发送 `http_tunnel_request` 并等待 `http_tunnel_response`，不再为每个轮询创建 `unidesk_tasks` 调度记录；旧 provider 未声明该能力时才回落到原 `dispatch` 任务路径。响应头会标记 `x-unidesk-proxy-mode=provider-ws-http-tunnel` 或旧 `provider-task`，用于性能验收。

backend-core 下发目标 service id、节点本机 `targetBaseUrl`、path、query、method、request body、timeout 和可选 JSON 数组裁剪参数；provider-gateway 支持 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`，但最终允许方法必须由每个用户服务的 `backend.allowedMethods` 显式配置。provider-gateway 只允许访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这些节点本地地址；主 server 内置服务可使用明确登记的 Compose 服务名。`deployment.mode=k3sctl-managed` 的 Code Queue 不得通过 provider-gateway 直连业务容器，正式路径只能是 backend-core -> provider WebSocket HTTP tunnel -> `k3sctl-adapter` -> Kubernetes native Service/DNS，必要时显式 fallback 到 Kubernetes API service proxy -> k3s/k8s Service。NC01 Todo Note 由 backend-core 直接访问同 namespace ClusterIP Service，不经过 provider-gateway。该能力不打开 provider-gateway 入站端口，也不替代业务仓库自身 Dockerfile/docker-compose。

backend-core 必须把 provider WebSocket HTTP tunnel 的失败分类到响应 body 和 headers：失败响应至少包含 `requestId`、`providerId`、`serviceId`、`stage`、`failureReason` 或 provider result，并带 `x-unidesk-request-id` 与 `x-unidesk-tunnel-error`。`GET`/`HEAD` 非 stream 请求允许短超时分层重试；`POST`、`PATCH`、`PUT`、`DELETE` 这类可能产生副作用的请求不得自动重复。Provider 重连时 backend-core 必须先确认 close 事件来自当前 active socket，旧 socket 被新 socket 替换后的迟到 close 不得清理新连接上的 tunnel waiter，也不得把节点误标 offline。

超大 JSON 响应可以使用 `jsonArrayLimits` 在 provider-gateway 返回前裁剪指定数组，并在响应体中写入 `_unidesk.arrayLimits` 元数据，便于 UniDesk frontend 预览列表而不展示裸 JSON。长期应优先推动业务后端提供分页 API；裁剪只是 UniDesk 集成层的展示保护。

## Egress Proxy

provider-gateway 可以提供 egress HTTP CONNECT 代理，用于让 Code Queue、Pipeline runner、target-side Docker build 等节点侧执行环境通过既有 provider WebSocket 通道出网。代理默认监听容器内 `0.0.0.0:18789`，节点部署必须只发布为宿主 loopback `127.0.0.1:18789->18789/tcp`，不得开放公网端口；普通 Docker 执行容器可通过同一私有 Docker network 访问 provider-gateway 容器名，k3s/k8s Pod 必须通过显式 Kubernetes Service 暴露同节点 provider-gateway 私有 endpoint，例如 D601 Code Queue 使用 selector 指向 hostNetwork 桥接 Pod 的 `d601-provider-egress-proxy.unidesk.svc.cluster.local:18789`，不得把固定 Docker bridge IP、手工 EndpointSlice 或该 egress Service 当作业务 HTTP 入口。代理只负责把本地 CONNECT/absolute HTTP 请求转换为 `egress_tcp_open`、`egress_tcp_data`、`egress_tcp_close` 消息；backend-core 在主 server 侧建立真实 TCP 连接并把数据回传，避免 D601 等计算节点本地网络不可达时卡死 Codex/Git/NPM/apt/Playwright。

该能力属于 provider-gateway 通道能力，register/heartbeat 的 `unideskCapabilities` 必须包含 `network.egress-proxy`，labels 必须上报 `providerGatewayEgressProxy*` 状态。不得再为某个用户服务单独注册伪 provider 来实现出网代理；否则节点列表会出现虚假 provider，且代理、统计、升级路径会形成多套通道。代理健康检查使用 `GET /__unidesk/egress-proxy/health`，返回 `connected`、`providerId`、`activeTunnels`、`pendingTunnels`、`oldestTunnelAgeMs`、`openTimeoutMs`、`idleTimeoutMs` 和监听端口；业务服务自己的 `/health` 应把该结果作为排障证据透出。

egress proxy 的长期边界是“统一 provider 通道，不引入第二控制面”。backend-core 只接受在线 provider socket 上的 `egress_tcp_*` 消息，并在该 socket 关闭时销毁全部对应 TCP relay；provider-gateway 只维护本地 HTTP proxy 与 WebSocket 消息映射，不保存业务状态，不参与任务调度、统计或节点注册以外的控制面。执行容器、用户服务、Pipeline runner 和 provider-side deploy build 不允许直接连接 backend-core provider ingress，也不允许携带 provider token 自行注册；需要出网时只能连接同节点 provider-gateway 的私有 proxy endpoint。当前 k3s/k8s Code Queue 通过 `d601-provider-egress-proxy` 或 `g14-provider-egress-proxy` Kubernetes Service 连接同节点 provider-gateway egress endpoint，这是 Pod 内的出网入口，不是业务 HTTP 代理入口，也不能替代 Kubernetes API service proxy。部署构建同样不得新建 SSH SOCKS、公网 master proxy 或未登记的宿主全局代理；构建脚本默认只能把 provider-gateway WS egress 作为短生命周期环境变量和 Docker build-arg 注入，并配合目标节点本地 BuildKit/image cache 避免重复下载大依赖层。G14 的节点本地 VPN proxy 是已登记的基础设施 bootstrap 例外，只允许按 `docs/reference/g14.md` 用于 G14 host-side 镜像构建、cache prewarm 或恢复下载；k3s runtime Pod 仍必须使用 `g14-provider-egress-proxy` 和 `g14-tcp-egress-gateway`。

egress tunnel 必须有生命周期边界：provider-gateway 发出 `egress_tcp_open` 后如果主 server 未在 `openTimeoutMs` 内返回 `egress_tcp_opened` 或 close，必须主动关闭本地 client 并向 core 发送 `egress_tcp_close`；provider-gateway 与 backend-core 都必须对长时间无数据的 relay 执行 idle 清理，避免 provider WebSocket 抖动、TCP connect 卡住或上游未关闭时留下 stale tunnel。生命周期日志必须覆盖 provider proxy open request/opened/remote close/local close、backend-core open requested/connect start/connected/connect failed/connect timeout/read-write failed，并包含 `providerId`、`connectionId`、目标 host/port、method、ageMs、pendingBytes 和低基数 `failureKind`；不得记录 payload、DSN 密码、Authorization、Secret 或 token。排障时如果 `activeTunnels` 持续增长、`pendingTunnels` 非零或 `oldestTunnelAgeMs` 明显超过业务请求耗时，应先看 provider-gateway 与 backend-core egress 清理日志，再判断 Code Queue、PostgreSQL 或 OA Event Flow 本身是否慢。

故障语义必须显式，不允许静默 fallback。provider-gateway 到 backend-core 的 WebSocket 未连接时，本地 proxy 必须返回 503；执行容器不能自动绕过到 D601 本地直连公网、外部公共代理或主 server 公网 HTTP 端口。`NO_PROXY` 只用于 PostgreSQL、OA Event Flow、ClaudeQQ、frontend/backend-core 内网代理、provider-gateway health 等明确内网链路，不能把 GitHub、npm registry 等外部目标加入绕过列表。`hyueapi.com` 与 MiniMax judge 上游 `api.minimaxi.com` 是明确的模型 API 例外：前者会拒绝 provider-gateway egress proxy 出口，后者在 judge 高频短请求上容易受 provider egress 抖动影响导致任务误重试；Code Queue 必须用 `CODE_QUEUE_EGRESS_PROXY_NO_PROXY` / `NO_PROXY` 将 `hyueapi.com,.hyueapi.com,api.minimaxi.com,.minimaxi.com` 配成直连，其它模型 API 仍不得默认绕过 proxy。验收必须同时证明 provider-gateway labels、业务服务 `/health` 和执行容器内 `curl -I https://...` 都走同一 proxy path，hyueapi/MiniMax 例外则以 Code Queue `/health.egressProxy.noProxy`、MiniMax judge 探测和目标任务成功完成作为证据。

## Gateway Version Metadata

provider-gateway 必须从自身 `package.json` 读取版本号，并在 register 与 heartbeat labels 中上报 `providerGatewayName`、`providerGatewayVersion`、`providerGatewayStartedAt` 和 `providerGatewayUpgradePolicy`。backend-core 将这些 labels 合并到 `unidesk_nodes.labels`，frontend 在节点清单、资源监控和 `资源节点 / 网关版本` 中展示；旧节点缺少这些字段时只能显示版本未知，不能用猜测值替代。`unideskCapabilities`、`hostSshConfigured`、`hostSshKeyPresent` 和 `hostSshTarget` 也是 WebUI 运维可用性徽标的数据源，用于直接显示每个计算节点的 SSH 透传可用性与远程更新可用性。

`src/components/provider-gateway` 下任何代码或行为变更都必须在同一变更集中递增 `src/components/provider-gateway/package.json` 的 `version`，不得只改实现而沿用旧版本号。验收时必须通过公网 frontend 的 `资源节点 / 网关版本` 或 `bun scripts/cli.ts debug health` 确认目标 provider 的 `providerGatewayVersion` 已上报新版本；如果线上节点仍显示旧版本，该 provider-gateway 变更不能视为交付完成。

任何远程升级预检、执行升级和远程更新记录都必须显式显示指定 Provider 的 gateway 版本号。`provider.upgrade` result 的 plan 中必须包含 `providerId`、`providerName`、当前运行中的 `providerGatewayVersion` 和从升级 workspace 的 `src/components/provider-gateway/package.json` 读取到的 `targetProviderGatewayVersion`；frontend 的升级控件与 `资源节点 / 网关版本` 远程更新记录必须把该版本渲染为结构化字段，不能只把版本埋在原始 JSON 中。

## Docker Status Telemetry

provider-gateway 连接成功后必须周期性上报 Docker daemon 状态，数据来源是本地 Docker socket 上的 `docker info`、`docker ps -a`、`docker images`、`docker volume ls` 和 `docker network ls`。backend-core 将最新快照保存到 `unidesk_node_docker_status`，frontend 的资源节点 `Docker 状态` 子标签用该快照渲染 Docker Desktop 风格视图；该能力仍然只通过 provider 主动上报，不要求主 server 反向连接计算节点。

## System Status Telemetry

provider-gateway 连接成功后必须周期性上报节点 CPU、内存、硬盘和进程资源占用。整体采集来源是节点本地 `/proc/stat`、`/proc/loadavg`、`/proc/meminfo` 与 `df -PB1`，进程表来源是 `/proc/[pid]/stat`、`/proc/[pid]/status`、`/proc/[pid]/cmdline`、`/proc/[pid]/io`、可用时的 `/proc/[pid]/smaps_rollup` 和 fallback 用的 `/proc/[pid]/statm`；backend-core 将最新快照保存到 `unidesk_node_system_status`，并将历史采样保存到 `unidesk_node_metric_samples` 供 frontend 绘制任务管理器风格曲线。内存使用量采用实际占用口径：`MemTotal - MemFree - Buffers - Cached - SReclaimable + Shmem`，也就是不把 Linux page cache / buffer 计入占用；上报中同时保留 `cacheBytes` 便于排查。进程表的 `memoryBytes` 优先使用 `smaps_rollup` 中的 PSS，避免 PostgreSQL shared buffer 等共享页在多个进程之间重复计入，同时保留 `rssBytes`、`privateBytes` 和 `sharedBytes` 便于排查；如果目标内核或权限不支持 `smaps_rollup`，则回退为 `rssBytes - statm.shared`，仍避免把共享页全部计入单个进程，只有 `statm` 也不可读时才退回原始 RSS。默认按 `memoryBytes` 降序截取前 120 个进程；`cpuPercent` 使用相邻采样 CPU tick 差值，首个采样用进程生命周期平均值兜底；磁盘 I/O 速率使用相邻 `/proc/[pid]/io` 的 `read_bytes/write_bytes` 差值。该链路仍然由 provider 主动上报，主 server 不反向探测计算节点。

## Remote Provider Upgrade

backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provider.upgrade`。`mode: "plan"` 只返回升级计划，用于 E2E 和人工预检；`mode: "schedule"` 会要求 provider-gateway 通过本地 Docker socket 启动一个 detached updater 容器。updater 的固定策略是先执行 `docker compose build provider-gateway`，构建成功后只按 `com.docker.compose.project` 与 `com.docker.compose.service=provider-gateway` label 删除旧 provider-gateway 容器，最后执行 `docker compose up -d --no-deps --force-recreate provider-gateway`。`--no-deps` 是强制要求，`--force-recreate` 用于保证 provider 重新注册能力标签并避免 compose no-op；升级 provider-gateway 时不得重建或停止 database、backend-core、frontend。对 D518、D601 这类计算节点，`mode: "schedule"` 是正式重建/升级 `provider-gateway` 容器的唯一标准路径；升级执行路径使用 Docker socket 和只读仓库挂载，不使用 Host SSH 维护桥作为自动调度通道。

使用 compact build context 的节点必须把 context 视为派生缓存，不得让它成为版本真相。D601 这类节点可能让 Compose 从 `.state/provider-<ID>-build-context` 构建兜底镜像，以便复用本地基础镜像和缩小构建输入；`provider.upgrade mode=schedule` 在 `docker compose build` 前必须从只读 `/workspace/src/components/provider-gateway` 与 `/workspace/src/components/shared` 刷新该 context，并把宿主 `.state` 以可写方式只挂给 detached updater。验收不能只看 updater 日志中的 `Built`、candidate reconnect 或本地 promote 文本，必须以 backend-core 任务结果中最终非 candidate Compose 容器的 `providerGatewayVersion`、`unideskCapabilities`、`containerId`、`restartPolicy`、`pidMode` 和 `heartbeatTimestamp` 为准；如果线上仍上报旧版本，说明运行镜像没有真正更新，必须先修复构建 context 再重跑标准升级。

远程升级策略固定为 always-enabled：只要 provider-gateway 在线并声明 `provider.upgrade`，`mode: "schedule"` 就必须真正调度升级容器，不允许被 `PROVIDER_UPGRADE_ENABLED=false`、前端隐藏按钮或服务端特殊名单禁用。升级能力的安全边界不是开关，而是显式 `PROVIDER_UPGRADE_*` 配置、Docker socket 权限、只读仓库挂载、固定 Compose service 和 `--no-deps` 约束。升级计划中必须展示 `policy: "always-enabled"`、updater 容器名、runner image、workspace、Compose project/service、env file、compose file 和实际 `docker run` 命令，方便前端任务历史与 CLI debug 直接诊断。

`mode: "schedule"` 调度成功后，provider-gateway 只能把任务推进到 `running`，不能返回终态成功；backend-core 必须接管最终终态判定。updater 必须先按 Compose 构建新镜像，再用旧容器的 `Config.Env` 生成候选 env-file，并复用旧容器的 Docker socket、日志目录、SSH 私钥只读挂载、Compose 网络和 `extra_hosts`；候选容器启动时 restart policy 必须先是 `no`，并显式使用 `--pid host` 保持节点级进程资源采集。candidate 通过日志级 register ack 后仍只是 promotion 前置条件；最终成功必须等 `docker compose up -d --no-deps --force-recreate provider-gateway` 创建出的非 candidate Compose 容器向 backend-core 上报目标版本 heartbeat。升级计划的 `replacementStrategy` 必须包含 `oldGatewaySleepMs`、`validationTimeoutMs`、`finalPromotedReconnectTimeoutMs`、`finalPromotedHeartbeatTimeoutMs`、`promoteOnlyAfterCandidateValidation`、`candidateValidationIsNotTerminalSuccess`、`finalPromotedComposeHeartbeatRequired`、`candidateUsesOldContainerEnvironment`、`candidateUsesOldContainerMounts`、`candidateUsesOldContainerNetworks`、`candidateUsesOldContainerExtraHosts` 和 `candidateUsesHostPidNamespace`，并且必须在 plan 中显示指定 Provider 的当前/目标 gateway 版本号，便于前端和 CLI 判断这不是旧的先删旧容器再 up 的危险流程。

`provider.upgrade` 的终态 result 必须包含最终 Compose 容器的 `containerId`、`version`、`restartPolicy`、`pidMode` 和 `heartbeatTimestamp`；字段不可得时必须给出稳定的 `*UnavailableReason`，不能省略或用空字符串伪装成功。若 backend-core 在窗口内只观察到 candidate 容器 heartbeat、旧版本 heartbeat、离线状态或缺失 heartbeat，任务必须 `failed`，`reason` 使用结构化 blocker 语义，并包含 last-known-good/rollback 语义，提示以旧容器恢复或回滚为优先处置。

远程更新记录的权威来源是 backend-core 保存的 `provider.upgrade` 任务历史，而不是 provider-gateway 容器日志文件。frontend 必须按 Provider 聚合这些任务，并把状态、模式、task id、来源、耗时、策略、updater 容器摘要、失败原因和更新时间渲染为表格或卡片；完整 task/result JSON 只能由操作员点击 `查看原始JSON` 后查看。

旧版 provider-gateway 如果只能返回 plan 或因为旧环境中的 `PROVIDER_UPGRADE_ENABLED=false` 拒绝 schedule，需要先通过任意现有维护通道手动 bootstrap 一次。bootstrap 的目标不是长期流程，而是把节点更新到支持 always-enabled 远程升级和 Host SSH / WSL SSH 维护桥的版本；完成后必须立刻用 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 做一次真实一键升级验证，再用 `bun scripts/cli.ts debug health` 或公网 frontend 确认该节点仍在线、`unideskCapabilities` 包含 `provider.upgrade`，需要 SSH 维护的 WSL 节点还必须包含 `host.ssh`。

## Provider Triage

`bun scripts/cli.ts provider triage <PROVIDER_ID>` 是 provider 运行状态的只读多信号裁决入口。输出必须包含 `decision`、`retryable`、`healthyScopes`、`failedScopes`、`degradedScopes`、`blockingDisposition`、`rationale`、`signals` 和 `recommendedCrossChecks`。`decision` 的长期语义是：`global-offline` 表示 provider heartbeat、Host SSH、k3s 或 scheduler 等多个独立关键面同时失败且没有健康交叉证据；`service-degraded` 表示 registry、service proxy 或单个用户服务局部退化但仍存在 provider 级健康信号；`retryable-transient` 表示单次 runner-local、SSH、proxy 或 API timeout 证据不足，应重试或补交叉验证；`healthy` 表示未观察到失败或退化信号。

`recommendedCrossChecks` 必须保留 argv 形态的 Host SSH 自检：`trans <PROVIDER_ID> argv true`。这条命令用于证明非交互维护桥仍可用；如果自由 ssh-like 形态出现 timeout、`kex_exchange_identification` 或 `Connection closed by remote host`，应先按 CLI 输出的 `UNIDESK_SSH_HINT` 改用 `trans D601 sh -- '<command>'` 或 `trans D601 bash -- '<bash command>'` 复测，再结合 `provider triage` 判断是否真是 provider 级故障。

D601 这类长期 WSL provider 不得因为单一路径失败被直接写成全局离线。典型局部退化包括 artifact registry 的 `unidesk-artifact-registry.service` inactive，但 registry container 仍 running、listener 仍绑定 loopback、`http://127.0.0.1:5000/v2/` 返回 200；这种状态应在 registry scope 内显示 degraded，并在 provider triage 中落到 `decision=service-degraded`，只提示修复 systemd drift，不阻断所有 D601 上的 Code Queue、k3sctl-adapter 或业务 API 判断。

## Manual Upgrade Maintenance

手动升级只用于把旧节点 bootstrap 到支持 always-enabled 远程升级的版本；bootstrap 完成后，常规重建/升级必须回到 `provider.upgrade mode=schedule`，不得再用 SSH 透传同步重建 `provider-gateway`。节点侧维护步骤是：进入节点本地 UniDesk 仓库，确认 GitHub 访问走本机 provider-gateway WS egress proxy，例如 `git config --local http.proxy http://127.0.0.1:18789` 和 `git config --local https.proxy http://127.0.0.1:18789` 后再执行 `git pull --ff-only` 获取主 server 已推送版本；不得让 provider 侧 Git 拉取退回直连公网、SSH SOCKS 或公开 master proxy。随后确认 `.state/provider-<ID>.env` 中存在 `UNIDESK_MASTER_SERVER=http://152.53.229.148/`、`PROVIDER_ID=<ID>`、`PROVIDER_NAME=<ID>`、`PROVIDER_TOKEN`、`PROVIDER_LABELS_JSON`、`PROVIDER_UPGRADE_HOST_PROJECT_ROOT=/home/ubuntu/unidesk`、`PROVIDER_UPGRADE_WORKSPACE_PATH=/workspace`、`PROVIDER_UPGRADE_COMPOSE_FILE`、`PROVIDER_UPGRADE_ENV_FILE`、`PROVIDER_UPGRADE_COMPOSE_PROJECT`、`PROVIDER_UPGRADE_SERVICE=provider-gateway`、`PROVIDER_UPGRADE_RUNNER_IMAGE=unidesk_provider-gateway:<id>`、`DOCKER_SOCKET_PATH=/var/run/docker.sock`、`MONITOR_DISK_PATH=/`、心跳和重连参数。旧 env 文件中如果还残留 `PROVIDER_UPGRADE_ENABLED`，新版 provider-gateway 会忽略它；长期文档和新部署不得再依赖这个键。

如果节点固定 UniDesk 仓库存在并行未提交修改或落后太多，不能为了 provider-gateway 恢复而 stash、reset 或把脏工作区作为构建真相。应在同一节点的固定仓库下从最新 remote 创建独立 clean runtime worktree，例如 `/home/ubuntu/unidesk/.worktree/provider-gateway-<ID>-runtime`，把节点本地 `.state/provider-<ID>.env`、`provider-<ID>.yml` 和必要的本地 Dockerfile/Compose 运行态指向该 worktree，并把 `PROVIDER_UPGRADE_HOST_PROJECT_ROOT` 改为该 clean worktree。bootstrap 成功后仍必须立刻执行标准 `provider.upgrade mode=schedule` 和 Host SSH/`trans` 原入口验收；clean runtime worktree 是恢复固定 repo 隔离性的手段，不是新的长期 source truth。

`PROVIDER_UPGRADE_HOST_PROJECT_ROOT` 指向的 runtime source root 必须是可执行 `git status --short --branch`、`git fetch origin <branch>` 和 `git pull --ff-only origin <branch>` 的真实 Git checkout；无 `.git` 的源码快照不能作为 provider-gateway 升级 source truth。节点没有 GitHub 私仓凭据时，可以先把 `origin` 指向同节点受控 local bare mirror，但该 mirror 必须由有权限的 source truth 显式同步，并在 closeout 中说明 mirror 同步入口和当前 commit；不能把本地 bare mirror 的 `Already up to date` 误写成已直接跟 GitHub 同步。`provider.upgrade mode=plan` 的验收必须同时看 `plan.hostProjectRoot`、`plan.workspaceStateMount`、当前/目标 gateway 版本和最终 Compose 容器 heartbeat 字段。

手动 bootstrap 或从旧部署迁移到 Compose 管理后，运行中的 provider-gateway 容器必须带有与 `.state/provider-<ID>.env` 中 `PROVIDER_UPGRADE_COMPOSE_PROJECT` 和 `PROVIDER_UPGRADE_SERVICE` 完全一致的 `com.docker.compose.project` / `com.docker.compose.service` label。容器名相同、镜像 tag 相同或 Docker `running` 都不能替代这个 label 验收；`provider.upgrade mode=schedule` 的 updater 会按这些 Compose labels 定位旧容器并安全替换，project 漂移会导致 updater 找不到当前 service。发现漂移时，应在节点本地用正确 `-p <compose-project>` 和同一 env/compose 文件重建一次，再执行标准 `provider.upgrade mode=schedule` 复验。

如果节点已有专用 Compose，优先用节点本地 Compose 手动重建一次：`docker compose --env-file .state/provider-<ID>.env -f <compose-file> -p <compose-project> up -d --no-deps --build --force-recreate provider-gateway`。这条命令必须在节点本地终端、节点自有 Web terminal、系统计划任务或 detached shell 中执行；不得通过正在被重建的 UniDesk provider-gateway 自己提供的 SSH 透传同步执行，否则旧 provider 容器停止时会切断 SSH client，可能导致重建中断在旧容器已停、新容器未起的状态。若只能通过 UniDesk 触达该节点，必须使用 `provider.upgrade mode=schedule` 的 detached updater，或先用节点本地 `nohup`/systemd 启动一个不依赖当前 provider 容器生命周期的重建脚本。老版 `docker-compose` 可能在重建已存在容器时因为 `ContainerConfig` 兼容问题失败；此时只能移除目标 provider-gateway 容器后重新 `up -d --no-deps provider-gateway`，不得执行 `down -v`、`docker volume rm` 或任何会影响 database 命名卷的命令。如果节点当前只有 `docker run` 部署，则先构建镜像 `docker build -f src/components/provider-gateway/Dockerfile -t unidesk_provider-gateway:<id> .`，再以固定容器名重建：使用 `--restart always --pid host`，挂载 `/var/run/docker.sock:/var/run/docker.sock`、`/home/ubuntu/unidesk:/workspace:ro`、节点日志目录到 `/var/log/unidesk`，如需 WSL SSH 维护桥还要把只读私钥目录挂载到 `/run/host-ssh`，并使用同一个 `.state/provider-<ID>.env` 启动。无论 Compose 还是 `docker run`，容器名和镜像 tag 都必须带 Provider ID，便于 Docker 状态页、进程资源表、任务历史和节点本地排障互相对应。

手动升级完成后的判定标准固定为主 server 可观测结果，而不是节点容器 `running`：访问公网 frontend `http://152.53.229.148:18081/`，确认该 Provider 在线；随后在任意装有本仓库且 `config.json` 含正确 frontend 登录凭据的计算节点上执行 `bun scripts/cli.ts --main-server-ip 152.53.229.148 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 300000`，确认任务 `succeeded` 且 result 包含最终 Compose 容器 `containerId`、目标 gateway `version`、`restartPolicy=always`、`pidMode=host` 和新的 `heartbeatTimestamp`；最后再次查看 frontend 或执行 `bun scripts/cli.ts --main-server-ip 152.53.229.148 debug health`，确认节点重连、指标恢复、labels 中 `host.ssh` 能力存在。每个 provider-gateway 手动升级后都必须用 remote CLI 再执行 `bun scripts/cli.ts --main-server-ip 152.53.229.148 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 152.53.229.148 ssh <PROVIDER_ID> hostname`，验证维护桥没有在升级后丢失；该 remote CLI 默认走公网 frontend，不需要指定 `--main-server-key`。

## Host SSH Maintenance Bridge

宿主 SSH / WSL SSH 转发是 provider-gateway 部署的必备维护能力，但只作为应急维护辅助路径，不用于自动计算任务调度。实现参考 `../web-terminal` 的经验：容器内使用只读挂载的私钥，主动连接宿主或 WSL sshd，并设置 `BatchMode=yes`、`StrictHostKeyChecking=accept-new`、`ServerAliveInterval=20` 和 `ServerAliveCountMax=3`。TTY 策略必须按会话类型区分：无远端命令的交互登录 shell 使用 `ssh -tt`，带远端命令的会话使用 `ssh -T`，避免脚本 stdin 被伪终端回显或把 `Ctrl-D` 当成业务输入。主 server Compose 会把 `config.json` 的 `sshForwarding.keyDir` 只读挂载为 `/run/host-ssh`，provider 标签会上报 `hostSshConfigured`、`hostSshKeyPresent` 和 `hostSshTarget`，便于在前端节点清单确认维护桥是否具备条件。

WSL 计算节点使用 Docker Desktop daemon 时，provider-gateway 容器通常应连接 `host.docker.internal:22`，目标是当前 WSL 发行版里的 sshd，而不是给节点开放公网 SSH。节点侧必须确认 sshd 监听 `22`、目标用户可用维护公钥免密登录、`authorized_keys` 与挂载到 `/run/host-ssh/id_ed25519` 的私钥匹配；如果容器内直连 `host.docker.internal` 都失败，先修复 WSL sshd、Docker Desktop host gateway 或密钥权限，再排查 UniDesk WebSocket 透传。

WSL provider 需要调用 Windows-only 工具链时，优先在 WSL 用户的 `~/.local/bin` 放轻量 shim，而不是维护一套完全分叉的 Windows skill。稳定形态是：`win-powershell` 负责 Windows 发现和诊断，`win-cmd` 通过临时 `.cmd` 文件执行命令以规避 UNC 当前目录和带空格路径的 cmd 引号问题，`win-py` 将 `/mnt/<drive>/...` 参数转换为 Windows 路径后调用 `py.exe`，`win-npm` 通过 `npm.cmd --prefix <skill_dir>` 运行需要访问 COM 口的 TypeScript skill。Keil 这类依赖 Windows GUI/驱动/注册表的 skill 应由 WSL wrapper 调用 Windows 侧 skill 脚本；board-comm 这类纯 TCP JSON-RPC skill 可以直接用 WSL Python 运行；serial-monitor 需要访问 Windows COM 口时应走 Windows Node/npm。不要用宽泛的 `find /mnt/*` 扫 Windows 盘，skill 位置先用 `trans <PROVIDER_ID> skills`，项目位置优先用 PowerShell 或结构化 `glob`/`find` 缩小范围。Windows 透传 wrapper 属于节点 bootstrap，不等于修改 WSL skill 业务代码；完整规则见 `docs/reference/windows-passthrough.md`。

维护桥通过真实 WebSocket dispatch 暴露为 `host.ssh` 命令。默认 payload 使用 `mode: "probe"`，远端只执行一个短命令并返回 `UNIDESK_SSH_TEST user=... host=... bridge=host.ssh cwd=...`；需要人工诊断时可以显式使用 `mode: "exec"` 与 `command` 字段执行有界命令。所有 `host.ssh` 执行都必须有超时，stdout/stderr 在 task result 中截断展示；自动升级和普通任务仍必须使用 Docker socket 与 `provider.upgrade`，不得把 WSL SSH 维护桥当成调度通道。

Host SSH 的 cwd 语义必须区分默认目录和显式 workspace。未传 cwd 时可以进入 `HOST_REMOTE_CWD`，失败后再落到登录目录；但 `trans <provider>:/absolute/workspace ...` 或等价 `host.ssh` payload 显式携带 cwd 时，provider-gateway 必须先 `cd` 到该目录，失败时返回非零并在 stderr 输出 `UNIDESK_SSH_CWD_FAILED`，其中 `failureKind=cwd-failed`、`cwd=<requested>`。显式 workspace 不得静默 fallback 到 `/root`、`HOST_REMOTE_CWD` 或登录目录；否则 workspace 预检、`git status`、`apply-patch` 和 source truth 判断会误落到错误目录。

面向人的终端入口是 `trans <PROVIDER_ID> [ssh-like args...]`。无后续参数时打开远端登录 shell，有后续参数时执行远端命令并返回远端 exit code；该入口的 client 侧仍连接 backend-core 内网 `/ws/ssh` broker，core 只用 provider WebSocket 下发 open/dispatch 控制消息，终端 stdin/stdout/stderr 数据面必须走 provider 主动连接 main server 的 `host.ssh.tcp-pool` TCP warm pool，不新增计算节点入站要求，也不保留旧 WebSocket 数据 fallback。传统 ssh 传输参数由 provider-gateway 环境变量统一控制，CLI 只负责把 Provider ID 后的远端命令和终端 stdin/stdout/stderr 透传过去。非交互单进程远端命令优先使用 argv 入口：`trans D601 argv true`；需要 shell 特性时在 operation 位置显式写 `sh` 或 `bash`，例如 `trans D601 sh -- '<command>'` 或 `trans D601 bash -- '<bash command>'`。WSL 节点需要同时看清 Linux/WSL 与 Windows 两套 skill 时，使用 `trans <PROVIDER_ID> skills`，该命令只通过已建立的维护桥读取 `SKILL.md` 元数据，不要求 provider-gateway 新增业务 API。

验证 WSL SSH 桥时，先在目标 WSL 中启动 sshd 并确保维护公钥写入目标用户的 `authorized_keys`，再确认目标 provider 注册 labels 中 `unideskCapabilities` 包含 `host.ssh`。运行 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000` 后，结果应在 `debug task latest` 或前端任务历史中显示 `status: succeeded`、`probeLine` 含 `UNIDESK_SSH_TEST`、`exitCode: 0`，并且目标节点 labels 中 `hostSshKeyPresent` 为 true；随后运行 `trans <PROVIDER_ID> argv true` 验证非交互 argv 维护命令，再运行 `trans <PROVIDER_ID> hostname` 验证近似原生 ssh 的远端命令体验。在计算节点本机自测时，使用 remote CLI 透传同一组命令：`bun scripts/cli.ts --main-server-ip 152.53.229.148 debug health`、`bun scripts/cli.ts --main-server-ip 152.53.229.148 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000`、`bun scripts/cli.ts --main-server-ip 152.53.229.148 ssh <PROVIDER_ID> argv true` 和 `bun scripts/cli.ts --main-server-ip 152.53.229.148 ssh <PROVIDER_ID> hostname`；默认 remote CLI 走公网 frontend 登录态，不需要主 server SSH key。健康检查必须能看到该 Provider 在线、`hostSshConfigured=true`、`hostSshKeyPresent=true`、`hostSshTarget` 正确、`unideskCapabilities` 包含 `host.ssh`，probe 必须返回 `UNIDESK_SSH_TEST`，`ssh <PROVIDER_ID> argv true` 与 `ssh <PROVIDER_ID> hostname` 必须 exit code 为 0。如果 D518 这类 WSL 节点没有公网 SSH 入口，也必须通过这个 provider-gateway 自连维护桥完成验证，而不是要求主 server 直接连节点公网 22 端口；旧版 provider 未声明 `host.ssh` 时必须先升级 provider-gateway，否则 core 会拒绝 SSH 透传。
