−25%

Windows 按年付费,截至 10 月 31 日。 查看套餐

EQVPS
开始使用

用于编程智能体预览的 VPS:智能体推送的每个分支都有自己的 URL

编程智能体完成一个分支,说它能用。给它一台可以验证这一点的服务器:每个分支构建成容器,在密码保护的独立 HTTPS 子域名上打开;智能体的 SSH 密钥只能启动和停止预览。

编程智能体说功能已经能用了。也许确实如此。它写的测试都通过了,可这证明的东西比大家希望的要少。你真正想要的,是在它接近用户之前先在浏览器里打开看看,而且不需要让智能体碰生产环境。

预览环境正好解决这个问题,而且不需要什么平台。一台带独立 IPv4 的 VPS、Docker、Caddy 和两个短脚本,就能给每个分支一个自己的 URL,比如 https://feature-login-form.preview.example.com,有密码保护,每次推送都会重新构建。智能体拿到的 SSH 密钥只能启动和停止预览,别的什么都做不了。

我们在测试服务器上把这套搭了起来,写之前完整跑了一遍:创建、打开、再次推送、拆除、尝试滥用密钥。

需要准备什么

  • 一台有公网 IPv4 的 VPS。AI-Agent-IP 可以同时容纳多个预览。
  • 一条通配 DNS 记录:*.preview.example.com → 服务器 IP。
  • 你的应用仓库,里面有一个在 8080 端口提供服务的 Dockerfile。任何语言都行。
  • 装好 apt install -y docker.io caddy git 的 Ubuntu 24.04 或 Debian 12/13。

用只读的部署密钥把仓库克隆到 /srv/app,只需一次。

两个脚本

/usr/local/bin/preview-up 构建一个分支并上线:

