隔离测试 Agent 环境部署指南 v2(完整版,从零到部署一份文档搞定)

隔离测试 Agent 环境部署指南 v2(完整版,从零到部署一份文档搞定)

巴黎机票监控 + 巴萨车票监控 + 假日感知 AI 行程规划

这是一份自包含文档,不需要再回看 v1。相对最初版本的核心变化:把"判断"从 LLM prompt 里挪到 Python 代码里,权限模型从裸 ask 改成精确规则,密钥彻底和 OpenCode 进程隔离,Playwright 浏览器进程容器化加固,并新增一个每周跑一次的"假日规划器",结合巴伦西亚本地假期自动找出值得关注的出行窗口。


0. 这一版改了什么(对照上一轮审查)

问题v1 的做法v2 的做法
无人值守遇到 ask 会卡住"bash":"ask"改用 v2 permissions 数组,明确 allow/deny,不留裸 ask
Telegram token 被 OpenCode 子进程继承source .env 后直接跑 opencode拆成两个进程,token 只在 notify.py 自己的调用里出现
Playwright 默认行为存疑(不同版本文档不一致,有的说默认持久化,有的说新版默认已改成内存态)未指定显式加 --isolated,不依赖"默认行为"
LLM 自己判断"最低价"prompt 里让它直接给结论prompt 只要求它如实列出看到的所有数据点min() 由 Python 算
不同日期价格直接比较last_price.json 只存裸 price改成按 route+date_out+date_return 做 key
@playwright/mcp@latest每次动态解析最新版锁定具体版本号
浏览器可以导航到任意网站未限制--allowed-origins 白名单(注意:官方文档明确写了这不是安全边界,只是防止误导航的辅助手段,见下文说明)

1. 隔离环境搭建(一次性)

1.1 创建独立的本地 macOS 账户

系统设置 → 用户与群组 → 添加账户,选择「标准」账户,账户名建议 agentlab

关键:跳过"登录 Apple ID"这一步,保持纯本地账户——避免 iCloud/Handoff 把这个环境和你的主账户串起来。

登录到这个新账户后,后续所有操作都在这个账户下进行。

1.2 安装基础工具

# 安装 Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Node.js(OpenCode 需要)
brew install node

# Python3(notify.py / price_store.py 等脚本要用)
brew install python3

# Xcode 命令行工具(很多依赖需要)
xcode-select --install

1.3 安装 OpenCode

npm install -g opencode-ai
opencode --version   # 确认安装成功

跑一次 opencode --help,确认能看到 permissions 相关说明——这份文档用的是 v2 权限系统语法(permissions 数组),如果你装到的版本文档里只有旧的 permission 对象写法,去官方文档确认一下当前推荐语法,配置字段名可能需要相应调整。

1.4 Gemini 免费层配置(模型名不要抄死)

重要提醒:Gemini 的 Flash 系列这半年迭代很快(2.5 → 3 → 3.5 → 3.6 → 3.7),而且免费层覆盖的具体型号会变——3.7 Flash 目前是否进免费层,不同渠道的信息互相矛盾,我没能拿到一个确定答案。

部署那天请务必先做这一步:打开 https://ai.google.dev/gemini-api/docs/pricing ,确认当前哪个 Flash 型号标着"Free of charge",把型号 ID 填进下面配置里的 model 字段。写这份文档时相对确定还在免费层里的是 google/gemini-3.6-flash,仅供参考,请以你实际部署时的官方页面为准。

  1. 打开 https://aistudio.google.com/apikey ,免费创建一个 API Key(无需信用卡)
  2. 配置到 OpenCode:
opencode auth login -p google
# 按提示粘贴 API Key,会存到 ~/.local/share/opencode/auth.json
# 这个凭据走的是 OpenCode 自己的 auth 文件,不经过 shell 环境变量,
# 和后面 3.3 节讲的 Telegram token 隔离是同一个思路

1.5 创建 Telegram Bot(免费通知渠道)

  1. Telegram 里搜索 @BotFather,发送 /newbot,按提示起名字,拿到一个 TELEGRAM_BOT_TOKEN
  2. 给这个新 bot 发一条任意消息(比如 “hi”),否则下一步拿不到 chat_id
  3. 浏览器打开 https://api.telegram.org/bot<TOKEN>/getUpdates,找到返回 JSON 里的 "chat":{"id": 数字},这就是你的 TELEGRAM_CHAT_ID

把这两个值先记下来,下一节建 .env 时要用。

1.6 目录结构

