diff --git a/.gitignore b/.gitignore index 5752d2bf..b5a433f3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,7 @@ openclaw/ # addon crews copied into crews/ at install time — not tracked .pnpm-store/ -# 本地凭据(relay 无状态多租户改造后,client 自管凭据) -crews/main/skills/wx-mp-publisher/accounts.json +# 本地凭据(relay 无状态多租户改造后,client 自管凭据,放源仓外 ~/.openclaw/ 下) skills/wxwork-drive/spaces.json # Claude Code 会话目录 diff --git a/CLAUDE.md b/CLAUDE.md index 3ca427d1..452b730d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,14 @@ metadata: - `always: true` 的真实语义是"跳过 `requires` 二进制/env 检查直接判定 eligible"(见 `config-eval.ts:124`),**不是**"强制注入整个 SKILL.md"。如果 skill 没声明 `requires`,加 `always: true` 等于无意义,应删除。 - 加载阶段 openclaw 只把 `name` + `description` + SKILL.md 绝对路径塞进 system prompt 的 `` 块;agent 用到时才主动 read 全文。所以 frontmatter 写得再多也不会污染 system prompt,但反过来也意味着——除上述识别字段外,多余字段不会带来任何运行时收益。 +## SKILL.md 内容书写规范 + +SKILL.md 是写给**执行时的智能体**看的操作手册,不是开发日志。内容只写指导性指令、正例、反例: + +- ✅ 直接写"应该怎么做""不要怎么做",配 ✅ 正例 / ❌ 反例 +- ❌ 不写排查故事、历史往来、开发经历、思路背景、踩坑全过程 +- 经验教训要提炼成一条可执行的规则,而不是一段叙事 + ## skill 依赖打包规则 产品拆分后(D8)addons/ 结构已销毁,skill 只有两层: diff --git a/README.md b/README.md index bae0c0aa..41c0e642 100755 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ xiaobei 由Wiseflow (原AI首席情报官)作者 bigbrother666sh 开发。 > > - YouTube:https://youtu.be/eK8aWWCNVZQ?si=K-MbWai-j6ydqCdy > - bilibili:https://www.bilibili.com/video/BV1euMR6PEDh + > - 开发与使用心得: https://mp.weixin.qq.com/s/zxvWdCMUd0XquWxujvxkSg - **🔄 数据闭环彻底打通,为自我进化奠基**:`content-calibrator`(打分+预测)与 `published-track`(数据复盘)机制全面完善,内容产出→发布→数据回流→下一轮策略调优的闭环不再有断点,为后续小贝自主迭代选题与打法提供燃料。现在主力四平台(微信视频号、微信公众号、小红书、抖音)全系支持内容自动生产、自动发布、自动取数、自动复盘 - **安装脚本大幅优化**:对比 5.6.0,install.sh / install-atomgit.sh / install.ps1 / install-atomgit.ps1 四脚本修了若干实测踩坑——tarball 下载原子写、Windows `.env`/`daemon.env` 换行与 BOM、技能 wrapper 在 Windows 用 `.cmd` shim 替代软链、`OPENCLAW_HOME` 在 `set -u` 下报 unbound、atomgit 国内线路默认跳过 gum spinner bootstrap 避免连 GitHub 超时、Windows 软链需要开发者模式等,产品稳定性显著提高。 diff --git a/crews/main/HEARTBEAT.md b/crews/main/HEARTBEAT.md index 78fb9d3d..bd7b595f 100644 --- a/crews/main/HEARTBEAT.md +++ b/crews/main/HEARTBEAT.md @@ -177,7 +177,7 @@ 1. 各平台数据更新情况(成功/跳过/失败数量) 2. **取数端 Cookie 失效列表**(如有): - > ⚠️ 以下**取数端**Cookie 已失效,数据未能更新。请白天使用 login-manager 技能重新登录: + > ⚠️ 以下**取数端**Cookie 已失效,数据未能更新。请白天通知小贝重新登录: > - douyin(抖音) > - xhs-browse(小红书浏览端) > - wechat-channel(微信视频号) diff --git a/crews/main/skills/_shared/runtime_root.py b/crews/main/skills/_shared/runtime_root.py new file mode 100644 index 00000000..44177515 --- /dev/null +++ b/crews/main/skills/_shared/runtime_root.py @@ -0,0 +1,46 @@ +"""runtime_root.py — 把 Path(__file__).resolve() 算出的 ROOT 映射回 OpenClaw 运行时工作区。 + +背景:部署到 ``~/.openclaw/workspace-/skills/`` 的 skill 是指向源仓 +``~/wiseflow/crews//skills/`` 的 symlink。脚本的 ``Path(__file__).resolve()`` +会跟随 symlink 跳进源仓,导致 ROOT 指向源仓而非运行时工作区——DB / prediction.md +等运行时数据都在工作区下,源仓里没有,于是全部 ``exists()=False``。 + +本 helper 按 marker 文件是否存在判定,把 ROOT 映射回 ``~/.openclaw/workspace-``。 +""" +from __future__ import annotations + +import os +from pathlib import Path + + +def resolve_runtime_root( + naive_root: Path, + marker: str = "db/published_track.db", + env_var: str = "PUBLISHED_TRACK_ROOT", +) -> Path: + """返回运行时 ROOT(marker 文件所在的工作区)。 + + 判定顺序: + 1. ``env_var`` 显式指定 → 直接用(最高优先级,供部署/调试覆盖)。 + 2. ``naive_root`` 下 marker 存在 → 未 symlink / 源仓内直跑,用 naive_root。 + 3. 从 ``naive_root`` 路径里解析 ``crews/`` → ``~/.openclaw/workspace-``, + 该目录下 marker 存在则用之(symlink 部署的常规情况)。 + 4. 都不命中 → 原样返回 naive_root(让调用方以清晰的 missing-DB 报错)。 + """ + env = os.environ.get(env_var) + if env: + return Path(env).expanduser() + + if (naive_root / marker).exists(): + return naive_root + + parts = naive_root.parts + for i, part in enumerate(parts): + if part == "crews" and i + 1 < len(parts): + crew = parts[i + 1] + cand = Path.home() / ".openclaw" / f"workspace-{crew}" + if (cand / marker).exists(): + return cand + break + + return naive_root diff --git a/crews/main/skills/content-calibrator/scripts/detect-bump-signals.py b/crews/main/skills/content-calibrator/scripts/detect-bump-signals.py index ca63b71f..a38ec3b8 100755 --- a/crews/main/skills/content-calibrator/scripts/detect-bump-signals.py +++ b/crews/main/skills/content-calibrator/scripts/detect-bump-signals.py @@ -23,14 +23,12 @@ import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared")) +from runtime_root import resolve_runtime_root # noqa: E402 + # ── 路径 ───────────────────────────────────────────────────────────────────── -ROOT = Path( - os.environ.get( - "PUBLISHED_TRACK_ROOT", - Path(__file__).resolve().parent.parent.parent.parent, # scripts/ → skill/ → skills/ → crew root - ) -).expanduser() +ROOT = resolve_runtime_root(Path(__file__).resolve().parent.parent.parent.parent) DB = ROOT / "db" / "published_track.db" # ── 常量 ───────────────────────────────────────────────────────────────────── diff --git a/crews/main/skills/content-calibrator/scripts/validate-rubric.py b/crews/main/skills/content-calibrator/scripts/validate-rubric.py index 7ce17240..05c119be 100755 --- a/crews/main/skills/content-calibrator/scripts/validate-rubric.py +++ b/crews/main/skills/content-calibrator/scripts/validate-rubric.py @@ -28,14 +28,12 @@ import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared")) +from runtime_root import resolve_runtime_root # noqa: E402 + # ── 路径 ───────────────────────────────────────────────────────────────────── -ROOT = Path( - os.environ.get( - "PUBLISHED_TRACK_ROOT", - Path(__file__).resolve().parent.parent.parent.parent, - ) -).expanduser() +ROOT = resolve_runtime_root(Path(__file__).resolve().parent.parent.parent.parent) DB = ROOT / "db" / "published_track.db" # ── 常量(与 detect-bump-signals.py 一致)───────────────────────────────────── diff --git a/crews/main/skills/published-track/scripts/query-retro-pending.py b/crews/main/skills/published-track/scripts/query-retro-pending.py index a94500ba..6ac4e321 100755 --- a/crews/main/skills/published-track/scripts/query-retro-pending.py +++ b/crews/main/skills/published-track/scripts/query-retro-pending.py @@ -22,14 +22,15 @@ from datetime import datetime, timedelta from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared")) +from runtime_root import resolve_runtime_root # noqa: E402 + # ── 路径 ───────────────────────────────────────────────────────────────────── -ROOT = Path( - os.environ.get( - "PUBLISHED_TRACK_ROOT", - Path(__file__).resolve().parent.parent.parent.parent, # scripts/ → skill/ → skills/ → crew root - ) -).expanduser() +# skills/ 目录是 symlink(workspace-main/skills/ -> wiseflow/crews/main/skills/), +# Path(__file__).resolve() 会跳进源仓,导致 ROOT 指向源仓而非运行时工作区。 +# resolve_runtime_root 按 DB 是否存在判定,映射回 ~/.openclaw/workspace-。 +ROOT = resolve_runtime_root(Path(__file__).resolve().parent.parent.parent.parent) DB = ROOT / "db" / "published_track.db" # ── 常量 ───────────────────────────────────────────────────────────────────── diff --git a/crews/main/skills/twitter-post/SKILL.md b/crews/main/skills/twitter-post/SKILL.md index ee939914..bd593f2b 100644 --- a/crews/main/skills/twitter-post/SKILL.md +++ b/crews/main/skills/twitter-post/SKILL.md @@ -336,7 +336,7 @@ snapshot eval: document.querySelector('[data-testid="icon-verified"]') !== null | Character limit exceeded (Premium 25K) | Trim or use thread | | Media upload fails | Retry once; check file format and size | | Upload strict mode violation (2 elements) | **用 `[data-testid=fileInput] >> nth=0` 消歧**(见 Workflow: Post with Image) | -| "Something went wrong, but don't fret" after Post | X 服务端瞬时错误。**自动重试**:reload compose 页 → retype → reupload → re-click Post,最多 3 次。3 次仍失败才报告用户。无需等待,立即重试。 | +| "Something went wrong, but don't fret" after Post | X 服务端瞬时错误。**优先精简正文**——这种情况大概率是字数超限(X 的字符计数规则与前端显示不完全一致,尤其 URL/emoji 计数偏差时实际超限但按钮未变灰)。先缩短正文再重试,而不是原样重发。重试流程:精简正文 → reload compose 页 → retype → reupload → re-click Post,最多 3 次。3 次仍失败才报告用户。 | | Rate limit error | **Wait 30 min minimum** (not 15) + check frequency tracker | | Post button greyed out | Content is empty or over limit — check before clicking | | Frequency tracker warns high-risk | Ask user: continue or defer to tomorrow? | diff --git a/crews/main/skills/wx-channel-engagement/SKILL.md b/crews/main/skills/wx-channel-engagement/SKILL.md index 032cf54d..a5c00a8e 100644 --- a/crews/main/skills/wx-channel-engagement/SKILL.md +++ b/crews/main/skills/wx-channel-engagement/SKILL.md @@ -48,6 +48,11 @@ wx-channel-engagement login # camoufox 无头截 QR PNG 落 /tmp/qr-wx wx-channel-engagement login-confirm # 验登录就位 + close session(不导出 cookie/UA/token) ``` +`login` 返回 `already_logged_in: true` 时无需扫码,直接执行后续命令。扫码失败 / 用错账户后,**不要直接重跑 `login`**——旧 cookie 污染 profile,重跑拿到的 QR 扫了也不生效。先带 `--reset` 清 profile 再重登: +```bash +wx-channel-engagement login --reset # 删 profile 目录 + 重新 open,从干净状态拿 QR +``` + 退出码: - `0` 成功 - `1` 通用错误(参数错 / row 找不到 / 标题未匹配) diff --git a/crews/main/skills/wx-channel-engagement/scripts/fetch_engagement.py b/crews/main/skills/wx-channel-engagement/scripts/fetch_engagement.py index ab2cf79b..0b7fe256 100644 --- a/crews/main/skills/wx-channel-engagement/scripts/fetch_engagement.py +++ b/crews/main/skills/wx-channel-engagement/scripts/fetch_engagement.py @@ -29,6 +29,7 @@ import re import sqlite3 import subprocess +import shutil import sys import time from datetime import datetime, timedelta, timezone @@ -66,6 +67,14 @@ FETCH_TIMEOUT_S = 30 SESSION_CLEANUP_ON_EXIT = True # 仅 close camoufox session,不动中央存储 +# 视频号助手后台真路径(登录成功后 redirect 落点);裸 /platform/ 是入口 URL,不算就位 +BACKEND_PATHS = ( + "/platform/home", "/platform/post", "/platform/data", + "/platform/interaction", "/platform/live", +) +# 持久化 profile 目录(登录态落盘位置) +PROFILE_DIR = Path.home() / ".camoufox-cli" / "profiles" / SESSION_NAME + # 登录流程常量(本技能自管 wechat-channel session 的扫码登录) QR_FILE = "/tmp/qr-wx-channel.png" LOGIN_CONFIRM_POLL_MAX_S = 150 @@ -219,18 +228,47 @@ def cmd_login(args) -> None: agent 拿 QR_FILE 发用户扫码,用户回复「已扫码」后调 login-confirm。 - 关键:camoufox-cli ``open`` 是「打开 + 立刻返回」,微信登录页的二维码是 JS - 动态注入的 ````(src 是 base64 或 JS 拼的),页面 onload 后才填。 - 若 open 后立刻 screenshot,QR 还没注入完,截图空白。故 open 后先 wait - 等二维码 ```` 出现(或兜底等固定时间),再 screenshot。 + --reset:先 close session + 删 profile 目录再 open,用于上一次扫码污染了 + cookie(如用错账户扫码)后强制从干净状态重来。不加 --reset 时不碰已有 + 登录态,避免把还有效的 session 清掉。 + + open 后先等 redirect 落点:落到后台路径 = 已登录,直接报 already_logged_in + 不出 QR;落到登录页 = 正常出 QR。 """ + # --reset:清掉污染的 profile(close daemon + 删磁盘 profile 目录) + if getattr(args, "reset", False): + camoufox_close(SESSION_NAME) + if PROFILE_DIR.exists(): + shutil.rmtree(PROFILE_DIR, ignore_errors=True) + # camoufox-cli open 视频号助手后台首页 camoufox_open(SESSION_NAME, CREATOR_CENTER_URL) - # 等二维码 出现:微信登录页 QR 是 JS 动态注入的 , - # camoufox-cli open 是「打开 + 立刻返回」,QR 还没注入完就截图会空白。 - # 不用 selector 锁 QR 元素(微信登录页结构不固定),直接轮询 eval - # 验证 元素已出现 + 有 src,再截图。最多等 10s。 + # 等 redirect 落点(最多 8s),区分「已登录」vs「需扫码」 + deadline = time.time() + 8 + current_url = "" + while time.time() < deadline: + current_url = camoufox_get_url(SESSION_NAME) + if current_url and ( + any(p in current_url for p in BACKEND_PATHS) + or "login" in current_url + or "scanloginqrcode" in current_url + ): + break + time.sleep(0.5) + + # 已落到后台路径 = 登录态有效,不出 QR + if current_url and any(p in current_url for p in BACKEND_PATHS): + camoufox_close(SESSION_NAME) + sys.stdout.write(json.dumps({ + "ok": True, + "already_logged_in": True, + "message": "登录态有效,无需扫码,可直接执行后续命令", + }, ensure_ascii=False, indent=2)) + sys.stdout.write("\n") + return + + # 落到登录页 → 等二维码 出现再截图 deadline = time.time() + 10 qr_ready = False while time.time() < deadline: @@ -352,9 +390,16 @@ def _ensure_login() -> None: # 显式跳登录页 → 真失效,不等 if "login" in current_url or "scanloginqrcode" in current_url: break - # 跳到后台真路径(排除 login.html 后的 /platform/ 段)= 登录就位 - if current_url and "/platform/" in current_url and "login" not in current_url: + # 跳到后台具体页面(/platform/home 等)= 登录就位 + # 不再用 "/platform/" in url 判断,因为初始 URL 本身就含 /platform/ + if any(p in current_url for p in BACKEND_PATHS): return + # 登录成功后页面可能停在根路径 /platform/(wujie 已加载但 URL 未跳转)。 + # 只要 wujie-app 已渲染且 URL 不含 login,即视为登录就位。 + if "login" not in current_url: + probe = camoufox_eval(session, '(() => { try { return !!document.querySelector("wujie-app"); } catch(e) { return false; } })()') + if probe in (True, "true", "True"): + return time.sleep(0.5) sys.stderr.write( @@ -492,11 +537,14 @@ def _cleanup_session(session: str) -> None: def fetch_post_list(session: str) -> list[dict]: """打开作品管理页,eval JS 解析作品列表""" - # 1. 打开作品管理页 + # 1. 先访问后台首页预热 wujie 微前端框架(直接跳作品管理页会导致 wujie-app 未初始化) + camoufox_open(session, CREATOR_CENTER_URL) + time.sleep(3) + # 2. 打开作品管理页 camoufox_open(session, POST_LIST_URL) # 等页面加载(wujie 初始化 + shadow DOM 渲染) time.sleep(5) - # 2. eval JS 解析 innerText + # 3. eval JS 解析 innerText raw = camoufox_eval(session, _LIST_PARSE_JS) if not raw: return [] @@ -794,7 +842,9 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("probe", help="打开视频号助手后台 dump DOM/截图/innerText").set_defaults(func=cmd_probe) sub.add_parser("list", help="列出后台所有视频 + 行内 metrics").set_defaults(func=cmd_list) - sub.add_parser("login", help="camoufox 无头截 QR PNG").set_defaults(func=cmd_login) + p_login = sub.add_parser("login", help="camoufox 无头截 QR PNG") + p_login.add_argument("--reset", action="store_true", help="先删 profile 目录再 open(上一次扫码污染 cookie 后强制重来)") + p_login.set_defaults(func=cmd_login) sub.add_parser("login-confirm", help="确认登录 + close session").set_defaults(func=cmd_login_confirm) p_fetch = sub.add_parser("fetch", help="抓单篇 engagement(按 title 在作品管理页匹配)") diff --git a/crews/main/skills/wx-mp-publisher/REFERENCE.md b/crews/main/skills/wx-mp-publisher/REFERENCE.md index 50f68249..a7a9b054 100644 --- a/crews/main/skills/wx-mp-publisher/REFERENCE.md +++ b/crews/main/skills/wx-mp-publisher/REFERENCE.md @@ -22,7 +22,7 @@ ## 写入 accounts.json -Agent 收到 AppID + AppSecret 后,写入 `crews/main/skills/wx-mp-publisher/accounts.json`: +Agent 收到 AppID + AppSecret 后,写入 `~/.openclaw/wx-mp-publisher/accounts.json`(源仓之外的实例态目录,密钥不落源仓): ```json { @@ -39,5 +39,5 @@ Agent 收到 AppID + AppSecret 后,写入 `crews/main/skills/wx-mp-publisher/a ## 安全 -- `accounts.json` 已在 `.gitignore` 中,不会进 git +- `accounts.json` 在源仓之外(`~/.openclaw/wx-mp-publisher/`),不会进 git / tarball / 备份 - AppSecret 等同于密码,不要贴到聊天群 / issue / 日志里 diff --git a/crews/main/skills/wx-mp-publisher/SKILL.md b/crews/main/skills/wx-mp-publisher/SKILL.md index cc6c4e57..bac8fe98 100644 --- a/crews/main/skills/wx-mp-publisher/SKILL.md +++ b/crews/main/skills/wx-mp-publisher/SKILL.md @@ -19,11 +19,11 @@ metadata: ## 凭据与存储位置 -- **公众号凭据**存放在本 skill 目录下的 `accounts.json`(已 gitignore,不进仓): +- **公众号凭据**存放在源仓之外的实例态目录(不跟源仓绑,软链 / tarball / 备份都不带密钥): ``` - crews/main/skills/wx-mp-publisher/accounts.json + ~/.openclaw/wx-mp-publisher/accounts.json ``` - 结构见 `accounts.example.json`。支持多账号,每条含 `alias` / `appId` / `appSecret`;多账号时 `default` 指向默认 alias。 + 结构见本 skill 同目录 `accounts.example.json`。支持多账号,每条含 `alias` / `appId` / `appSecret`;多账号时 `default` 指向默认 alias。 - **relay 身份** `OFB_KEY` + `RELAY_BASE_URL` 来自 `daemon.env`(由 entrypoint 注入环境变量)。 ### 凭据缺失时 Agent 行为 @@ -55,6 +55,7 @@ wx-mp-publisher [theme] [--account ALIAS] 脚本自动: - 从 `accounts.json` 取目标账号凭据 - 从 Markdown 中提取本地图片路径,作为 `images` 字段一并上传(http/https 图片由 relay 自行抓取,不在此列) +- 发布前校验:图片引用必须纯文件名(不得带目录前缀 / 绝对路径)、frontmatter `author` ≤ 24 字节,违规直接报错退出 - POST multipart 到 `${RELAY_BASE_URL}/api/v1/wx-mp/publish`,带 `X-OFB-Key` - 校验响应包络 `{ success, data, error }` @@ -98,7 +99,7 @@ wx-mp-publisher [theme] [--account ALIAS] --- title: 文章标题 cover: ./cover.jpg # 可选,缺省自动取正文第一张图 -author: 作者名称 # 可选 +author: 作者名称 # 可选,≤ 8 个汉字 / 24 字节(超长报 45110) source_url: https://... # 可选,原文链接 need_open_comment: true # 可选,是否开启评论(默认 false) only_fans_can_comment: false # 可选,是否仅粉丝可评论(默认 false) @@ -139,6 +140,8 @@ image_list: | `OFB_KEY 未配置` | 让 IT engineer 在 `daemon.env` 配置 `OFB_KEY` 后重启实例 | | `MISSING_APP_ID` / `MISSING_APP_SECRET`(relay 400) | accounts.json 中该账号凭据为空,补全 | | `MISSING_MARKDOWN`(relay 400) | 检查 markdown 文件内容非空 | +| `45110: author size out of limit` | frontmatter `author` 超 8 汉字 / 24 字节,缩短后再发 | +| 图片 `ENOENT`(relay) | markdown 图片引用带目录前缀 / 绝对路径,改为纯文件名(basename) | | relay 502 | relay 调微信失败,检查 AppSecret / IP 白名单(见 `REFERENCE.md`) | --- diff --git a/crews/main/skills/wx-mp-publisher/scripts/publish_wx_mp.py b/crews/main/skills/wx-mp-publisher/scripts/publish_wx_mp.py index 9eec3634..b50133e6 100644 --- a/crews/main/skills/wx-mp-publisher/scripts/publish_wx_mp.py +++ b/crews/main/skills/wx-mp-publisher/scripts/publish_wx_mp.py @@ -24,7 +24,8 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent -ACCOUNTS_FILE = SCRIPT_DIR.parent / "accounts.json" +# 凭据放在源仓之外(实例态目录),避免软链模式下密钥落进源仓工作树 +ACCOUNTS_FILE = Path.home() / ".openclaw" / "wx-mp-publisher" / "accounts.json" SKILL_MD = SCRIPT_DIR.parent / "SKILL.md" CREW_WORKSPACE = SCRIPT_DIR.parent.parent.parent # crews/main DEFAULT_RELAY_BASE_URL = "https://relay.openclaw-for-business.com" @@ -48,8 +49,8 @@ def load_account(alias_arg: str | None) -> tuple[str, str, str]: if not ACCOUNTS_FILE.exists(): die( "未找到公众号凭据文件 accounts.json。\n" - " 位置:crews/main/skills/wx-mp-publisher/accounts.json\n" - " → 请让 Agent 帮你创建并填入公众号 AppID/AppSecret(获取方式见同目录 REFERENCE.md)" + f" 位置:{ACCOUNTS_FILE}\n" + " → 请让 Agent 帮你创建并填入公众号 AppID/AppSecret(获取方式见 wx-mp-publisher SKILL 同目录 REFERENCE.md)" ) try: cfg = json.loads(ACCOUNTS_FILE.read_text(encoding="utf-8")) @@ -227,23 +228,89 @@ def add(src: str) -> None: return out -def rewrite_image_refs(md_text: str, local_images: list[Path]) -> str: - """把本地图片引用(绝对路径或 `./x` 相对路径)重写为 basename,与 images multipart 文件名对齐。 +def rewrite_image_refs(md_text: str, local_images: list[Path], md_dir: Path) -> str: + """把本地图片引用重写为 basename,与 images multipart 文件名对齐。 - relay 端 @wenyan-md/core 渲染时按文件名匹配上传的 images,绝对路径会让 relay 去 - 自己磁盘 stat 报 ENOENT。同时处理 frontmatter 的 `cover` / `image_list` 字段。 + relay 端 @wenyan-md/core 渲染时按文件名匹配上传的 images,带目录前缀或绝对路径 + 会让 relay 去自己磁盘 stat 报 ENOENT。覆盖 markdown / frontmatter 里可能出现的 + 所有形式:绝对路径、`./name`、`name`、以及相对 md_dir 的子目录路径(如 `images/x.jpg`)。 """ if not local_images: return md_text for img in local_images: name = img.name - # 把可能出现在 markdown / frontmatter 里的形式都替换为 basename - for original in (str(img), f"./{name}", name): + candidates = {str(img), f"./{name}", name} + try: + candidates.add(str(img.relative_to(md_dir))) + except ValueError: + pass + for original in candidates: if original != name: md_text = md_text.replace(original, name) return md_text +# ── 发布前校验 ───────────────────────────────────────────────────────────── + +# 微信草稿 author 字段上限:8 个汉字 / 24 字节(errcode 45110: author size out of limit) +WX_AUTHOR_MAX_BYTES = 24 + + +def _parse_frontmatter_author(md_text: str) -> str | None: + """从 YAML frontmatter 提取 author 字段值(去首尾引号);无则返回 None。""" + if not md_text.startswith("---"): + return None + end = md_text.find("\n---", 3) + if end < 0: + return None + for line in md_text[3:end].splitlines(): + m = re.match(r"^\s*author:\s*(.+?)\s*$", line) + if m: + val = m.group(1).strip() + if (val.startswith('"') and val.endswith('"')) or ( + val.startswith("'") and val.endswith("'") + ): + val = val[1:-1] + return val + return None + + +def validate_for_publish(md_text: str) -> None: + """发布前校验,命中违规即 die() 拦截,避免 relay/微信侧报错。 + + 1. 本地图片引用必须用纯文件名(basename),不得含目录前缀或绝对路径。 + 2. frontmatter author 字段 ≤ 24 字节(8 个汉字)。 + """ + # 1. 图片引用:正文 ![]() + frontmatter cover/image_list + local_refs = [ + m.group(1).split()[0] for m in re.finditer(r"!\[[^\]]*\]\(([^)]+)\)", md_text) + ] + local_refs += _frontmatter_local_refs(md_text) + bad_imgs = [ + ref + for ref in local_refs + if not ref.startswith(("http://", "https://", "data:")) + and ("/" in ref or "\\" in ref or Path(ref).is_absolute()) + ] + if bad_imgs: + die( + "图片引用必须用纯文件名(与上传时的 originalname 一致),不得带目录前缀或绝对路径。\n" + " relay 把图片按 originalname 存到 per-request 临时目录,带前缀的路径在 relay 侧\n" + " stat 不到会 ENOENT。请改为 basename:\n " + + "\n ".join(bad_imgs) + ) + + # 2. author 长度 + author = _parse_frontmatter_author(md_text) + if author is not None: + n = len(author.encode("utf-8")) + if n > WX_AUTHOR_MAX_BYTES: + die( + f"frontmatter author 超过微信草稿上限({WX_AUTHOR_MAX_BYTES} 字节 / 8 个汉字)," + f"当前 {n} 字节:{author!r}。请缩短 author 字段。" + ) + + # ── 主流程 ─────────────────────────────────────────────────────────────────── def main() -> None: @@ -265,7 +332,8 @@ def main() -> None: md_text = md_path.read_text(encoding="utf-8") images = extract_local_images(md_text, md_path.parent) - md_text = rewrite_image_refs(md_text, images) + md_text = rewrite_image_refs(md_text, images, md_path.parent) + validate_for_publish(md_text) fields = { "markdown": md_text, diff --git a/docker/Dockerfile b/docker/Dockerfile index d4ca65b9..994cac2b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,51 +1,137 @@ # syntax=docker/dockerfile:1 -# xiaobei Docker image +# xiaobei Docker image — 开箱即用版(v5.6.3+) # -# The build intentionally reuses the same lower-level installation scripts as -# scripts/install.sh. It differs only where a container has no service manager: -# CI supplies the pinned source tree, Docker builds immutable application files, -# and the entrypoint initializes the writable runtime state on first launch. +# 三阶段构建: +# 1. xiaobei-build 基础系统 + Node/pnpm 工具链 + 容器内 Xvfb/VNC/noVNC 软件 +# + 跑 docker-bootstrap.sh 完成"install.sh 装好后"的所有 immutable 层 +# (apply patches / 装 skills+deps / 编译 openclaw / camoufox-cli+Firefox / +# 预装 openclaw-weixin 插件 / 生成 runtime seed) +# 2. xiaobei-runtime 从 build 拷贝产物,只放可执行层 + entrypoint,体积更瘦 +# +# 镜像语义:等价于裸机跑完 scripts/install.sh 后的状态——用户只需 docker compose up -d +# 即可启动,唯一要填的 AWK_API_KEY 走环境变量注入,不进镜像层。 +# +# openclaw/ 源码树(含 .git 元数据)由 build-image.sh 或 CI 流水线在 docker build 之前 +# 注入到仓根,apply-addons.sh 的 git apply --3way 需 .git object store 读补丁头 pre-image blob。 +# ════════════════════════════════════════════════════════════════════ +# Stage 1: build(装齐 immutable 应用层 + 显示栈软件) +# ════════════════════════════════════════════════════════════════════ FROM node:24-bookworm AS xiaobei-build +# USE_MIRROR=1 走国内镜像源(本地构建推荐),USE_MIRROR=0 走原始源 +# 阿里云 ACR 海外构建机推荐 USE_MIRROR=0 + ACR 的"海外源智能加速"——跨境拉 GitHub/npm 更稳 +ARG USE_MIRROR=${USE_MIRROR:-0} ENV DEBIAN_FRONTEND=noninteractive \ HOME=/root \ OPENCLAW_HOME=/root/.openclaw \ + OPENCLAW_STATE_DIR=/root/.openclaw \ NPM_CONFIG_REGISTRY=https://registry.npmmirror.com +# 国内镜像加速(USE_MIRROR=0 关闭,海外构建用) +RUN if [ "$USE_MIRROR" = "1" ]; then \ + sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null \ + || sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true; \ + fi + +# 基础工具 + 显示栈(camoufox 有头模式跑 Xvfb,用户经 noVNC 操作浏览器过验证) +# + 浏览器运行时依赖(gtk/dbus/nss/atspi/cups,camoufox Firefox 需要) +# + 微信容器配合:docker CLI 让 xiaobei 容器能 docker exec 进微信容器读消息库 +# 合并成一次 apt-get 减层数 RUN apt-get update && apt-get install -y --no-install-recommends \ - bash ca-certificates curl git openssl \ - python3 python3-pip python3-venv \ - libasound2 libdbus-1-3 libgtk-3-0 libxt6 \ - fonts-liberation fonts-noto-cjk \ - xvfb fluxbox x11vnc novnc websockify \ + bash ca-certificates curl git openssl openssh-client \ + python3 python3-pip python3-venv \ + # 显示栈 + xvfb fluxbox x11vnc novnc websockify \ + # 浏览器运行时(camoufox Firefox) + libasound2 libdbus-1-3 libgtk-3-0 libxt6 libxkbcommon-x11-0 \ + libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 \ + fonts-liberation fonts-noto-cjk fonts-wqy-microhei \ + # 微信容器配合(wx-mp-hunter posts-list 用 docker exec 读消息库) + docker.io \ + # 杂项 + sudo procps net-tools \ && rm -rf /var/lib/apt/lists/* +# pnpm via corepack(node:24 带 corepack) RUN corepack enable && corepack prepare pnpm@10.30.2 --activate WORKDIR /opt/xiaobei -# openclaw/ is injected at the commit pinned by openclaw.version before either -# local builds or the GitHub Actions image build. Its .git metadata is retained -# because apply-addons.sh applies the maintained patch series with git --3way. +# 仓根整体 COPY(.dockerignore 排除 node_modules/dist/.git,但保留 openclaw/.git) +# docker-bootstrap.sh 在仓根 docker/ 下,apply-addons.sh / setup-crew.sh 在 scripts/ 下, +# camoufox-cli fork 在 patches/camoufox-cli/ 下,全部随仓根打进镜像。 COPY . /opt/xiaobei +# 校验源码树 + 跑 bootstrap 完成"装好后"状态 +# bootstrap 复用裸机安装的 apply-addons.sh / setup-crew.sh / camoufox-cli/build.sh, +# 但不跑 daemon install / 不交互收密钥 / 不拉远程仓库(源码已 COPY 进来) RUN test -d /opt/xiaobei/openclaw \ && test -d /opt/xiaobei/openclaw/.git \ - && test -x /opt/xiaobei/scripts/docker-bootstrap.sh \ - && NODE_OPTIONS="--max-old-space-size=4096" /opt/xiaobei/scripts/docker-bootstrap.sh \ + && test -x /opt/xiaobei/docker/docker-bootstrap.sh \ + && NODE_OPTIONS="--max-old-space-size=4096" /opt/xiaobei/docker/docker-bootstrap.sh \ && install -d /opt/xiaobei/runtime-seed/openclaw \ && cp -a /root/.openclaw/. /opt/xiaobei/runtime-seed/openclaw/ -FROM xiaobei-build AS xiaobei-runtime +# ════════════════════════════════════════════════════════════════════ +# Stage 2: runtime(瘦运行镜像,从 build 拷 immutable 层) +# ════════════════════════════════════════════════════════════════════ +FROM node:24-bookworm + +ARG USE_MIRROR=${USE_MIRROR:-0} +ENV DEBIAN_FRONTEND=noninteractive \ + HOME=/root \ + OPENCLAW_HOME=/root/.openclaw \ + OPENCLAW_STATE_DIR=/root/.openclaw \ + DISPLAY=:99 \ + LANG=C.UTF-8 + +RUN if [ "$USE_MIRROR" = "1" ]; then \ + sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null \ + || sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true; \ + fi +# runtime 只装显示栈 + 浏览器运行时 + docker CLI(不带构建工具链,瘦镜像) +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates curl openssl \ + python3 python3-pip \ + xvfb fluxbox x11vnc novnc websockify \ + libasound2 libdbus-1-3 libgtk-3-0 libxt6 libxkbcommon-x11-0 \ + libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 \ + fonts-liberation fonts-noto-cjk fonts-wqy-microhei \ + docker.io \ + procps net-tools \ + && rm -rf /var/lib/apt/lists/* + +# 从 build 阶段拷贝 immutable 应用层 +# /opt/xiaobei 仓根(引擎源码+dist / patches / scripts / crews / skills / awada / camoufox-cli fork) +# /root/.openclaw 预装态(openclaw.json template / 全局 skills / crew workspaces / weixin 插件) +# /root/.camoufox-cli Firefox 二进制 + camoufox-cli 全局包 +# /usr/local Node + pnpm(node:24 带,corepack pnpm 在 build 装的) +COPY --from=xiaobei-build /opt/xiaobei /opt/xiaobei +COPY --from=xiaobei-build /root/.openclaw /root/.openclaw +COPY --from=xiaobei-build /root/.camoufox-cli /root/.camoufox-cli +COPY --from=xiaobei-build /root/.cargo /root/.cargo +# camoufox-cli 全局 npm 包(build 阶段 npm install -g 装的) +COPY --from=xiaobei-build /usr/local/lib/node_modules /usr/local/lib/node_modules +COPY --from=xiaobei-build /usr/local/bin /usr/local/bin.d-build +# 把 build 阶段的全局 bin(camoufox-cli 等)合并到 runtime 的 /usr/local/bin +RUN cp -a /usr/local/bin.d-build/* /usr/local/bin/ 2>/dev/null || true && rm -rf /usr/local/bin.d-build + +# pnpm 在 runtime 也需要(openclaw gateway 用 pnpm openclaw gateway 启动) +RUN corepack enable && corepack prepare pnpm@10.30.2 --activate + +# 入口脚本 COPY docker-entrypoint.sh /usr/local/bin/xiaobei-entrypoint RUN chmod 0755 /usr/local/bin/xiaobei-entrypoint -# Both directories contain credentials and platform login state. The -# entrypoint initializes empty named volumes and empty bind mounts from -# /opt/xiaobei/runtime-seed instead of relying on Docker's volume-copy detail. +# 两个数据目录外挂——首启从镜像内 runtime-seed 初始化空卷,已有卷绝不覆盖 +# 两个目录含 API key 和平台登录态,权限 0700 VOLUME ["/root/.openclaw", "/root/.camoufox-cli"] +# gateway API + noVNC web EXPOSE 18789 6080 + ENTRYPOINT ["/usr/local/bin/xiaobei-entrypoint"] diff --git a/docker/README.md b/docker/README.md index 1805cdf5..2b4c6127 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,3 +1,140 @@ -Docker方案目前尚在开发中,不要使用。 +# xiaobei Docker 部署(v5.6.3+) -deploy-via-docker cannot work this time, do not use... under developing \ No newline at end of file +> **开箱即用**:镜像内已装好 openclaw 引擎 + 全部 skills/crews + camoufox-cli + Firefox + +> openclaw-weixin 插件 + Xvfb/fluxbox/x11vnc/websockify/noVNC 显示栈。 +> 用户拉镜像后只需填 `AWK_API_KEY`,`docker compose up -d` 即可启动。 + +## 快速开始 + +```bash +# 1. 拷贝环境模板 +cp docker/.env.example docker/.env + +# 2. 编辑 .env,填入你的 AWK_API_KEY(阿里云百炼 token) +# AWK_API_KEY=sk-xxxxxxxxxxxxxxxx + +# 3. 启动 +docker compose up -d + +# 4. 访问 +# gateway API: http://localhost:18789 +# noVNC web: http://localhost:6080/vnc.html +``` + +首启会打印微信扫码绑定二维码,用手机微信扫码确认登录即可。绑定态持久化在 +`xiaobei-openclaw` 卷,后续重启自动跳过扫码。 + +## 镜像内容(= 跑完 install.sh 后的状态) + +| 层 | 内容 | +|----|------| +| openclaw 引擎 | 按 `openclaw.version` pin 的 commit + 全部 patches + 编译后的 dist | +| awada 插件 | 本地 TS 插件 + ws/zod 运行时依赖 | +| skills | 公共 skills(`skills/`)+ crew 专属 skills(`crews/*/skills/`)+ python deps | +| crews workspace | main / content-producer / it-engineer / sales-cs 四套 crew 预初始化 | +| camoufox-cli | wiseflow fork(反指纹浏览器 CLI)+ Firefox 二进制(~557MB) | +| openclaw-weixin | 预装微信 channel 插件(首启扫码绑定) | +| 显示栈 | Xvfb(虚拟显示)+ fluxbox(窗口管理)+ x11vnc + websockify + noVNC | + +**不装**:systemd/launchd daemon(容器无服务管理器)、交互式密钥收集(走环境变量)。 + +## 数据外挂(两个 named volume) + +| Volume | 容器路径 | 内容 | +|--------|---------|------| +| `xiaobei-openclaw` | `/root/.openclaw` | openclaw.json、`.env`、workspace、会话、微信登录态 | +| `xiaobei-camoufox` | `/root/.camoufox-cli` | 浏览器 profile、Cookie、指纹缓存 | + +**首启行为**:空卷从镜像内 `/opt/xiaobei/runtime-seed/openclaw` 初始化。 +**升级行为**:已有卷**绝不覆盖**,登录态和用户配置保留。 +**备份**:两个卷含 API key 和平台登录态,备份时应限制文件权限。 +**清空**:`docker compose down -v` 删除卷,等同于清空该实例的配置与登录状态。 + +## noVNC — 浏览器操作容器内桌面 + +camoufox 有头模式跑在容器内 Xvfb 虚拟显示里。用户浏览器打开 +`http://localhost:6080/vnc.html` 即可看到该显示里的 fluxbox 桌面,里面能看到 camoufox +浏览器窗口——用于**小红书/抖音等平台登录过验证**的场景。 + +**mimicwx 容器**也有自己的 noVNC web(宿主端口 `6081`):浏览器开 +`http://localhost:6081/vnc.html` 操作微信客户端扫码登录。 + +**端口默认只绑 Docker host 的 `127.0.0.1`**。远程访问应走 SSH 隧道或显式配置代理, +不要直接把 6080 / 6081 暴露到公网。 + +## 微信容器配合(wx-mp-hunter posts-list) + +`wx-mp-hunter` skill 的 `posts-list` 子命令依赖一个运行中的微信客户端容器 +(MimicWX-Linux),通过 `docker exec` 进微信容器读 SQLCipher 加密的消息库。 + +`docker-compose.yml` 已配好两个容器在同一 `xiaobei-net` 网络里: + +- `xiaobei` 容器挂载宿主 `/var/run/docker.sock`(只读),用于 `docker exec` 进微信容器 +- `mimicwx` 容器跑微信客户端,登录态持久化在 `wechat-data` 卷 +- 环境变量 `WX_BIZ_CONTAINER` / `WX_BIZ_USER_DIR` / `WX_BIZ_KEYS_FILE` 指向微信容器 + +**前提**:需要先有 `mimicwx` 镜像。如果只用到 `wx-mp-hunter` 的 `fetch` / `homepage` +子命令(不依赖微信容器),可以注释掉 `docker-compose.yml` 里的 `mimicwx` service。 + +## 构建与分发(GitHub + 阈里云 ACR 直绑) + +镜像由阿里云 ACR 企业版**直绑 GitHub 仓库**自动构建并托管——不走外部流水线, +push tag 即触发 ACR 构建。 + +### 触发方式 + +1. **手动触发**:在阿里云 ACR 控制台 → `xiaobei` 仓库 → 构建规则 → 点"立即构建" +2. **push tag 自动触发**:`git push origin v5.6.3` 会自动触发 ACR 构建规则 + +### ACR 构建规则配置(一次性) + +在 ACR 控制台为 `xiaobei` 仓库添加一条构建规则: + +| 字段 | 配置 | +|------|------| +| 代码源 | GitHub(先在 ACR 绑定 GitHub 个人版账号) | +| 分支/Tag | 正则 `v(?\w*)` 匹配 `v*` tag | +| 构建上下文目录 | `/` | +| Dockerfile 文件名 | `docker/Dockerfile` | +| 镜像版本 | `${imageTag}` 和 `latest`(加两条镜像版本行,或建两条规则) | +| 海外机器构建 | 勾选(镜像内要 clone GitHub openclaw、npm 拉海外包,海外构建 + 海外源智能加速更稳) | +| 构建参数 | `USE_MIRROR=0`(用原始源 + ACR 海外源智能加速,国内镜像源在海外构建机反而绕远路) | + +### 产物(阿里云 ACR) + +``` +registry.cn-hangzhou.aliyuncs.com//xiaobei:v5.6.3 +registry.cn-hangzhou.aliyuncs.com//xiaobei:latest +``` + +### 发放模式 + +构建完成后,向符合条件的用户直接发放阿里云的 image 链接: + +```bash +# 用户侧:拉镜像 + 启动 +docker login registry.cn-hangzhou.aliyuncs.com # 填发放的账号 +docker pull registry.cn-hangzhou.aliyuncs.com//xiaobei:v5.6.3 +cp docker/.env.example docker/.env # 填 AWK_API_KEY +AWK_API_KEY= docker compose up -d +``` + +## 本地验证 + +```bash +# 本地构建镜像(会先按 openclaw.version 检出 pinned openclaw 源码) +./scripts/build-image.sh + +# 用本地构建的镜像启动 +AWK_API_KEY= IMAGE=xiaobei:local docker compose up -d +``` + +## 安全边界 + +- `AWK_API_KEY` 仅从运行环境读取,**不写入镜像层**也不写 `openclaw.json` 的明文。 +- 首启会为 gateway 生成随机 `OPENCLAW_GATEWAY_TOKEN`,以 `0600` 写入持久化 `.env`。 +- Gateway 和 noVNC 在 Compose 中只映射到 `127.0.0.1`。**不要直接把 6080 暴露到公网**。 +- 当前 Camoufox sandbox 需要 `SYS_ADMIN` capability;只运行受信任的官方镜像,并保持 + Docker daemon 权限最小化。 +- `/var/run/docker.sock` 只读挂载——xiaobei 容器能 `docker exec` 进微信容器读消息库, + 但无法修改宿主 docker 状态。若需更强隔离,可改用 docker-socket-proxy。 diff --git a/docker/docker-bootstrap.sh b/docker/docker-bootstrap.sh index 7a2deb6f..140ebbbf 100755 --- a/docker/docker-bootstrap.sh +++ b/docker/docker-bootstrap.sh @@ -1,43 +1,110 @@ #!/usr/bin/env bash -# Build the immutable xiaobei application layer. +# docker-bootstrap.sh — 构建镜像的 immutable 应用层(等价于"跑完 install.sh 装好后"状态) # -# This is deliberately not a wrapper around install.sh: image builds must not -# fetch/reset repositories, ask for API keys, or install a host daemon. It does -# reuse the same lower-level provisioning scripts used by install.sh so patches, -# crews, skills, wrappers, and dependency installation stay in one code path. -set -euo pipefail - -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OPENCLAW_HOME="${OPENCLAW_HOME:-$HOME/.openclaw}" +# 与裸机 install.sh 区别: +# - 不拉远程仓库(openclaw 源码已由 Dockerfile COPY 进来) +# - 不跑 daemon install / 不交互收密钥(容器无 systemd,密钥走运行时环境变量) +# - 不重启 gateway(构建阶段没运行实例) +# +# 复用裸机安装的同一套底层脚本,保证 patch / skills / wrappers / 依赖 / 编译语义与裸机同源: +# - patches/camoufox-cli/build.sh 反指纹浏览器 CLI fork +# - scripts/apply-addons.sh patch 应用 + skills + crew workspace + 编译 +# - scripts/setup-crew.sh crew workspace 初始化(由 apply-addons 内部触发) +# - weixin 插件在线 plugins install (逻辑内联在此,不调独立的 install-weixin-channel.sh) +# +# 诊断:每步打 [bootstrap] STEP N done 标记,ACR 构建失败时精确定位哪步炸。 +# 容错:非致命步骤(camoufox 二进制、weixin 插件)失败不中断构建——首启可手动补。 +set -uo pipefail # 不用 -e:用显式 || exit 1 控制致命步,非致命步容错 + +PROJECT_ROOT="${XIAOBEI_ROOT:-/opt/xiaobei}" +OPENCLAW_HOME="${OPENCLAW_HOME:-/root/.openclaw}" OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$OPENCLAW_HOME/openclaw.json}" -[ -d "$PROJECT_ROOT/openclaw/.git" ] || { - echo "Docker build requires the pinned openclaw checkout (including .git)." >&2 - exit 1 -} +# USE_MIRROR=0(阿里云 ACR 海外构建机)走原始 npmjs + ACR 海外源智能加速 +# USE_MIRROR=1(国内本地构建)走 npmmirror +if [ "${USE_MIRROR:-1}" = "0" ]; then + export NPM_REGISTRY="https://registry.npmjs.org" + export npm_config_registry="https://registry.npmjs.org" +else + export NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}" + export npm_config_registry="${NPM_REGISTRY}" +fi +echo "[bootstrap] NPM_REGISTRY=${NPM_REGISTRY} USE_MIRROR=${USE_MIRROR:-1}" + +# ─── 校验源码树(致命)──────────────────────────────────────────── +[ -d "$PROJECT_ROOT/openclaw/.git" ] || { echo "[bootstrap] ❌ openclaw 源码树缺 .git" >&2; exit 1; } +[ -x "$PROJECT_ROOT/scripts/apply-addons.sh" ] || { echo "[bootstrap] ❌ apply-addons.sh 缺失" >&2; exit 1; } mkdir -p "$OPENCLAW_HOME" + +# ─── 放置 config template ──────────────────────────────────────── cp "$PROJECT_ROOT/config-templates/openclaw.json" "$OPENCLAW_CONFIG_PATH" cp "$PROJECT_ROOT/config/daemon.env.template" "$OPENCLAW_HOME/daemon.env" cp "$PROJECT_ROOT/config/.env.template" "$OPENCLAW_HOME/.env" chmod 600 "$OPENCLAW_HOME/daemon.env" "$OPENCLAW_HOME/.env" -# The fork is the browser CLI used by the installed skills. Its own build and -# Firefox download are idempotent, matching the browser step in apply-addons. -"$PROJECT_ROOT/patches/camoufox-cli/build.sh" +# template 内 ${XIAOBEI_HOME} 解析成容器内固定路径写回 +node -e ' + const fs = require("fs"); + const p = process.argv[1]; + let raw = fs.readFileSync(p, "utf8"); + raw = raw.replace(/\$\{XIAOBEI_HOME\}/g, "/opt/xiaobei"); + fs.writeFileSync(p, raw); +' "$OPENCLAW_CONFIG_PATH" +echo "[bootstrap] STEP 1 done: config template placed" + +# ─── camoufox-cli fork 构建 + 全局安装(致命:browser-guide 依赖)──── +echo "[bootstrap] STEP 2: building camoufox-cli fork..." +if ! "$PROJECT_ROOT/patches/camoufox-cli/build.sh"; then + echo "[bootstrap] ❌ camoufox-cli build failed" >&2 + exit 1 +fi +echo "[bootstrap] STEP 2 done: camoufox-cli fork built + installed globally" + +# ─── apply-addons:patch + skills + crew + 编译(致命)──────────── +echo "[bootstrap] STEP 3: applying addons (patches + skills + crews + build)..." +if ! "$PROJECT_ROOT/scripts/apply-addons.sh" --force --no-restart; then + echo "[bootstrap] ❌ apply-addons.sh failed" >&2 + exit 1 +fi +echo "[bootstrap] STEP 3 done: addons applied + openclaw built" -# apply-addons is the shared installation core: reset/apply patches, install -# awada and skill dependencies, create crew workspaces, generate wrappers, and -# compile the patched OpenClaw distribution. Docker has no service to restart. -"$PROJECT_ROOT/scripts/apply-addons.sh" --force --no-restart +# ─── camoufox-cli install:拉 Firefox 二进制(非致命,首启可补)──── +# 幂等:已装且版本一致时打印 "already up to date" 并返回 +echo "[bootstrap] STEP 4: ensuring camoufox Firefox binary..." +camoufox-cli install || echo "[bootstrap] ⚠️ camoufox-cli install failed(可后续手动 camoufox-cli install)" +echo "[bootstrap] STEP 4 done" -# Pre-install the openclaw-weixin channel plugin so first launch can go straight -# to QR login. Switches stay off here (--no-enable); the entrypoint flips them -# on first launch, then runs weixin-qr.mjs to print the binding QR code. -"$PROJECT_ROOT/scripts/install-weixin-channel.sh" --no-enable +# ─── 预装 openclaw-weixin 插件(非致命,首启可补)──────────────── +# 与裸机 install.sh 的 install_weixin_plugin() 同源:读 pin 走在线 plugins install +install_weixin_plugin() { + local pin_file="$PROJECT_ROOT/openclaw-weixin.version.json" + local pkg ver + if [ -f "$pin_file" ]; then + pkg=$(python3 -c "import json;print(json.load(open('$pin_file'))['openclaw-weixin']['package'])" 2>/dev/null || true) + ver=$(python3 -c "import json;print(json.load(open('$pin_file'))['openclaw-weixin']['version'])" 2>/dev/null || true) + fi + pkg="${pkg:-@tencent-weixin/openclaw-weixin}" + ver="${ver:-2.4.6}" + # 幂等检查 + if (cd "$PROJECT_ROOT/openclaw" && pnpm openclaw plugins list 2>/dev/null | grep -q "openclaw-weixin"); then + echo "[bootstrap] openclaw-weixin plugin already installed" + return 0 + fi + echo "[bootstrap] installing openclaw-weixin plugin (${pkg}@${ver})" + if (cd "$PROJECT_ROOT/openclaw" && pnpm openclaw plugins install "${pkg}@${ver}" --pin); then + echo "[bootstrap] openclaw-weixin plugin installed" + else + echo "[bootstrap] ⚠️ openclaw-weixin 插件预装失败;首启可手动:pnpm openclaw plugins install ${pkg}@${ver} --pin" + fi +} +echo "[bootstrap] STEP 5: installing openclaw-weixin plugin..." +install_weixin_plugin +echo "[bootstrap] STEP 5 done" -# Docker needs the gateway reachable through the published localhost port. Do -# not persist a token in the image; entrypoint creates one on first launch. +# ─── gateway 配置:bind lan + token mode ───────────────────────── +# 容器内 gateway 要经 published localhost:18789 端口被宿主访问,故 bind lan +# token 不持久化进镜像,entrypoint 首启生成随机 token 写 ~/.openclaw/.env OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" node - <<'NODE' const fs = require('fs'); const path = process.env.OPENCLAW_CONFIG_PATH; @@ -47,5 +114,6 @@ config.gateway.auth = { ...(config.gateway.auth || {}), mode: 'token' }; delete config.gateway.auth.token; fs.writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); NODE +echo "[bootstrap] STEP 6 done: gateway config (bind lan + token mode)" -echo "[docker-bootstrap] immutable application layer prepared" +echo "[bootstrap] ✅ immutable application layer prepared" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ca64a04d..339f8a3a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,34 +1,111 @@ name: xiaobei -# Production compose file. Images are built and published by GitHub Actions; -# local validation can override IMAGE with `IMAGE=xiaobei:local` after running -# ./scripts/build-image.sh. +# 生产 compose 文件。镜像由 AtomGit 流水线构建并推送到阿里云 ACR; +# 本地验证可用 IMAGE=xiaobei:local 覆盖(先跑 ./scripts/build-image.sh)。 # -# Required configuration: copy .env.example to .env and fill in AWK_API_KEY. -# Docker Compose auto-loads .env from this directory, so `docker compose up|down|logs` -# work without prefixing env vars on every command. +# 必填配置:拷 .env.example 到 .env,填 AWK_API_KEY。Docker Compose 自动从本目录 +# 加载 .env,所以 `docker compose up|down|logs` 不必每次前缀 env vars。 +# +# 网络拓扑: +# xiaobei 容器(wiseflow,跑 openclaw gateway + crews + camoufox) +# mimicwx-linux 容器(MimicWX,跑微信客户端,登录态持久化在 wechat-data 卷) +# 两个容器共用 xiaobei-net 网络,xiaobei 容器经 docker exec 进微信容器读消息库 +# (wx-mp-hunter posts-list 依赖此能力) +# +# 端口映射(默认只绑 127.0.0.1,远程访问走 SSH 隧道): +# xiaobei 容器 18789 → gateway API +# xiaobei 容器 6080 → noVNC web(浏览器开 http://localhost:6080/vnc.html 操作 camoufox 过验证) +# mimicwx 容器 6081 → noVNC web(浏览器开 http://localhost:6081/vnc.html 微信扫码登录) +# 两个容器都开 noVNC web,用不同宿主端口(6080 / 6081)避免冲突 services: xiaobei: image: ${IMAGE:-registry.cn-hangzhou.aliyuncs.com/${ALIYUN_NAME_SPACE:-xiaobei}/xiaobei:latest} restart: unless-stopped environment: - # The only required user-supplied setting. Other skill credentials are - # configured later by the running agent in the persistent .env file. - # ${AWK_API_KEY:-__FILL_AWK_API_KEY__} lets `compose config|ps|down` succeed - # without a .env; the entrypoint refuses placeholder values at runtime. + # 唯一必填用户设置。其他 skill 凭据由运行中的 agent 在持久化 .env 里后配。 + # ${AWK_API_KEY:-__FILL_AWK_API_KEY__} 让 `compose config|ps|down` 在无 .env 时也能 + # 成功;entrypoint 在运行时拒绝 placeholder 值。 AWK_API_KEY: ${AWK_API_KEY:-__FILL_AWK_API_KEY__} + # wx-mp-hunter posts-list 需 docker exec 进微信客户端容器读 SQLCipher 消息库。 + # WX_BIZ_CONTAINER = 微信客户端容器名(与 mimicwx service 的 container_name 对齐) + # WX_BIZ_USER_DIR = 容器内微信用户数据根目录 + # WX_BIZ_KEYS_FILE = 容器内密钥文件路径 + WX_BIZ_CONTAINER: ${WX_BIZ_CONTAINER:-mimicwx-linux} + WX_BIZ_USER_DIR: ${WX_BIZ_USER_DIR:-/home/wechat/.xwechat} + WX_BIZ_KEYS_FILE: ${WX_BIZ_KEYS_FILE:-/home/wechat/.xwechat/keys.json} ports: - # Keep management surfaces local to the Docker host by default. Remote - # access should use an SSH tunnel or a deliberately configured proxy. + # 管理面默认只绑 Docker host 的 127.0.0.1。远程访问应走 SSH 隧道或显式配置代理。 - "127.0.0.1:18789:18789" - "127.0.0.1:6080:6080" cap_add: - # Camoufox's Firefox sandbox needs this in the current container model. + # Camoufox 的 Firefox sandbox 在当前容器模型下需要这个 - SYS_ADMIN + # wx-mp-hunter posts-list 用 docker exec 进微信容器读消息库(SYS_PTRACE 读进程内存) + - SYS_PTRACE + security_opt: + # 放开 seccomp/apparmor 让 camoufox sandbox + docker exec 都稳跑 + - seccomp:unconfined + - apparmor:unconfined volumes: + # 两个数据目录外挂——首启从镜像内 runtime-seed 初始化空卷,已有卷绝不覆盖。 + # 两个目录含 API key 和平台登录态,权限 0700。 - xiaobei-openclaw:/root/.openclaw - xiaobei-camoufox:/root/.camoufox-cli + # 挂宿主 docker.sock:wx-mp-hunter posts-list 用 docker exec 进微信容器读消息库 + # (需求 4:xiaobei 容器需能访问宿主 docker daemon 才能 docker exec) + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - xiaobei-net + # camoufox Firefox + 微信容器配合需要共享内存 + shm_size: '512m' + tmpfs: + - /tmp + depends_on: + - mimicwx + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:18789/status"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + + # 微信客户端容器(MimicWX-Linux)—— wx-mp-hunter posts-list 的数据源 + # 参考 MimicWX 项目 Dockerfile,微信 .deb 官方包 + VNC 桌面 + # 登录态持久化在 wechat-data 卷,重启不丢登录 + mimicwx: + image: ${MIMICWX_IMAGE:-registry.cn-hangzhou.aliyuncs.com/${ALIYUN_NAME_SPACE:-xiaobei}/mimicwx:latest} + container_name: mimicwx-linux + restart: unless-stopped + cap_add: + - SYS_ADMIN + - SYS_PTRACE + security_opt: + - seccomp:unconfined + - apparmor:unconfined + ports: + # mimicwx 容器自己的 noVNC web(宿主 6081 → 容器 6080,与 xiaobei 的 6080 分开) + # 浏览器开 http://localhost:6081/vnc.html 微信扫码登录 + - "127.0.0.1:6081:6080" + volumes: + # 微信登录态 + 消息库持久化(wx-mp-hunter posts-list 读这里) + - wechat-data:/home/wechat/.xwechat + networks: + - xiaobei-net + shm_size: '512m' + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8899/status"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + +networks: + xiaobei-net: + driver: bridge volumes: xiaobei-openclaw: xiaobei-camoufox: + wechat-data: diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index ea5b790c..e326dd7c 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,22 +1,37 @@ #!/usr/bin/env bash +# xiaobei 容器入口脚本 +# +# 职责: +# 1. 首启从镜像内 runtime-seed 初始化空的 /root/.openclaw 和 /root/.camoufox-cli 卷 +# (已有卷绝不覆盖——升级镜像时登录态和用户配置会保留) +# 2. 加载持久化 .env / daemon.env,把 AWK_API_KEY 渲染进 openclaw.json 的 apiKey 字段 +# 3. 首启生成随机 OPENCLAW_GATEWAY_TOKEN 写 ~/.openclaw/.env(不进镜像层) +# 4. 启动显示栈:Xvfb(虚拟显示,camoufox 有头模式跑这里)+ fluxbox + x11vnc + websockify + noVNC +# 用户浏览器开 http://localhost:6080 操作 VNC 桌面,里面能看到 camoufox 浏览器窗口 +# 过小红书/抖音等平台验证 +# 5. 首启若未绑定微信则打印扫码二维码(weixin-qr.mjs),扫码后绑定态迁到挂载卷根 +# 6. 启动 openclaw gateway(--allow-unconfigured,首启即跑) set -euo pipefail XIAOBEI_ROOT=/opt/xiaobei -OPENCLAW_HOME=${OPENCLAW_HOME:-/root/.openclaw} -CAMOUFOX_HOME=${CAMOUFOX_HOME:-/root/.camoufox-cli} +OPENCLAW_HOME="${OPENCLAW_HOME:-/root/.openclaw}" +CAMOUFOX_HOME="${CAMOUFOX_HOME:-/root/.camoufox-cli}" RUNTIME_SEED=/opt/xiaobei/runtime-seed/openclaw DOTENV="$OPENCLAW_HOME/.env" DAEMON_ENV="$OPENCLAW_HOME/daemon.env" +DISPLAY_NUM="${DISPLAY_NUM:-99}" +export DISPLAY=":${DISPLAY_NUM}" fail() { echo "[xiaobei] ERROR: $*" >&2 exit 1 } +# ─── 1. 首启初始化空卷 ────────────────────────────────────────────── bootstrap_runtime_state() { if [ ! -f "$OPENCLAW_HOME/openclaw.json" ]; then - [ -d "$RUNTIME_SEED" ] || fail "runtime seed is missing: $RUNTIME_SEED" - echo "[xiaobei] initializing persistent OpenClaw state" + [ -d "$RUNTIME_SEED" ] || fail "runtime seed missing: $RUNTIME_SEED" + echo "[xiaobei] first launch — initializing persistent OpenClaw state from seed" install -d -m 700 "$OPENCLAW_HOME" cp -a "$RUNTIME_SEED/." "$OPENCLAW_HOME/" fi @@ -24,10 +39,10 @@ bootstrap_runtime_state() { chmod 700 "$OPENCLAW_HOME" "$CAMOUFOX_HOME" } +# ─── 2. 加载运行时环境 ───────────────────────────────────────────── load_runtime_environment() { - # Compose variables take precedence over persisted configuration. The seed - # contains placeholders, which must never shadow a supplied AWK_API_KEY. - local supplied_awk_api_key=${AWK_API_KEY:-} + # Compose 注入的 AWK_API_KEY 优先于持久化 .env 里的值 + local supplied_awk_api_key="${AWK_API_KEY:-}" if [ -f "$DAEMON_ENV" ]; then set -a @@ -39,6 +54,7 @@ load_runtime_environment() { if [ -f "$DOTENV" ]; then local clean_dotenv clean_dotenv=$(mktemp) + # 过滤 placeholder(__FILL_*__),避免阴影真实 key grep -v '__FILL_.*__' "$DOTENV" > "$clean_dotenv" || true set -a # shellcheck disable=SC1090 @@ -52,6 +68,7 @@ load_runtime_environment() { fi } +# ─── 3. 首启生成 gateway token ───────────────────────────────────── ensure_gateway_token() { if [ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]; then umask 077 @@ -63,17 +80,48 @@ ensure_gateway_token() { fi } +# ─── 4. 显示栈:Xvfb + fluxbox + x11vnc + websockify ─────────────── +# camoufox 有头模式跑在 Xvfb 虚拟显示里,用户经 noVNC(http://localhost:6080) +# 看到该显示里的浏览器窗口,可操作过小红书/抖音验证。 start_display_stack() { - export DISPLAY=${DISPLAY:-:99} + echo "[xiaobei] starting display stack (Xvfb :${DISPLAY_NUM} + fluxbox + x11vnc + websockify)" + # Xvfb 虚拟显示(1280x800 分辨率足够浏览器窗口 + 验证码滑块操作) Xvfb "$DISPLAY" -screen 0 1280x800x24 -ac >/tmp/xiaobei-xvfb.log 2>&1 & - fluxbox >/tmp/xiaobei-fluxbox.log 2>&1 & - x11vnc -display "$DISPLAY" -forever -shared -nopw -rfbport 5900 >/tmp/xiaobei-x11vnc.log 2>&1 & + # 等显示就绪 + for i in 1 2 3 4 5; do + if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then break; fi + sleep 0.5 + done + # fluxbox 极简窗口管理器(看到浏览器窗口标题栏 + 可拖动) + DISPLAY="$DISPLAY" fluxbox >/tmp/xiaobei-fluxbox.log 2>&1 & + # x11vnc 把 Xvfb 显示暴露成 VNC(5900 内部端口,无密码——容器内只绑 127.0.0.1) + x11vnc -display "$DISPLAY" -forever -shared -nopw -rfbport 5900 -localhost >/tmp/xiaobei-x11vnc.log 2>&1 & + # websockify 把 VNC 流转成 WebSocket,noVNC web 客户端通过它连 websockify --web=/usr/share/novnc 6080 localhost:5900 >/tmp/xiaobei-websockify.log 2>&1 & + echo "[xiaobei] noVNC web client: http://localhost:6080/vnc.html" } -# Flip the openclaw-weixin switches on first launch so the channel comes up -# alongside the gateway. The plugin itself is pre-installed in the image by -# docker-bootstrap.sh → install-weixin-channel.sh --no-enable. +# ─── 5. 渲染 AWK_API_KEY 进 openclaw.json ─────────────────────────── +# 构建期 AWK_API_KEY 未设故 template 里 apiKey 字段是 ${AWK_API_KEY} placeholder, +# 不渲染则 gateway 拿空凭据静默回退到 bundled 模型,故这里必须替换成真值。 +render_awk_api_key() { + node -e ' + const fs = require("fs"); + const p = process.argv[1]; + const key = process.env.AWK_API_KEY; + if (!key) { console.error("[xiaobei] AWK_API_KEY missing — cannot render openclaw.json"); process.exit(1); } + let raw = fs.readFileSync(p, "utf8"); + const before = raw; + raw = raw.replace(/\$\{AWK_API_KEY\}/g, key); + if (raw !== before) { + fs.writeFileSync(p, raw); + console.log("[xiaobei] AWK_API_KEY rendered into openclaw.json"); + } + ' "$OPENCLAW_HOME/openclaw.json" +} + +# ─── 6. 启用 weixin channel + 首启扫码绑定 ───────────────────────── +# 插件本体已在镜像内预装(docker-bootstrap.sh 阞装),这里只翻 enabled 开关 + 扫码绑定。 enable_weixin_channel() { node -e ' const fs = require("fs"); @@ -89,21 +137,18 @@ enable_weixin_channel() { echo "[xiaobei] openclaw-weixin channel enabled" } -# First-launch WeChat binding: print the QR code to stdout and poll until the -# user scans + confirms. Subsequent launches skip this (binding state persists -# in /root/.openclaw/openclaw-weixin/accounts.json + accounts/{accountId}.json). -# -# 注意:插件 accounts.js 的 resolveStateDir() = OPENCLAW_STATE_DIR || ~/.openclaw -# 容器里 OPENCLAW_HOME=/root/.openclaw 已写死,但 OPENCLAW_STATE_DIR 未设时插件用 -# os.homedir() 拼 .openclaw —— 即嵌套层 /root/.openclaw/.openclaw/。扫码后绑定态 -# 落到嵌套层 openclaw-weixin/accounts.json。entrypoint 兜底迁移到挂载卷根。 +# 首启微信扫码绑定:打印 QR 到 stdout + 轮询扫码状态 + 写绑定态。 +# 已绑定(accounts.json 存在)则跳过。 +# 注意:插件 accounts.js 的 resolveStateDir() 用 os.homedir() 拼 .openclaw,容器里 +# OPENCLAW_HOME=/root/.openclaw 但 OPENCLAW_STATE_DIR 也设了同名,仍可能嵌套层写绑定态, +# 扫码后兜底迁移到挂载卷根(与裸机 install.sh bind_weixin 同逻辑)。 bind_weixin_channel() { local nested_dir="$OPENCLAW_HOME/.openclaw/openclaw-weixin" local nested_binding="$nested_dir/accounts.json" local root_dir="$OPENCLAW_HOME/openclaw-weixin" local root_binding="$root_dir/accounts.json" - # 嵌套层有绑定态 → 迁到挂载卷根(只迁一次,幂等) + # 嵌套层有绑定态 → 迁到挂载卷根(幂等) if [ -f "$nested_binding" ] && [ ! -f "$root_binding" ]; then echo "[xiaobei] migrating weixin binding from nested .openclaw/ to volume root" install -d -m 700 "$root_dir" @@ -118,12 +163,12 @@ bind_weixin_channel() { echo "[xiaobei] first launch — starting WeChat QR binding" echo "[xiaobei] scan the QR code below with WeChat on your phone, then confirm login" - node "$XIAOBEI_ROOT/scripts/weixin-qr.mjs" || { + node "$XIAOBEI_ROOT/docker/weixin-qr.mjs" || { echo "[xiaobei] ⚠️ weixin-qr exited non-zero; gateway will start without weixin binding" return 0 } - # 扫码成功后绑定态在嵌套层(插件路径决策)——立即迁到挂载卷根 + # 扫码成功后绑定态可能在嵌套层,立即迁到挂载卷根 if [ -f "$nested_binding" ] && [ ! -f "$root_binding" ]; then install -d -m 700 "$root_dir" cp -a "$nested_dir/." "$root_dir/" 2>/dev/null || true @@ -131,6 +176,7 @@ bind_weixin_channel() { fi } +# ─── main ────────────────────────────────────────────────────────── bootstrap_runtime_state load_runtime_environment @@ -139,30 +185,9 @@ if [ -z "${AWK_API_KEY:-}" ] || [[ "$AWK_API_KEY" == __FILL_*__ ]]; then fi ensure_gateway_token +render_awk_api_key start_display_stack enable_weixin_channel - -# Render ${AWK_API_KEY} placeholder in openclaw.json with the real env value. -# docker-bootstrap.sh copies config-templates/openclaw.json into the image during -# build — at build time AWK_API_KEY is unset, so the apiKey 字段 stays a literal -# "${AWK_API_KEY}" placeholder. Without substitution here, the gateway receives an -# empty AWK credential and silently falls back to openai/gpt-5.5 (bundled). Only -# the apiKey 字段 is touched; all other fields already shipped correct from the template. -# 必须在 bind_weixin_channel 之前:bind 步会前台跑 weixin-qr.mjs 等扫码,render 跑不到。 -node -e ' - const fs = require("fs"); - const p = process.argv[1]; - const key = process.env.AWK_API_KEY; - if (!key) { console.error("[xiaobei] AWK_API_KEY missing — cannot render openclaw.json"); process.exit(1); } - let raw = fs.readFileSync(p, "utf8"); - const before = raw; - raw = raw.replace(/\$\{AWK_API_KEY\}/g, key); - if (raw !== before) { - fs.writeFileSync(p, raw); - console.log("[xiaobei] AWK_API_KEY rendered into openclaw.json"); - } -' "$OPENCLAW_HOME/openclaw.json" - bind_weixin_channel echo "[xiaobei] starting gateway" diff --git a/docs/docker-distribution.md b/docs/docker-distribution.md index 31dbd0f8..ae1a5f02 100644 --- a/docs/docker-distribution.md +++ b/docs/docker-distribution.md @@ -1,74 +1,133 @@ # xiaobei Docker 分发 -> ⚠️ **可选通道,现阶段不再主推**(2026-07-19) -> -> wiseflow 现以 `scripts/install.sh`(curl 一键脚本)为主分发通道,详见 `docs/native-distribution-plan-2026-07-18.md` 与 README「快速开始」段。 -> `.github/workflows/release.yml` 的 `docker` job 已删除,CI 不再自动构建推送镜像;`release` job 仅保留版本 bump + tag。 -> -> 本文档保留给需要沙箱 / 隔离场景的用户参考,但需自行 build 镜像(`docker/docker-bootstrap.sh`)或使用历史已推到阿里云的滞后镜像。Dockerfile 与 `docker/` 脚本本身不删,留作后续重启通道时的复用基础。 +> **v5.6.3+ 开箱即用**:镜像内已装好 openclaw 引擎 + 全部 skills/crews + +> camoufox-cli + Firefox + openclaw-weixin 插件 + 显示栈(Xvfb/fluxbox/x11vnc/ +> websockify/noVNC)。用户拉镜像后只需填 `AWK_API_KEY`,`docker compose up -d` 即启动。 -## 运行 - -生产镜像由 GitHub Actions 在合并到 `master` 后构建并推送到阿里云镜像服务。用户只需要: +## 快速开始 ```bash -AWK_API_KEY= docker compose up -d -``` +# 1. 拷贝环境模板 +cp docker/.env.example docker/.env -`docker-compose.yml` 默认使用: +# 2. 编辑 .env,填入你的 AWK_API_KEY(阿里云百炼 token) +# AWK_API_KEY=sk-xxxxxxxxxxxxxxxx -```text -registry.cn-hangzhou.aliyuncs.com//xiaobei:latest +# 3. 启动 +docker compose up -d + +# 4. 访问 +# gateway API: http://localhost:18789 +# noVNC web: http://localhost:6080/vnc.html ``` -本地验证先构建,再覆盖镜像名: +首启会打印微信扫码绑定二维码,用手机微信扫码确认登录即可。绑定态持久化在 +`xiaobei-openclaw` 卷,后续重启自动跳过扫码。 -```bash -./scripts/build-image.sh -AWK_API_KEY= IMAGE=xiaobei:local docker compose up -d -``` +## 镜像内容(= 跑完 install.sh 后的状态) -## 构建语义 +镜像构建复用裸机安装的同一套底层脚本(`apply-addons.sh` / `setup-crew.sh` / +`camoufox-cli/build.sh`),保证 patch / skills / wrappers / 依赖 / 编译语义与裸机同源。 -Docker 并不运行 `scripts/install.sh`。该脚本还负责拉取仓库、交互收集密钥以及安装 systemd/launchd daemon,这些都不属于镜像构建。 +| 层 | 内容 | +|----|------| +| openclaw 引擎 | 按 `openclaw.version` pin 的 commit + 全部 patches + 编译后的 dist | +| awada 插件 | 本地 TS 插件 + ws/zod 运行时依赖 | +| skills | 公共 skills(`skills/`)+ crew 专属 skills(`crews/*/skills/`)+ python deps | +| crews workspace | main / content-producer / it-engineer / sales-cs 四套 crew 预初始化 | +| camoufox-cli | wiseflow fork(反指纹浏览器 CLI)+ Firefox 二进制(~557MB) | +| openclaw-weixin | 预装微信 channel 插件(首启扫码绑定) | +| 显示栈 | Xvfb(虚拟显示)+ fluxbox(窗口管理)+ x11vnc + websockify + noVNC | -镜像构建使用 `scripts/docker-bootstrap.sh`,它复用与裸机安装相同的 `apply-addons.sh` 与 `setup-crew.sh` 路径,完成: +**不装**:systemd/launchd daemon(容器无服务管理器)、交互式密钥收集(走环境变量)。 -- 应用 OpenClaw patches 和 overrides; -- 安装 awada、公共/crew skills 的 Node 与 Python 依赖; -- 创建 crew workspace、skills allowlist、wrapper 与运行配置; -- 编译 patched OpenClaw; -- 安装仓内 fork 的 `camoufox-cli` 及 Firefox 二进制。 +## 持久化和升级 -因此 Docker 与裸机部署只在“源码同步、交互式密钥收集、宿主机服务管理”上不同,能力安装链路保持同源。 +| Volume | 容器路径 | 内容 | +|--------|---------|------| +| `xiaobei-openclaw` | `/root/.openclaw` | openclaw.json、`.env`、workspace、会话、微信登录态 | +| `xiaobei-camoufox` | `/root/.camoufox-cli` | 浏览器 profile、Cookie、指纹缓存 | -GitHub Actions 与 `scripts/build-image.sh` 都会先按 `openclaw.version` 注入固定 commit 的 `openclaw/` 源码(连同 `.git` 元数据)。这样 patch 的 `git apply --3way` 可用,并且构建不依赖构建时拉取最新业务代码。 +**首启行为**:空卷从镜像内 `/opt/xiaobei/runtime-seed/openclaw` 初始化。 +**升级行为**:已有卷**绝不覆盖**,登录态和用户配置保留。 +**备份**:两个卷含 API key 和平台登录态,备份时应限制文件权限。 +**清空**:`docker compose down -v` 删除卷,等同于清空该实例的配置与登录状态。 -## 持久化和升级 +## noVNC — 浏览器操作容器内桌面 -| 卷 | 容器路径 | 内容 | -|---|---|---| -| `xiaobei-openclaw` | `/root/.openclaw` | 配置、`.env`、workspace、会话与渠道状态 | -| `xiaobei-camoufox` | `/root/.camoufox-cli` | 浏览器 profile、Cookie、指纹与运行缓存 | +camoufox 有头模式跑在容器内 Xvfb 虚拟显示里。用户浏览器打开 +`http://localhost:6080/vnc.html` 即可看到该显示里的 fluxbox 桌面,里面能看到 camoufox +浏览器窗口——用于**小红书/抖音等平台登录过验证**的场景。 -入口脚本在卷为空时从镜像内 `/opt/xiaobei/runtime-seed/openclaw` 初始化;它不依赖 Docker 对 named volume 的首次复制行为,因此空 bind mount 也可正常启动。已有卷绝不会被镜像升级覆盖,登录态和用户配置会保留。 +**mimicwx 容器**也有自己的 noVNC web(宿主端口 `6081`):浏览器开 +`http://localhost:6081/vnc.html` 操作微信客户端扫码登录。 -两个卷包含 API key 和平台登录态。备份时应限制文件权限;删除卷等同于清空该实例的配置与登录状态。 +**端口默认只绑 Docker host 的 `127.0.0.1`**。远程访问应走 SSH 隧道或显式配置代理, +不要直接把 6080 / 6081 暴露到公网。 -## 安全边界 +## 微信容器配合(wx-mp-hunter posts-list) -- `AWK_API_KEY` 仅从运行环境读取,不写入镜像层或 `openclaw.json`。 -- 首启会为 gateway 生成随机 `OPENCLAW_GATEWAY_TOKEN`,以 `0600` 写入持久化 `.env`。 -- Gateway 和 noVNC 在 Compose 中只映射到 `127.0.0.1`。不要直接把 6080 暴露到公网。 -- 当前 Camoufox sandbox 需要 `SYS_ADMIN` capability;只运行受信任的官方镜像,并保持 Docker daemon 权限最小化。 +`wx-mp-hunter` skill 的 `posts-list` 子命令依赖一个运行中的微信客户端容器 +(MimicWX-Linux),通过 `docker exec` 进微信容器读 SQLCipher 加密的消息库。 + +`docker-compose.yml` 已配好两个容器在同一 `xiaobei-net` 网络里: + +- `xiaobei` 容器挂载宿主 `/var/run/docker.sock`(只读),用于 `docker exec` 进微信容器 +- `mimicwx` 容器跑微信客户端,登录态持久化在 `wechat-data` 卷 +- 环境变量 `WX_BIZ_CONTAINER` / `WX_BIZ_USER_DIR` / `WX_BIZ_KEYS_FILE` 指向微信容器 + +**前提**:需要先有 `mimicwx` 镜像。如果只用到 `wx-mp-hunter` 的 `fetch` / `homepage` +子命令(不依赖微信容器),可以注释掉 `docker-compose.yml` 里的 `mimicwx` service。 + +## 构建与分发(GitHub + 阿里云 ACR 直绑) + +镜像由阿里云 ACR 企业版**直绑 GitHub 仓库**自动构建并托管——不走外部流水线, +push tag 即触发 ACR 构建。 -## CI 发布 +### 触发方式 -`release.yml` 的 Docker job: +1. **手动触发**:在阿里云 ACR 控制台 → `xiaobei` 仓库 → 构建规则 → 点"立即构建" +2. **push tag 自动触发**:`git push origin v5.6.3` 会自动触发 ACR 构建规则 -1. checkout release commit; -2. 按 `openclaw.version` clone pinned OpenClaw; -3. 用 Buildx 构建 `linux/amd64` 镜像; -4. 推送 `xiaobei:` 和 `xiaobei:latest`。 +### ACR 构建规则配置(一次性) -在阿里云 ACR 中预先创建名为 `xiaobei` 的仓库,并配置 `ALIYUN_REGISTRY`、`ALIYUN_NAME_SPACE`、`ALIYUN_REGISTRY_USER` 和 `ALIYUN_REGISTRY_PASSWORD` 四个 GitHub Secrets。 +在 ACR 控制台为 `xiaobei` 仓库添加一条构建规则: + +| 字段 | 配置 | +|------|------| +| 代码源 | GitHub(先在 ACR 绑定 GitHub 个人版账号) | +| 分支/Tag | 正则 `v(?\w*)` 匹配 `v*` tag | +| 构建上下文目录 | `/` | +| Dockerfile 文件名 | `docker/Dockerfile` | +| 镜像版本 | `${imageTag}` 和 `latest`(加两条镜像版本行,或建两条规则) | +| 海外机器构建 | 勾选(镜像内要 clone GitHub openclaw、npm 拉海外包,海外构建 + 海外源智能加速更稳) | +| 构建参数 | `USE_MIRROR=0`(用原始源 + ACR 海外源智能加速,国内镜像源在海外构建机反而绕远路) | + +### 产物(阿里云 ACR) + +``` +registry.cn-hangzhou.aliyuncs.com//xiaobei:v5.6.3 +registry.cn-hangzhou.aliyuncs.com//xiaobei:latest +``` + +### 发放模式 + +构建完成后,向符合条件的用户直接发放阿里云的 image 链接: + +```bash +# 用户侧:拉镜像 + 启动 +docker login registry.cn-hangzhou.aliyuncs.com # 填发放的账号 +docker pull registry.cn-hangzhou.aliyuncs.com//xiaobei:v5.6.3 +cp docker/.env.example docker/.env # 填 AWK_API_KEY +AWK_API_KEY= docker compose up -d +``` + +## 安全边界 + +- `AWK_API_KEY` 仅从运行环境读取,**不写入镜像层**也不写 `openclaw.json` 的明文。 +- 首启会为 gateway 生成随机 `OPENCLAW_GATEWAY_TOKEN`,以 `0600` 写入持久化 `.env`。 +- Gateway 和 noVNC 在 Compose 中只映射到 `127.0.0.1`。**不要直接把 6080 暴露到公网**。 +- 当前 Camoufox sandbox 需要 `SYS_ADMIN` capability;只运行受信任的官方镜像,并保持 + Docker daemon 权限最小化。 +- `/var/run/docker.sock` 只读挂载——xiaobei 容器能 `docker exec` 进微信容器读消息库, + 但无法修改宿主 docker 状态。若需更强隔离,可改用 docker-socket-proxy。 diff --git a/patches/camoufox-cli/build.sh b/patches/camoufox-cli/build.sh index 564b843e..24979d3c 100755 --- a/patches/camoufox-cli/build.sh +++ b/patches/camoufox-cli/build.sh @@ -10,7 +10,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" echo "→ [camoufox-cli fork] installing deps (incl. devDeps for tsc)..." -npm install +# NPM_REGISTRY 默认 npmmirror(国内裸机/本地),USE_MIRROR=0(海外构建机)走原始 npmjs +npm install --registry="${NPM_REGISTRY:-https://registry.npmmirror.com}" echo "→ [camoufox-cli fork] building dist/..." npm run build diff --git a/scripts/apply-addons.sh b/scripts/apply-addons.sh index 2ce8d445..693ae00a 100755 --- a/scripts/apply-addons.sh +++ b/scripts/apply-addons.sh @@ -296,7 +296,9 @@ if [ -d "$AWADA_EXT" ] && [ -f "$AWADA_EXT/package.json" ]; then awada_stored="$(cat "$AWADA_PKG_HASH_FILE" 2>/dev/null || echo '')" if [ "$awada_hash" != "$awada_stored" ] || [ ! -d "$AWADA_EXT/node_modules" ]; then echo "📦 Installing awada plugin dependencies (ws + zod)..." - (cd "$AWADA_EXT" && npm install --omit=dev --no-audit --no-fund --loglevel=warn --registry=https://registry.npmmirror.com) \ + # USE_MIRROR=0(海外构建机)走原始 npmjs,USE_MIRROR=1(国内裸机/本地)走 npmmirror + npm_reg="${NPM_REGISTRY:-https://registry.npmmirror.com}" + (cd "$AWADA_EXT" && npm install --omit=dev --no-audit --no-fund --loglevel=warn --registry="$npm_reg") \ && echo "$awada_hash" > "$AWADA_PKG_HASH_FILE" \ && echo "✅ awada dependencies installed" \ || echo " ⚠️ awada npm install failed (可后续手动 cd $AWADA_EXT && pnpm install --prod)" >&2 @@ -383,7 +385,7 @@ if [ "$current_pkg_hash" != "$stored_pkg_hash" ]; then for d in "${skill_pkg_dirs[@]}"; do SKILL_PKG_IDX=$((SKILL_PKG_IDX + 1)) printf " [%d/%d] %s\n" "$SKILL_PKG_IDX" "$SKILL_PKG_TOTAL" "${d#$PROJECT_ROOT/}" - (cd "$d" && npm install --omit=dev --no-audit --no-fund --loglevel=warn --registry=https://registry.npmmirror.com) \ + (cd "$d" && npm install --omit=dev --no-audit --no-fund --loglevel=warn --registry="${NPM_REGISTRY:-https://registry.npmmirror.com}") \ || echo " ⚠️ npm install failed in $d" >&2 done echo "$current_pkg_hash" > "$SKILL_PKG_HASH_FILE" @@ -524,7 +526,7 @@ if [ "$NEEDS_INSTALL" = "true" ]; then fi NODE_OPTIONS="--max-old-space-size=8192" \ pnpm install --no-frozen-lockfile --strict-peer-dependencies=false \ - --registry=https://registry.npmmirror.com \ + --registry="${NPM_REGISTRY:-https://registry.npmmirror.com}" \ --fetch-retries=5 --fetch-timeout=600000 --network-concurrency=8 cd "$PROJECT_ROOT" fi diff --git a/scripts/lib/agent-skills.sh b/scripts/lib/agent-skills.sh index a4a273ff..1d2325e7 100644 --- a/scripts/lib/agent-skills.sh +++ b/scripts/lib/agent-skills.sh @@ -563,6 +563,8 @@ python3 /tmp/my_script.py ``` 临时脚本统一写到 `/tmp/` 下,执行后可删除。 + +> ⚠️ 部分模型(deepseek-v4-flash 等)在 heredoc / `python3 -c` 里会把 `\n` 序列化成字面量反斜杠+n 而非真换行,触发 `SyntaxError`。heredoc 内 Python 源码一律用真实换行,不在字符串里写 `\n` 转义;文本替换优先 `awk`/`sed`。 GUIDE # sed -i 在 BSD(macOS)会把脚本串当成备份后缀吞掉;-i.bak 两端都支持,再清掉 .bak。 sed -i.bak "s|@@WS@@|$ws|g" "$tools_md" && rm -f "$tools_md.bak" @@ -574,6 +576,14 @@ GUIDE 本 crew 为**对内 crew**,exec **无白名单限制**——管道、`&&`、`||`、`;`、`cd` 前缀、相对路径、`bash`/`sh` 前缀等均不触发 allowlist miss,可直接执行。 脚本调用仍建议用绝对路径(如 `python3 @@WS@@/skills/xxx/scripts/yyy.py`),仅为跨环境/跨 workspace 稳定,非安全约束。 + +## Python 多行脚本规范 + +多行 Python **不要**用 `python3 -c '...'` 内联——部分模型(deepseek-v4-flash 等)会把 `\n` 序列化成字面量反斜杠+n 而非真换行,触发 `SyntaxError: unexpected character after line continuation character`。 + +- ✅ 文本替换用 `awk`/`sed` +- ✅ 多行 Python 先 `cat > /tmp/script.py << 'PYEOF'`(heredoc 内用真实换行,不在字符串里写 `\n`)再 `python3 /tmp/script.py` +- ❌ `python3 -c 'import json\nwith open(...) as f:\n ...'`(`\n` 会被写成字面量) GUIDE sed -i.bak "s|@@WS@@|$ws|g" "$tools_md" && rm -f "$tools_md.bak" fi diff --git a/version b/version index 58ed2d2d..c66ecbe2 100644 --- a/version +++ b/version @@ -1 +1 @@ -v5.6.2 +v5.6.3