Files
workbuddy_skills/browser-harness/agent-workspace/bh_chrome_keepalive.py
T
admin d81f75f173 技能包:配置与代码分家 —— 每包自建 env/ 放本机专属配置,scripts/ 不再写死本机路径
用户 2026-10-11 原话:
· 19:22「这个环境都是针对一台机器的,同步到别的机器把环境都搞坏了」
· 19:28「不用挪出 用env文件夹区分就行,重点是 脚本是脚本 配置是配置」
· 19:29「每个技能 需要环境配置的 都有自己的 env文件夹」
· 19:30「修改完成后要仔细检查 避免调整后 各技能脚本运行失败」

改动:
1、session-mechanism 新增 env/(roots.env=本机真身不入库 + roots.env.example=样例入库),
   install.py 的 ROOTS_ENV 改指 env/roots.env;全包脚本/hook 的配置定位统一为
   「向上 4 层 × env/roots.env 优先 → 包根 roots.env 兼容 → 技能库那份兜底」。
2、去硬编码:collabctl / keepalive / supervise-launch / board-launch 里的本机路径与本机工作区
   清单全清,改为 DSH_AIROOT(兜底 dirname(DSH_WS_ROOT))+ 扫 <AIROOT>/*/.workbuddy/collab/
   自动发现;保活任务名按 collabd-keepalive-<工作区名> 现推;启动器默认值改 ~/.workbuddy 形态。
3、browser-harness:bh_chrome_keepalive.py 默认值全部改 env 驱动,新增 env/bh.env.example。
4、.gitignore:env/ 下只放行 *.example(三行三种层级)+ 兜住旧位置的包根 roots.env。
5、改包纪律.md 新增「四、包结构:脚本是脚本,配置是配置」,立为全仓通用规矩。
6、文档同步:SKILL.md / deploy.md(新增 §3b 配置层级、§6 换机器必查 6 件事)/ rules.md /
   pitfalls.md / supervise-persistence.md。

验收(本机):py_compile 34 个 py 全过;selftest PASS 109 / FAIL 0;
逐文件实证 8 个 loader 命中 env/roots.env;preflight-lock.sh 同目录实测解出两个根;
install.py --manifest 77 份、语法失败 0。

异机验证(包拷成「新克隆」(只有 *.example)+ 另造假工作区 ws-alpha/ws-beta):
install.py --apply 自生成该机 env/roots.env;collabctl/keepalive 自动发现的确实是
ws-alpha/ws-beta、任务名现推(写死清单确已消除);部署副本本地无 roots.env 时靠
CODEBUDDY_CONFIG_DIR 兜底读到技能库那份;整套 selftest 有宿主库时 PASS 108 / FAIL 1
(唯一 FAIL=该区没有常驻心跳,环境所致;本机常驻在跑时同一套为 PASS 109 / FAIL 0)。
2026-10-11 21:49:28 +08:00

174 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""常驻守端口:让本机有一个可用的独立 Chrome 实例(默认 9223,固定 profile ⇒ 登录态保留)。
## 它解决什么
本机沙箱**在父进程退出时连带杀子进程** ⇒ 会话/工具调用里起的浏览器,调用一返回就死
(症状:`netstat` 查不到端口、`curl` 报 `WinError 10061`)。
要的是:浏览器活在一个**永不退出的循环**里 —— 起完 Chrome 就守在端口上。
## 怎么用(Windows 实测 2026-10-10)
🔴 **只允许一种起法**(用户 2026-10-10 令:「只允许这一种方式,使用 browser harness 打开浏览器的时候
也必须用这种方式打开浏览器」):**在本会话的后台任务里跑本文件**,stdout 全重定向到文件。
1、命令**整段抄**(见 `SKILL.md`「调 AI 工具」§1),解释器用 **`pythonw.exe`**
2、**交给你这个会话的后台任务去跑**(后台任务只活到本会话结束 ⇒ 会话在、浏览器在;会话收工、浏览器下线)
3、⛔ 不往工作区复制副本、⛔ 不建 Windows 计划任务(计划任务跨会话常驻,会把用户手关的浏览器拉回来)
⛔ 也不在同步调用里跑(父进程一退 Chrome 就死)
**为什么必须有这一步**:后台任务的 stdout 会灌进会话日志 ⇒ 超 ~10 MiB 后宿主丢日志、界面看着卡死
⇒ 所以命令里那截 `>> …_bh_bg.out 2>&1` 不能省。
## 做法约定
- **幂等**:端口已在听 ⇒ 立刻退出。(避免叠加第二个实例、避免两个实例抢同一个 profile)
- **守**:`while True` 里看着子进程;它**异常**退出(rc≠0)就重新起。
- 🔴 **手关窗 ⇒ 停手**:Chrome 正常退出(rc=0,=用户点的关闭窗口)时脚本**自己退出**,不再拉起。
依据(实测 2026-10-10):用户关掉浏览器,守卫在 1 秒内把它拉回,连关三次弹三次。
要重新开,跑一条命令起本脚本即可(见 `SKILL.md`「调 AI 工具」§1)。
- **僵死态只记日志**:端口不应答而进程仍在时,脚本**不自动重启** —— 那会把正在被排查的现场清掉,
留给人/会话按 SKILL.md 的判据处置。
- **清代理 env**:`HTTP_PROXY` / `HTTPS_PROXY` 会劫持本地 CDP 连接(报 `502 Bad Gateway`)。
- **只用独立实例**:⛔ 不附着用户日常 Chrome(那是 9222,会弹「允许远程调试?」动到用户浏览器)。
用法:
pythonw bh_chrome_keepalive.py
(整条命令见 SKILL.md「调 AI 工具」§1;本脚本要放在**会话的后台任务**里跑)
可选:`--port` `--chrome` `--profile` `--proxy` `--log`
🔴 **默认值从哪来(2026-10-11 起)**:先读本包 `env/bh.env`(每台机器一份,不入库),
其次宿主 env 的 `BH_*`;再兜底到「WorkBuddy 配置目录(`CODEBUDDY_CONFIG_DIR`,否则 `~/.workbuddy`)
+ tmp/…」。⛔ 代码里不写死本机路径 —— 要改就往 `env/bh.env` 里写(样例 `env/bh.env.example`)。
"""
from __future__ import annotations
import argparse
import io
import os
import socket
import subprocess
import sys
import time
# 清掉会劫持本地 CDP 的代理变量(要在起 Chrome 之前做)
for _k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
os.environ.pop(_k, None)
os.environ.setdefault("NO_PROXY", "127.0.0.1,localhost")
# [browser-harness] 环境专属配置外置(🔴 2026-10-11 用户令「脚本是脚本 配置是配置」⇒「每个技能
# 需要环境配置的 都有自己的 env文件夹」):**每台机器各不同**的值写本包 `env/bh.env`
# (本机生成、不入库;样例 `env/bh.env.example` 入库)。⛔ 代码里不写死本机路径。
def _bh_load_env():
import os as _os
_here = _os.path.dirname(_os.path.abspath(__file__))
for _up in range(4):
_base = _os.path.normpath(_os.path.join(_here, *([".."] * _up)))
for _rel in (("env", "bh.env"), ("bh.env",)):
_p = _os.path.join(_base, *_rel)
if _os.path.isfile(_p):
try:
with io.open(_p, encoding="utf-8") as _f:
for _ln in _f:
_ln = _ln.strip()
if _ln and not _ln.startswith("#") and "=" in _ln:
_k, _v = _ln.split("=", 1)
_os.environ.setdefault(_k.strip(), _v.strip())
except Exception:
pass
return
_bh_load_env()
# WorkBuddy 配置目录:宿主 env 优先,其次 `~/.workbuddy`(⛔ 不写死本机盘符/盘)
_CB_CONF = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")
DEFAULT_CHROME = (os.environ.get("BH_CHROME")
or r"C:\Program Files\Google\Chrome\Application\chrome.exe")
DEFAULT_PROFILE = (os.environ.get("BH_PROFILE")
or os.path.join(_CB_CONF, "tmp", "bu-chatgpt-profile"))
DEFAULT_LOG = (os.environ.get("BH_LOG")
or os.path.join(_CB_CONF, "tmp", "_bh_chrome.log"))
DEFAULT_PORT = int(os.environ.get("BH_PORT") or 9223)
def _log(path, msg):
if not path:
return
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with io.open(path, "a", encoding="utf-8") as f:
f.write("[%s] %s\n" % (time.strftime("%m-%d %H:%M:%S"), msg))
except Exception:
pass
def _listening(port: int) -> bool:
try:
s = socket.create_connection(("127.0.0.1", port), 1)
s.close()
return True
except Exception:
return False
def _launch(chrome: str, profile: str, port: int, proxy: str = ""):
cmd = [chrome, "--remote-debugging-port=%d" % port,
"--user-data-dir=%s" % profile,
"--no-first-run", "--no-default-browser-check"]
if proxy:
# 目标站点要走代理时才加(例:--proxy socks5://127.0.0.1:10800)
cmd.append("--proxy-server=%s" % proxy)
cmd.append("about:blank")
return subprocess.Popen(
cmd,
# DETACHED | CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
creationflags=0x00000008 | 0x08000000 | 0x00000200,
)
def main() -> int:
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--chrome", default=DEFAULT_CHROME)
ap.add_argument("--profile", default=DEFAULT_PROFILE)
ap.add_argument("--log", default=DEFAULT_LOG)
ap.add_argument("--proxy", default=os.environ.get("BH_PROXY") or "",
help='目标站点要走代理时才给,例 --proxy socks5://127.0.0.1:10800')
a = ap.parse_args()
if not os.path.isfile(a.chrome):
_log(a.log, "chrome 可执行体不存在:%s ⇒ 停(⛔ 不静默)" % a.chrome)
print("✗ 找不到 chrome:%s" % a.chrome, file=sys.stderr)
return 2
# ── 幂等前置:端口已在听 ⇒ 什么都不做 ──────────────────────────
if _listening(a.port):
_log(a.log, "port %d already listening ⇒ exit (idempotent)" % a.port)
return 0
p = _launch(a.chrome, a.profile, a.port, a.proxy)
_log(a.log, "launched pid=%s" % p.pid)
# ── 守:循环不结束 ⇒ Chrome 不被连坐 ───────────────────────────
# 🔴 退出码分两档(2026-10-10 用户报障「关掉就自动打开」后改):
# rc=0 = 正常关窗(用户自己点的关闭)⇒ **停手退出**,把浏览器还给用户;
# rc≠0 = 异常退出(崩溃/被杀)⇒ 重拉。
# 依据(实测):`[22:45:06] chrome exited rc=0 ⇒ relaunch` —— 用户手关三次、被拉回三次。
while True:
time.sleep(5)
if p.poll() is not None:
if p.returncode == 0:
_log(a.log, "chrome closed cleanly (rc=0) ⇒ stop guarding, exit")
return 0
_log(a.log, "chrome exited rc=%s ⇒ relaunch" % p.returncode)
p = _launch(a.chrome, a.profile, a.port, a.proxy)
_log(a.log, "relaunched pid=%s" % p.pid)
elif not _listening(a.port):
# ⚠️ 僵死态(端口不应答、进程还在):只记日志,⛔ 不自动重启 ——
# 自动重启会把"正在被排查的现场"清掉。判据与处置见 SKILL.md §1。
_log(a.log, "port %d not answering (pid alive=True)" % a.port)
if __name__ == "__main__":
sys.exit(main())