mkdir -p ~/agent-lab/{shared,paris-flights,bcn-train,holiday-planner}
mkdir -p ~/agent-lab/shared/{data,run}
~/agent-lab/
  .env                     # 只给 notify.py 用,opencode 永远看不到
  .aiignore
  opencode.json
  shared/
    price_store.py          # 价格历史存取(route+date 做 key,原子写)
    notify.py                # Telegram 推送
    extract_prompt.py        # 生成"只提数据不判断"的统一 prompt
    data/
      valencia_holidays.json
      price_history.json
    run/                     # 中间结果,不再用 /tmp
  paris-flights/
    check.sh
  bcn-train/
    check.sh
  holiday-planner/
    plan.py
    plan.sh

1.7 创建 .env(存放上一节拿到的 Telegram 凭据)

cat > ~/agent-lab/.env << 'EOF'
TELEGRAM_BOT_TOKEN=你的token
TELEGRAM_CHAT_ID=你的chat_id
EOF
chmod 600 ~/agent-lab/.env

再建一个 .aiignore,作为额外一层防线(注意:这只是提示级过滤,真正防泄露靠的是第 3.3 节 notify.py 的进程隔离设计,不要只依赖这个文件):

cat > ~/agent-lab/.aiignore << 'EOF'
.env
*.log
*.err
EOF

2. 核心配置:opencode.json(v2 权限系统 + 容器化 Playwright)

2.1 为什么改成容器跑 Playwright

上一版是 npx -y @playwright/[email protected] 直接在 agentlab 账户下跑浏览器进程。这样有一个 v2 权限模型管不到的缺口:安装/运行浏览器这件事本身发生在 OpenCode 的权限系统之外——npx 拉包、Chromium 渲染不可信页面,这些都是在 agentlab 账户的真实文件系统和真实内核上跑的,供应链投毒或渲染层漏洞理论上能摸到账户能碰到的一切。

把 Playwright 装进 Docker 容器后,多一层真实的隔离边界:容器有独立的文件系统(不共享 agentlab 账户的 ~)、独立的进程/网络命名空间,就算浏览器进程被攻破,攻击者拿到的是一个几乎是空的、用完即焚的容器,而不是你的账户环境。这个投入量级远小于整机跑 VM,但补上了"账户隔离 + 运行时权限"这个组合本身管不到的那块。

2.2 安装 Docker 运行时

2018 Intel Mac 上,Docker Desktop 偏重(自带一个完整的虚拟机+GUI),更推荐轻量的 Colima(开源、CLI,同样是"用一个精简 Linux VM 跑 Docker daemon",但没有 Docker Desktop 那套 GUI 和额外服务,资源占用小很多,对老硬件更友好):

brew install colima docker
colima start --cpu 2 --memory 2
docker info   # 确认能正常连上 Docker daemon

2.3 拉取官方镜像并锁定版本

docker pull mcr.microsoft.com/playwright/mcp
# 拿到这次拉取的具体 digest,后续用 digest 而不是 tag 来锁定,避免镜像被静默更新
docker inspect --format='{{index .RepoDigests 0}}' mcr.microsoft.com/playwright/mcp
# 输出类似:mcr.microsoft.com/playwright/mcp@sha256:xxxxxxxx...

把输出的完整 mcr.microsoft.com/playwright/mcp@sha256:xxxx 记下来,填进下面配置里。用 digest 锁定比用版本号标签更可靠——标签可能被镜像发布方重新指向新内容,digest 不会变。

2.4 opencode.json

{
  "$schema": "https://opencode.ai/config.json",
  "model": "google/gemini-3.6-flash",
  "permissions": [
    { "action": "*", "resource": "*", "effect": "deny" },
    { "action": "read", "resource": "*", "effect": "allow" },
    { "action": "read", "resource": "*.env", "effect": "deny" },
    { "action": "read", "resource": "*.env.*", "effect": "deny" },
    { "action": "webfetch", "resource": "*", "effect": "allow" },
    { "action": "websearch", "resource": "*", "effect": "allow" },
    { "action": "shell", "resource": "*", "effect": "deny" },
    { "action": "edit", "resource": "*", "effect": "deny" }
  ],
  "mcp": {
    "playwright": {
      "type": "local",
      "command": [
        "docker", "run", "-i", "--rm", "--init",
        "--pull=never",
        "--memory=768m", "--cpus=1.5",
        "--cap-drop=ALL",
        "--security-opt", "no-new-privileges",
        "--read-only",
        "--tmpfs", "/tmp",
        "-e", "PLAYWRIGHT_MCP_ISOLATED=true",
        "-e", "PLAYWRIGHT_MCP_ALLOWED_ORIGINS=https://www.google.com;https://www.google.es;https://www.renfe.com;https://www.omio.com;https://www.alsa.es",
        "mcr.microsoft.com/playwright/mcp@sha256:替换成你实际拉取到的digest"
      ],
      "enabled": true
    }
  }
}