#!/usr/bin/env bash
# preview-up <branch> — build the branch and open it at https://<name>.preview.example.com
set -euo pipefail
BRANCH="$1"
NAME=$(printf '%s' "$BRANCH" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/--*/-/g; s/^-//; s/-$//' | cut -c1-40)
DIR=/srv/previews/$NAME
git -C /srv/app fetch --quiet origin "$BRANCH"
rm -rf "$DIR"; git -C /srv/app worktree prune
git -C /srv/app worktree add --quiet --force --detach "$DIR" FETCH_HEAD
docker build --quiet -t "preview-$NAME" "$DIR" >/dev/null
docker rm -f "preview-$NAME" >/dev/null 2>&1 || true
docker run -d --name "preview-$NAME" --restart unless-stopped --memory 512m -p 127.0.0.1::8080 "preview-$NAME" >/dev/null
PORT=$(docker port "preview-$NAME" 8080/tcp | head -1 | awk -F: '{print $NF}')
cat > "/etc/caddy/previews/$NAME.caddy" <<CADDY
$NAME.preview.example.com {
	import preview_common
	reverse_proxy 127.0.0.1:$PORT
}
CADDY
systemctl reload caddy
echo "https://$NAME.preview.example.com"

/usr/local/bin/preview-down 把它删掉:

#!/usr/bin/env bash
# preview-down <branch> — remove the branch preview
set -euo pipefail
NAME=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/--*/-/g; s/^-//; s/-$//' | cut -c1-40)
docker rm -f "preview-$NAME" >/dev/null 2>&1 || true
docker image rm "preview-$NAME" >/dev/null 2>&1 || true
rm -f "/etc/caddy/previews/$NAME.caddy"; rm -rf "/srv/previews/$NAME"; git -C /srv/app worktree prune
systemctl reload caddy

里面有几处是刻意为之。容器只在 127.0.0.1 上以随机端口发布,所以除了经过 Caddy,没有东西能访问到它们。这一点很重要,因为 Docker 发布的端口会绕过 ufw。每个预览有 512 MB 内存上限,一个失控的分支不会拖垮其他分支。再次运行同一个分支会就地重新构建:推送、运行 up、刷新。

Caddy:每个预览一个文件,所有预览共用一个密码

mkdir -p /etc/caddy/previews /srv/previews
chmod +x /usr/local/bin/preview-up /usr/local/bin/preview-down
caddy hash-password --plaintext 'choose-a-review-password'

把哈希填进 /etc/caddy/Caddyfile:

(preview_common) {
	basicauth {
		reviewer PASTE-THE-HASH-HERE
	}
}
import /etc/caddy/previews/*.caddy

然后执行 systemctl reload caddy。现在所有预览都在同一个登录后面,带调试输出的半成品分支不会暴露在公网上。较新的 Caddy 版本里这个指令也写作 basic_auth;basicauth 在两种版本里都还能用。

智能体的密钥:只有 up 和 down

智能体需要的是触发预览,而不是拥有服务器。创建一个 deploy 用户和一个只接受两条命令的网关脚本:

#!/usr/bin/env bash
# /usr/local/bin/preview-gate — the only thing the agent's key may run
set -euo pipefail
read -r ACTION BRANCH EXTRA <<< "${SSH_ORIGINAL_COMMAND:-}"
[[ -z "${EXTRA:-}" && "${BRANCH:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$ && "$BRANCH" != *..* ]] || { echo "usage: up|down <branch>" >&2; exit 2; }
case "$ACTION" in
  up)   exec sudo /usr/local/bin/preview-up "$BRANCH" ;;
  down) exec sudo /usr/local/bin/preview-down "$BRANCH" ;;
  *)    echo "usage: up|down <branch>" >&2; exit 2 ;;
esac
useradd -m -s /bin/bash deploy
echo 'deploy ALL=(root) NOPASSWD: /usr/local/bin/preview-up, /usr/local/bin/preview-down' > /etc/sudoers.d/preview
chmod 440 /etc/sudoers.d/preview && visudo -cf /etc/sudoers.d/preview
chmod 755 /usr/local/bin/preview-gate

在 /home/deploy/.ssh/authorized_keys 里,把智能体的公钥绑定到这个脚本:

restrict,command="/usr/local/bin/preview-gate" ssh-ed25519 AAAA... coding-agent

在智能体工作的机器上:

ssh deploy@preview.example.com up feature/login-form
# https://feature-login-form.preview.example.com
ssh deploy@preview.example.com down feature/login-form

在我们的测试中,up 构建并发布了分支,新提交后的第二次 up 提供了新版本,down 删除了容器和站点。用这把密钥尝试 cat /etc/shadow、up x; id、-x 或 a/../b 这样的分支名,或者交互式登录,得到的都只有 usage: up|down <branch>。分支名必须以字母或数字开头,且不能包含 ..。

接入智能体

用 Claude Code 或 Codex 时,在项目的指令文件里加一行就够了:

推送分支后,运行 ssh deploy@preview.example.com up <branch>,并把打印出的 URL 写进你的总结。分支合并或放弃后运行 down。

如果你的 CI 已经在每次推送时运行,就从 CI 里调用同一条命令,让密钥完全不出现在智能体的环境里。这是更严格的做法,凡是超出个人项目的情况,我们都会选它。

需要注意的地方

  • 磁盘。 每次构建都会留下镜像层。每周用 cron 跑一次 docker image prune -f 就能控制住;down 已经会删除预览自己的镜像。
  • 没人关闭的分支。 智能体开的分支比人多。用一个 cron 列出超过一周的 preview-* 容器并对它们运行 preview-down,就不会冒出二十个被遗忘的预览。
  • 机密。 预览应该使用测试凭据。basic auth 密码能挡住陌生人,但这不代表可以把预览连到生产数据库。
  • 构建时间。 镜像在服务器上构建,所以重型构建会和正在运行的预览争抢 CPU。如果构建要好几分钟,就在 CI 里构建,再拉取镜像。

智能体本身不一定要住在这台服务器上。如果住在上面,请看用于 Claude Code 的 VPS;要让它自己下单并照看这台服务器,请把它接入 EQVPS MCP 服务器。同一台机器还可以托管你们团队的 MCP 网关。要在完全没有服务器的情况下运行不可信代码,可以用沙箱,而带独立 IPv4 的套餐每月 $8 起。

常见问题

为什么不让智能体直接部署到生产环境?

因为“测试通过”和“能用”不是一回事,而且最不该做这个判断的就是智能体。预览让人在任何东西到达用户之前先点开看看,代价只是多一条命令。

选哪个套餐?

取决于同时运行多少个预览。AI-Agent-IP(每月 $15,4 vCPU,4 GB 内存,40 GB 磁盘)可以轻松容纳几个各自限制 512 MB 的小型 Web 应用。最重的是 Docker 构建,镜像较大时要留意磁盘和内存。

需要通配符证书吗?

不需要。Caddy 会自动为每个预览子域名单独申请证书。只有 DNS 记录是通配的。Let's Encrypt 每个注册域名每周允许约 50 张新证书,除非智能体创建上百个分支,否则绰绰有余。

智能体能用它的 SSH 密钥搞坏服务器吗?

用这把密钥不能。它被限制为一条命令,只接受带安全分支名的“up <分支>”或“down <分支>”。shell 访问、额外参数和端口转发都会被拒绝。

NAT 套餐能用吗?

不能。预览必须能通过各自子域名的 80 和 443 端口访问,这需要公网 IPv4。NAT 套餐只转发 SSH 端口。

评论

暂无评论。来做第一个吧。

发表评论

评论在显示前会经过审核。