要点:

  • 全局默认 deny,只放行 read(且 .env 单独二次拒绝)、webfetchwebsearch——没有任何 shelledit 权限,Agent 只能看和查,不能改文件、不能执行命令。这样无人值守时不会卡在 ask,也不需要冒险开 allow 给危险操作。
  • --pull=never + digest 锁定:容器镜像版本完全固定,不会在某次运行时被静默换成新内容。你想手动升级时,重新走一遍 2.3 的拉取流程,换新的 digest。
  • --cap-drop=ALL + --security-opt no-new-privileges:剥掉容器所有 Linux capability,就算容器内的浏览器进程被攻破,能做的系统调用范围也被砍到最小。
  • --read-only + --tmpfs /tmp:容器整个文件系统只读,只有 /tmp(内存里的临时空间,进程退出即清空)可写。因为我们只用 accessibility snapshot 提取数据,不需要保存截图/下载文件,容器不需要挂载任何宿主机目录——容器写不了宿主机的任何东西,也不会在宿主机上留下任何持久化痕迹
  • --memory=768m --cpus=1.5:给这台老 Intel 机器一个硬性资源上限,防止浏览器进程(尤其是同时开多个 tab 查询时)拖垮整台机器,也顺带防住了"failure 后疯狂重试打满资源"这类异常情况。
  • --allowed-origins 依然保留,但官方文档明确写了这不是安全边界,也不影响重定向,只是防止 Agent 被诱导"顺手"导航去无关网站的辅助措施。真正的边界现在是:容器隔离(这一节新加的)+ 零 shell/edit 权限(上一版就有)+ 账户隔离(最外层)。
  • PLAYWRIGHT_MCP_ISOLATED=true 是容器版对应 --isolated 的环境变量写法,保证浏览器不留 cookie/登录态,配合 --tmpfs /tmp 双重保证不留痕。

2.5 手动验证一次

在正式接入 OpenCode 之前,先单独跑一次确认容器本身没问题:

docker run -i --rm --init --pull=never \
  --memory=768m --cpus=1.5 --cap-drop=ALL \
  --security-opt no-new-privileges --read-only --tmpfs /tmp \
  -e PLAYWRIGHT_MCP_ISOLATED=true \
  mcr.microsoft.com/playwright/mcp@sha256:替换成你实际拉取到的digest \
  --help

能正常打印帮助信息就说明容器权限配置没有把它自己也锁死。


3. 共享模块

3.1 ~/agent-lab/shared/data/price_history.json(初始化)

{}

3.2 ~/agent-lab/shared/price_store.py —— 价格历史存取,按路线+日期做 key,原子写

#!/usr/bin/env python3
import json, os, tempfile

STORE_PATH = os.path.join(os.path.dirname(__file__), "data", "price_history.json")

def _key(route, date_out, date_return=None):
    return f"{route}|{date_out}|{date_return or ''}"

def load_all():
    if not os.path.exists(STORE_PATH):
        return {}
    with open(STORE_PATH) as f:
        return json.load(f)

def get_last_price(route, date_out, date_return=None):
    data = load_all()
    entry = data.get(_key(route, date_out, date_return))
    return entry["price"] if entry else None

def get_historical_min(route):
    """同一条线路(不分具体日期)历史最低价,用于假日规划器横向比较"""
    data = load_all()
    prices = [v["price"] for k, v in data.items() if k.startswith(route + "|")]
    return min(prices) if prices else None

def save_price(route, date_out, date_return, price, note=""):
    data = load_all()
    data[_key(route, date_out, date_return)] = {
        "price": price, "note": note
    }
    # 原子写:先写临时文件,再 rename,避免中途崩溃导致 JSON 损坏
    dir_name = os.path.dirname(STORE_PATH)
    fd, tmp_path = tempfile.mkstemp(dir=dir_name)
    with os.fdopen(fd, "w") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp_path, STORE_PATH)

3.3 ~/agent-lab/shared/notify.py —— Telegram 推送,token 只在这里出现

#!/usr/bin/env python3
import sys, os, json, urllib.request

def load_env(path):
    """手动解析 .env,不污染当前进程之外的任何环境——只在这一个脚本内使用"""
    env = {}
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, v = line.split("=", 1)
            env[k.strip()] = v.strip()
    return env

def send_telegram(token, chat_id, text):
    url = f"https://api.telegram.org/bot{token}/sendMessage"
    data = json.dumps({"chat_id": chat_id, "text": text}).encode()
    req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
    urllib.request.urlopen(req, timeout=10)

def main():
    # 用法: notify.py "推送文字"
    message = sys.argv[1]
    env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
    env = load_env(env_path)
    send_telegram(env["TELEGRAM_BOT_TOKEN"], env["TELEGRAM_CHAT_ID"], message)

if __name__ == "__main__":
    main()

关键改动.env 只在 notify.py 内部被读取、被使用,check.sh 不再 source .envopencode 进程的环境变量里完全不会出现 Telegram token。opencode 需要的 Gemini 凭据走的是 opencode auth login 写入的 ~/.local/share/opencode/auth.json,不经过 shell 环境变量,同样不会被裸露。

3.4 提取规范:Agent 只许"如实列数据",不许"下结论"

所有 prompt 统一遵循这个契约(这是对上一轮审查里"LLM 不该自己判断最低价"这条意见的直接回应):

角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。

任务:{具体查询指令}

把页面上你看到的**每一个**可选日期/价格组合都记录下来,不要筛选、不要挑"最优",
就算只看到一条结果也如实只输出这一条。

只输出如下格式,前后各加一行分隔符,不要输出任何其它文字或 markdown 代码块:

RESULT_BEGIN
[{"date_out":"YYYY-MM-DD","date_return":"YYYY-MM-DD或留空","price":数字}, ...]
RESULT_END

RESULT_BEGIN/RESULT_END 分隔符是为了让 Python 端可以稳定地从 Agent 输出里截取出目标 JSON,不依赖对 opencode run 底层输出格式的假设(这部分我没能百分百确认官方输出协议的细节,用显式分隔符是更保险的做法,而不是假设它一定输出"干净 JSON")。

Python 侧统一的提取+决策函数:

#!/usr/bin/env python3
import re, json

def extract_data_points(agent_output: str):
    m = re.search(r"RESULT_BEGIN\s*(.*?)\s*RESULT_END", agent_output, re.DOTALL)
    if not m:
        return []
    try:
        points = json.loads(m.group(1))
    except json.JSONDecodeError:
        return []
    # 基础校验,防止脏数据进历史记录
    valid = []
    for p in points:
        if isinstance(p.get("price"), (int, float)) and p.get("date_out"):
            valid.append(p)
    return valid

def pick_cheapest(points):
    if not points:
        return None
    return min(points, key=lambda p: p["price"])

判断"最低价"、“是否降价"这些全部是 Python 里的 min()/比较,Agent 只负责把它在页面上看到的东西如实转录成结构化数据。


4. 场景一:巴黎机票监控(战术级,高频)

~/agent-lab/paris-flights/check.sh

#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
SHARED=../shared

PROMPT='角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。

任务:用 Playwright 打开 Google Flights (https://www.google.com/travel/flights),
查询出发地 Valencia(VLC),目的地 Paris(不限机场),
往返日期:未来 8-10 周内、周五出发、周日或周一返回的组合。

把页面上你看到的每一个可选日期/价格组合都记录下来,不要筛选、不要挑"最优"。

只输出如下格式,前后各加一行分隔符,不要输出任何其它文字:

RESULT_BEGIN
[{"date_out":"YYYY-MM-DD","date_return":"YYYY-MM-DD","price":数字}, ...]
RESULT_END'

RAW=$(opencode run "$PROMPT" 2>>run.err) || { echo "opencode 调用失败"; exit 1; }
echo "$RAW" > "$SHARED/run/paris_raw_$(date +%Y%m%d_%H%M%S).txt"

python3 - "$RAW" << 'PYEOF'
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "shared"))
from extract import extract_data_points, pick_cheapest
from price_store import get_last_price, save_price

raw = sys.argv[1]
points = extract_data_points(raw)
best = pick_cheapest(points)
if not best:
    print("解析失败或无有效数据点,跳过本次")
    sys.exit(0)

route = "VLC-PAR"
last = get_last_price(route, best["date_out"], best["date_return"])
threshold = 0.9  # 同一日期组合,低于历史价的90%才提醒

if last is None or best["price"] < last * threshold:
    msg = f"【巴黎机票降价提醒】\n{best['date_out']} → {best['date_return']}\n价格:€{best['price']}"
    os.system(f'python3 ../shared/notify.py "{msg}"')
    print("已推送:", msg)
else:
    print(f"{best['date_out']} 当前 €{best['price']},未达阈值,静默跳过")

save_price(route, best["date_out"], best["date_return"], best["price"])
PYEOF

注:为了让这份文档独立可读,extract.py 里的两个函数请从第 3.4 节复制一份到 ~/agent-lab/shared/extract.py

chmod +x ~/agent-lab/paris-flights/check.sh

手动测试一次,不要一上来就上 launchd:

cd ~/agent-lab/paris-flights
./check.sh

确认能收到 Telegram 消息、price_history.json 有更新之后,再进入下面的定时部署。

launchd 配置(每 4 小时跑一次):~/Library/LaunchAgents/com.agentlab.parisflight.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.agentlab.parisflight</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/agentlab/agent-lab/paris-flights/check.sh</string>
  </array>
  <key>StartInterval</key>
  <integer>14400</integer>
  <key>StandardOutPath</key>
  <string>/Users/agentlab/agent-lab/paris-flights/run.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/agentlab/agent-lab/paris-flights/run.err</string>
  <key>RunAtLoad</key>
  <false/>
</dict>
</plist>

StartInterval 单位是秒,14400 = 4 小时。把路径里的 agentlab 换成你实际的账户名。

launchctl load ~/Library/LaunchAgents/com.agentlab.parisflight.plist
launchctl list | grep agentlab   # 确认已加载

5. 场景二:巴萨车票监控

结构和场景一完全一致,只换 prompt 和 route 标识。

~/agent-lab/bcn-train/check.sh

#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
SHARED=../shared

PROMPT='角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。

任务:依次用 Playwright 打开 Renfe (https://www.renfe.com) 和 Omio (https://www.omio.com),
查询 Valencia 到 Barcelona 未来 2 周内、周五或周六出发的单程票价(火车和大巴都查)。

把你看到的每一个班次/价格组合都记录下来,不要筛选、不要判断哪个最好。

只输出如下格式,前后各加一行分隔符,不要输出任何其它文字:

RESULT_BEGIN
[{"date_out":"YYYY-MM-DD","date_return":"","price":数字,"mode":"train或bus"}, ...]
RESULT_END'

RAW=$(opencode run "$PROMPT" 2>>run.err) || { echo "opencode 调用失败"; exit 1; }
echo "$RAW" > "$SHARED/run/bcn_raw_$(date +%Y%m%d_%H%M%S).txt"

python3 - "$RAW" << 'PYEOF'
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "shared"))
from extract import extract_data_points, pick_cheapest
from price_store import get_last_price, save_price

raw = sys.argv[1]
points = extract_data_points(raw)
best = pick_cheapest(points)
if not best:
    print("解析失败或无有效数据点,跳过本次")
    sys.exit(0)

route = "VLC-BCN"
last = get_last_price(route, best["date_out"], best.get("date_return", ""))
threshold = 0.9

if last is None or best["price"] < last * threshold:
    mode = best.get("mode", "")
    msg = f"【巴萨车票降价提醒】\n{best['date_out']} ({mode})\n价格:€{best['price']}"
    os.system(f'python3 ../shared/notify.py "{msg}"')
    print("已推送:", msg)
else:
    print(f"{best['date_out']} 当前 €{best['price']},未达阈值,静默跳过")

save_price(route, best["date_out"], best.get("date_return", ""), best["price"])
PYEOF
chmod +x ~/agent-lab/bcn-train/check.sh
cd ~/agent-lab/bcn-train && ./check.sh   # 手动测试一次

launchd 配置(车票价格波动更频繁,间隔设短一点,2 小时):~/Library/LaunchAgents/com.agentlab.bcntrain.plist,结构和巴黎机票那份一样,只改这几处:

  • Labelcom.agentlab.bcntrain
  • ProgramArguments 里的路径 → /Users/agentlab/agent-lab/bcn-train/check.sh
  • StartInterval7200(2 小时)
  • 日志路径 → 换成 bcn-train 目录下的 run.log/run.err
launchctl load ~/Library/LaunchAgents/com.agentlab.bcntrain.plist
launchctl list | grep agentlab

6. 新增:假日感知 AI 行程规划器

这是本次新增的核心功能。目标:不只盯着一个固定日期区间,而是结合巴伦西亚本地假期表,自动找出"性价比高的出行窗口”,每周汇总一次对比报告

6.1 设计逻辑

valencia_holidays.json(巴伦西亚市 2026 官方假期表)
        │
        ▼
find_bridges():Python 用 datetime 计算每个假日
        │        能不能和周末拼成长假(周二/周四假日天然形成"puente")
        ▼
生成候选出行窗口列表(每个窗口 = 一段日期范围)
        │
        ▼
对每个窗口,复用场景一/二的"提取模块"分别查机票和车票
        │        (只查数据点,不做判断——原则不变)
        ▼
Python 汇总所有窗口的最低价 + 是否邻近假日(省年假)
        │
        ▼
按"价格"和"是否借上了公共假期"排序,生成对比表
        │
        ▼
每周一次,把 Top N 选项通过 Telegram 推送

6.2 ~/agent-lab/shared/data/valencia_holidays.json

数据来源:2026 年巴伦西亚市官方劳动历(DOGV 公布的 Decreto 100/2025 + 2025 年 11 月市政府公布的地方假期决议)。这份文件需要每年手动更新一次,通常上一年年中/年末会公布次年日历。

{
  "year": 2026,
  "source": "DOGV Decreto 100/2025 (autonómico) + Resolución 12-nov-2025 (locales Valencia ciudad)",
  "last_verified": "2026-08-27",
  "holidays": [
    { "date": "2026-01-01", "name": "Año Nuevo", "scope": "nacional" },
    { "date": "2026-01-06", "name": "Epifanía del Señor", "scope": "nacional" },
    { "date": "2026-01-22", "name": "San Vicente Mártir (patrón de Valencia ciudad)", "scope": "local" },
    { "date": "2026-03-19", "name": "San José / Fallas", "scope": "autonómico" },
    { "date": "2026-04-03", "name": "Viernes Santo", "scope": "nacional" },
    { "date": "2026-04-06", "name": "Lunes de Pascua", "scope": "autonómico" },
    { "date": "2026-04-13", "name": "San Vicente Ferrer (patrón de la Comunitat Valenciana)", "scope": "local" },
    { "date": "2026-05-01", "name": "Fiesta del Trabajo", "scope": "nacional" },
    { "date": "2026-06-24", "name": "San Juan / Fogueres", "scope": "autonómico" },
    { "date": "2026-08-15", "name": "Asunción de la Virgen", "scope": "nacional" },
    { "date": "2026-10-09", "name": "Día de la Comunitat Valenciana", "scope": "autonómico" },
    { "date": "2026-10-12", "name": "Fiesta Nacional de España", "scope": "nacional" },
    { "date": "2026-12-08", "name": "Inmaculada Concepción", "scope": "nacional" },
    { "date": "2026-12-25", "name": "Natividad del Señor", "scope": "nacional" }
  ]
}

⚠️ 请在正式使用前自行核对一遍(比如 https://www.valenciabonita.es 或市政府官网的最新版本),日历数据每年由市议会决议确定,存在小概率的临时调整。这份 JSON 只是给规划器用的输入数据,不是我替你做的官方认证。

6.3 ~/agent-lab/shared/bridges.py —— 从假日表计算"puente"候选窗口(纯 Python,不涉及任何 AI 调用)

#!/usr/bin/env python3
import json, os
from datetime import date, timedelta

HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "data", "valencia_holidays.json")

WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]

def load_holidays():
    with open(HOLIDAYS_PATH) as f:
        data = json.load(f)
    return [
        {"date": date.fromisoformat(h["date"]), "name": h["name"], "scope": h["scope"]}
        for h in data["holidays"]
    ]

def find_bridges():
    """
    对每个假日判断能否和周末拼成长假:
    - 假日在周二 → 周六到周二是4天长假(周一是自然连休日)
    - 假日在周四 → 周四到周日是4天长假
    - 假日在周一 → 周六到周一是3天长假
    - 假日在周五 → 周五到周日是3天长假
    - 假日在周中(周三)→ 没有自然连休,可能需要单独请1天假
    """
    bridges = []
    for h in load_holidays():
        d, wd = h["date"], h["date"].weekday()  # Mon=0 ... Sun=6
        if wd == 1:  # Tuesday
            window_start, window_end = d - timedelta(days=3), d
            span, kind = 4, "长周末(周六-周二),周一为自然连休日"
        elif wd == 3:  # Thursday
            window_start, window_end = d, d + timedelta(days=3)
            span, kind = 4, "长周末(周四-周日),周五为自然连休日"
        elif wd == 0:  # Monday
            window_start, window_end = d - timedelta(days=2), d
            span, kind = 3, "三天周末(周六-周一)"
        elif wd == 4:  # Friday
            window_start, window_end = d, d + timedelta(days=2)
            span, kind = 3, "三天周末(周五-周日)"
        else:
            window_start, window_end = d, d
            span, kind = 1, f"假日在{WEEKDAY_NAMES[wd]},无自然连休(需额外请假才能拼假)"
        bridges.append({
            "holiday_date": d.isoformat(),
            "holiday_name": h["name"],
            "weekday": WEEKDAY_NAMES[wd],
            "window_start": window_start.isoformat(),
            "window_end": window_end.isoformat(),
            "span_days": span,
            "note": kind,
        })
    return bridges

def upcoming_bridges(from_date=None, within_days=180):
    """只看未来 N 天内、且自然连休 >= 3 天的窗口(1天的价值不大,过滤掉)"""
    from_date = from_date or date.today()
    result = []
    for b in find_bridges():
        hd = date.fromisoformat(b["holiday_date"])
        if from_date <= hd <= from_date + timedelta(days=within_days) and b["span_days"] >= 3:
            result.append(b)
    return result

if __name__ == "__main__":
    for b in upcoming_bridges():
        print(f"{b['holiday_date']} {b['holiday_name']} ({b['weekday']}) → "
              f"{b['window_start']} ~ {b['window_end']} [{b['note']}]")

先手动跑一次这个脚本,确认输出的候选窗口符合你的预期:

python3 ~/agent-lab/shared/bridges.py

6.4 ~/agent-lab/holiday-planner/plan.py —— 汇总查询 + 生成对比报告

#!/usr/bin/env python3
import sys, os, subprocess, json
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "shared"))
from bridges import upcoming_bridges
from extract import extract_data_points, pick_cheapest
from price_store import save_price, get_historical_min

DESTINATIONS = [
    {"route": "VLC-PAR", "label": "巴黎",
     "prompt_tpl": '角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。\n\n'
                   '任务:用 Playwright 打开 Google Flights,查询 Valencia(VLC) 往返 Paris,'
                   '出发日期在 {window_start} 附近(±2天内可接受的选项),返程在 {window_end} 附近。\n\n'
                   '把你看到的每一个日期/价格组合都记录下来,不要筛选。\n\n'
                   '只输出:\nRESULT_BEGIN\n'
                   '[{{"date_out":"YYYY-MM-DD","date_return":"YYYY-MM-DD","price":数字}}, ...]\n'
                   'RESULT_END'},
    {"route": "VLC-BCN", "label": "巴塞罗那",
     "prompt_tpl": '角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。\n\n'
                   '任务:用 Playwright 依次打开 Renfe 和 Omio,查询 Valencia 到 Barcelona,'
                   '出发日期在 {window_start} 附近(±1天内),火车和大巴都查。\n\n'
                   '把你看到的每一个班次/价格组合都记录下来,不要筛选。\n\n'
                   '只输出:\nRESULT_BEGIN\n'
                   '[{{"date_out":"YYYY-MM-DD","date_return":"","price":数字,"mode":"train或bus"}}, ...]\n'
                   'RESULT_END'},
]

def run_agent(prompt):
    result = subprocess.run(["opencode", "run", prompt], capture_output=True, text=True, timeout=300)
    return result.stdout

def main():
    bridges = upcoming_bridges(within_days=180)
    if not bridges:
        print("未来半年内没有符合条件(自然连休>=3天)的假日窗口")
        return

    report_lines = []
    for bridge in bridges:
        for dest in DESTINATIONS:
            prompt = dest["prompt_tpl"].format(
                window_start=bridge["window_start"], window_end=bridge["window_end"]
            )
            raw = run_agent(prompt)
            points = extract_data_points(raw)
            best = pick_cheapest(points)
            if not best:
                continue

            hist_min = get_historical_min(dest["route"])
            save_price(dest["route"], best["date_out"], best.get("date_return", ""), best["price"])

            is_new_low = (hist_min is None) or (best["price"] <= hist_min)
            report_lines.append({
                "holiday": f"{bridge['holiday_name']} ({bridge['holiday_date']})",
                "window": f"{bridge['window_start']} ~ {bridge['window_end']} [{bridge['note']}]",
                "destination": dest["label"],
                "price": best["price"],
                "is_new_low": is_new_low,
            })

    if not report_lines:
        print("本轮没有查到有效数据,跳过推送")
        return

    # 按价格排序,生成 Top 5 对比报告
    report_lines.sort(key=lambda r: r["price"])
    top = report_lines[:5]

    msg_parts = ["【本周假日出行选项 Top 5】"]
    for r in top:
        flag = " 🔻历史新低" if r["is_new_low"] else ""
        msg_parts.append(
            f"\n{r['destination']} · {r['holiday']}\n"
            f"窗口:{r['window']}\n价格:€{r['price']}{flag}"
        )
    message = "\n".join(msg_parts)

    subprocess.run(["python3", os.path.join(os.path.dirname(__file__), "..", "shared", "notify.py"), message])
    print(message)

if __name__ == "__main__":
    main()

~/agent-lab/holiday-planner/plan.sh

#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
python3 plan.py
chmod +x ~/agent-lab/holiday-planner/plan.sh

6.5 配额预算check

这个规划器每次运行会对"未来半年内每个 ≥3 天的假日窗口 × 2 个目的地"各查一次,粗略估算 2026 年下半年大概有 4-6 个符合条件的窗口,也就是每周一次运行大概 8-12 次 opencode run 调用。加上日常战术监控(巴黎 6 次/天 + 巴萨 12 次/天 ≈ 126 次/周),一周总调用量在 150 次左右,相对 Gemini 免费层 1500 次/天的额度非常宽松,不会成为瓶颈。

6.6 launchd 配置(每周一早上跑一次)

~/Library/LaunchAgents/com.agentlab.holidayplanner.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.agentlab.holidayplanner</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/agentlab/agent-lab/holiday-planner/plan.sh</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Weekday</key>
    <integer>1</integer>
    <key>Hour</key>
    <integer>9</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/Users/agentlab/agent-lab/holiday-planner/run.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/agentlab/agent-lab/holiday-planner/run.err</string>
</dict>
</plist>

这里用 StartCalendarInterval(每周一 9:00)而不是 StartInterval,两者是 launchd 支持的两种不同调度方式,前者更适合"每周固定时间"这种场景。

launchctl load ~/Library/LaunchAgents/com.agentlab.holidayplanner.plist

7. 防重叠:给所有 launchd 任务加锁

上一轮审查提到的"任务卡住时下一次调度又启动"问题,用一个简单的目录锁解决,加在每个 check.sh / plan.sh 开头:

LOCKDIR=/tmp/agentlab_$(basename "$0" .sh).lock
if ! mkdir "$LOCKDIR" 2>/dev/null; then
  echo "上一次任务还没结束,跳过本次运行"
  exit 0
fi
trap 'rmdir "$LOCKDIR"' EXIT

mkdir 在文件系统层面是原子操作,两个进程不可能同时成功创建同一个目录,用来实现锁比较可靠,macOS 没有 GNU flock 命令时这是最简单的替代方案。


8. 部署清单(v2)

  • agentlab 账户搭好,未登录 Apple ID
  • opencode.json 用的是 permissions 数组写法,全局默认 deny,没有任何裸 ask
  • .env 只被 notify.py 读取,check.sh/plan.sh 里没有任何 source .env
  • Colima/Docker 已安装,docker info 能正常连上
  • Playwright MCP 镜像已用 digest 锁定(不是 latest 标签),容器加了 --cap-drop=ALL --read-only --memory 等加固参数,单独 --help 验证过能正常启动
  • 部署当天核对过 Gemini 免费层当前对应的 Flash 型号 ID,配置里不是拍脑袋写的旧版本号
  • valencia_holidays.json 里的日期已核对过官方来源
  • bridges.py 单独跑通,输出的候选窗口符合预期
  • 三个场景(巴黎/巴萨/假日规划器)都手动跑通过一次、收到过 Telegram 消息
  • 三个 launchd job 都加了目录锁,launchctl load 成功
  • 观察前几天日志,确认没有被目标网站限流、Gemini 调用量在预算内

9. 尚未解决 / 明确交给你自己判断的点

诚实说明这版仍然没做到、需要你自己权衡的地方:

  • --allowed-origins 不是真正的安全边界(官方原话),容器化之后风险已经降低很多,但如果还想要更硬的网络层限制,可以在 Colima/Docker 的网络层再加一层出站规则,这份文档没有覆盖到这一细粒度
  • 假日数据需要每年手动更新,没有做成自动抓取官方页面刷新的机制——这是故意的,因为"自动解析政府公告页面"本身就是一个不小的可靠性风险点,暂时手动维护更稳妥
  • Google Flights / Renfe / Omio 的页面结构变化仍然可能让 Agent 提取失败extract_data_points 在解析失败时会静默跳过而不是报错崩溃,你需要偶尔看一眼 run.errrun.log 确认没有连续多次失败
  • 容器化只覆盖了 Playwright 浏览器这一个环节opencode 本身、node/npm 这些还是直接跑在 agentlab 账户下——这是有意的取舍:浏览器是"渲染不可信第三方内容"这个最危险的动作,优先把它关进容器;opencode 本体没有 shell/edit 权限,风险已经通过权限模型压低,暂不需要跟着一起容器化,等哪天权限模型放开了再重新评估