Files
workbuddy_skills/session-mechanism/scripts/wake-session.py
T

256 lines
11 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""wake-session.py —— 本机 WorkBuddy 网关「唤醒」投递器(纯本机 · 无代理 · 无垫片)
用途
----
把「唤醒某个 WorkBuddy 会话」做成**一次程序调用**:
程序 → 本机 WorkBuddy gateway(127.0.0.1:<动态口>)→ POST /api/v1/sessions/{id}/reply
⛔ 不经过覆盖网络 443、⛔ 不经过设备垫片 20090、⛔ 不经过任何代理 —— 全程本机回环。
设计约束(对应架构定稿 v3 §0.5 T1–T7)
--------------------------------------
· 凭据**只**从环境变量 `CODEBUDDY_GATEWAY_PASSWORD` 读:⛔ 命令行明文、⛔ 落盘、⛔ 回显
· 端口**每次现查**(网关每次重启都换口)⇒ **fail-closed**:查不到就具名失败,⛔ 绝不沿用上次端口
· 只投给 **live 且 `live.sessionId == 目标`** 的会话(T2:不夺会话、不抢 `writer_occupied`)
· 超时:读 ≤5 s、写 ≤10 s(T5);**单次投递、不重试**(T4:绝不反复试凭据、不用重试把自己锁死)
· 本程序**不监听任何端口**;⛔ 不改 WorkBuddy 配置 / 插件 / 令牌 / 进程(T3)
· 单次短操作 ⇒ 不长期持锁(T6);无守护、无残留(T7)
用法
----
python wake-session.py --list # 列出本机所有网关及其 live 会话
python wake-session.py --session current --dry-run # 只做发现 + 前置判定,不投递
python wake-session.py --session current --text "…" # 真投递(默认目标=当前会话)
python wake-session.py --session <uuid> --text "…" # 指定会话
python wake-session.py --port 59486 --session current --text "…" # 跳过发现(应急)
退出码
------
0 成功 | 1 参数错 | 2 前置不满足(目标非 live / 匹配不唯一)| 3 发现失败 | 4 投递失败
"""
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
TOKEN_ENV = "CODEBUDDY_GATEWAY_PASSWORD"
SESSION_ENV = "CODEBUDDY_SESSION_ID"
AUTH_HEADER = "x-access-token" # 唯一实测可用的带法(C1:?password= 在受保护路径上永远失效)
MARKERS = ("CodeBuddy Gateway", "CodeBuddy Remote Control")
READ_TIMEOUT = 5 # T5:读 ≤5 s
WRITE_TIMEOUT = 10 # T5:写 ≤10 s
PROBE_TIMEOUT = 1.2
# 具名错误码(⛔ 不把三类失败混成一句 "not found")
E_NO_LISTENER = "no-loopback-listener"
E_ENUM_FAILED = "enumeration-failed"
E_NOT_GATEWAY = "not-workbuddy-gateway"
E_NO_LIVE_MATCH = "no-live-match"
E_AMBIGUOUS = "ambiguous-match"
E_NOT_LIVE = "target-not-live"
def token():
"""凭据只从环境读。⛔ 任何日志 / 输出里都不得出现它的值。"""
t = os.environ.get(TOKEN_ENV) or ""
if not t:
die(1, "env-missing", "环境变量 %s 不存在 —— 本程序只接受环境变量取凭据(⛔ 不落盘)" % TOKEN_ENV)
return t
def die(code, name, msg):
print("[FAIL] %s: %s" % (name, msg))
sys.exit(code)
# --------------------------------------------------------------------------
# ① 列举本机回环监听口
# --------------------------------------------------------------------------
def listen_ports():
"""netstat -ano ⇒ [(port, pid)],只取 127.0.0.1 上的 LISTENING。
注:Python 的 subprocess 在本沙箱里可以起 netstat(Node 的 spawn 会 EBUSY)。"""
try:
out = subprocess.run(["netstat", "-ano"], capture_output=True, text=True,
timeout=10, encoding="utf-8", errors="replace",
creationflags=0x08000000).stdout # CREATE_NO_WINDOW:⛔ 闪窗
except Exception as e: # noqa: BLE001
return None, "%s (%s)" % (E_ENUM_FAILED, e)
rows, seen = [], set()
for line in out.splitlines():
m = re.match(r"\s*TCP\s+127\.0\.0\.1:(\d+)\s+\S+\s+LISTENING\s+(\d+)", line, re.I)
if not m:
continue
key = (m.group(1), m.group(2))
if key in seen:
continue
seen.add(key)
rows.append((int(m.group(1)), int(m.group(2))))
return rows, None
# --------------------------------------------------------------------------
# ② HTTP(纯标准库)
# --------------------------------------------------------------------------
def http(port, method, path, body=None, timeout=READ_TIMEOUT, auth=True):
"""auth=False ⇒ 不带凭据(判指纹时必须不带:带了口令 /api/v1/health 会从 401 变 200)。"""
url = "http://127.0.0.1:%d%s" % (port, path)
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
if auth and TIMEOUT_TOKEN[0]:
headers[AUTH_HEADER] = TIMEOUT_TOKEN[0]
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
except Exception: # noqa: BLE001
return 0, ""
TIMEOUT_TOKEN = [None] # 由 main 填;模块级避免到处传参
def fingerprint(port):
"""三判据(**全部不带凭据**):① GET / 含网关标记 ② /api/v1/health = 401 ③ 是 HTTP 服务。
⚠️ 判指纹必须 **auth=False**:带上 `x-access-token` 时 `/api/v1/health` 会 200。
⚠️ 这一条也正是「真网关 vs 垫片 20090」的**唯一分水岭** ——
垫片会把根页原样转出去(标记同样命中),但它自己不做鉴权 ⇒ health=200。
"""
code, body = http(port, "GET", "/", auth=False)
if code != 200 or not any(m in body for m in MARKERS):
return False, "root-markers-miss"
h, _ = http(port, "GET", "/api/v1/health", auth=False)
if h != 401:
return False, "health=%s(expect 401)" % h
return True, "ok"
def live_of(port):
"""GET /api/v1/sessions/live ⇒ (sessionId, writerOccupied) 或 (None, None)。"""
code, body = http(port, "GET", "/api/v1/sessions/live")
if code != 200:
return None, None, "http=%s" % code
try:
d = (json.loads(body) or {}).get("data") or {}
return d.get("sessionId"), bool(d.get("writerOccupied")), "ok"
except Exception as e: # noqa: BLE001
return None, None, "bad-json(%s)" % e
# --------------------------------------------------------------------------
# ③ 发现「服务目标会话的那个网关」
# --------------------------------------------------------------------------
def discover(target):
ports, err = listen_ports()
if ports is None:
die(3, err, "无法枚举本机监听口")
if not ports:
die(3, E_NO_LISTENER, "本机没有任何回环监听口")
gateways, matches = [], []
for port, pid in sorted(ports):
ok, why = fingerprint(port)
if not ok:
continue
sid, occ, _ = live_of(port)
gateways.append({"port": port, "pid": pid, "live": sid, "writerOccupied": occ})
if sid and target and sid == target:
matches.append(port)
return gateways, matches
def cmd_list():
gateways, _ = discover(None)
if not gateways:
die(3, E_NOT_GATEWAY, "没有端口通过网关指纹(GET / 含 %s + /api/v1/health=401)" % MARKERS[0])
me = os.environ.get(SESSION_ENV)
print("本机网关 %d 个(⛔ 端口每次重启都会变,故一律现查):" % len(gateways))
for g in gateways:
flag = ""
if me and g["live"] == me:
flag = " ← 本会话所在"
print(" · 127.0.0.1:%-6d pid=%-7s live=%-38s writerOccupied=%s%s"
% (g["port"], g["pid"], g["live"] or "(none)", g["writerOccupied"], flag))
return 0
def resolve_port(args, target):
if args.port:
ok, why = fingerprint(args.port)
if not ok:
die(2, E_NOT_GATEWAY, "指定端口 %d 未通过指纹:%s" % (args.port, why))
return args.port
gateways, matches = discover(target)
if not matches:
detail = ";".join(":%d live=%s" % (g["port"], g["live"] or "-") for g in gateways) or "无网关"
die(3, E_NO_LIVE_MATCH,
"没有网关的 live 会话 == 目标 %s ⇒ 目标当前不是桌面活动会话(%s)" % (target, detail))
if len(matches) > 1:
die(3, E_AMBIGUOUS, "多个网关都声称 live==%s:%s" % (target, matches))
return matches[0]
# --------------------------------------------------------------------------
# ④ 投递
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--list", action="store_true", help="列出本机网关与各自 live 会话")
ap.add_argument("--session", default="current", help="目标会话 id;current = 本会话(默认)")
ap.add_argument("--text", default=None, help="投递正文")
ap.add_argument("--port", type=int, default=None, help="跳过发现,直连指定网关口(应急)")
ap.add_argument("--dry-run", action="store_true", help="只做发现 + 前置判定,不投递")
args = ap.parse_args()
TIMEOUT_TOKEN[0] = os.environ.get(TOKEN_ENV) or None
if args.list:
return cmd_list()
if not TIMEOUT_TOKEN[0]:
die(1, "env-missing", "环境变量 %s 不存在" % TOKEN_ENV)
target = os.environ.get(SESSION_ENV, "") if args.session == "current" else args.session
if not target:
die(1, "no-target", "取不到本会话 id(环境变量 %s)" % SESSION_ENV)
port = resolve_port(args, target)
sid, occ, why = live_of(port)
if sid != target:
# T2:只投给「live 且就是目标」的会话
die(2, E_NOT_LIVE, "127.0.0.1:%d 的 live=%s,与目标 %s 不符(%s)" % (port, sid, target, why))
print("[OK] 网关 127.0.0.1:%d | live=%s | writerOccupied=%s" % (port, sid, occ))
if args.dry_run:
print("[DRY] 前置全部满足(发现+live 判定通过),**未投递**。")
return 0
if args.text is None:
die(1, "no-text", "缺 --text(或加 --dry-run 只做前置判定)")
code, body = http(port, "POST", "/api/v1/sessions/%s/reply" % target,
body={"text": args.text}, timeout=WRITE_TIMEOUT)
print("[POST] /api/v1/sessions/%s/reply -> HTTP %s" % (target, code))
print(" %s" % body[:300].replace("\n", " "))
if code != 200:
return 4
print("[DONE] 已投递(⛔ 单次、不重试)。目标会话将在空闲边界收到这条消息。")
return 0
if __name__ == "__main__":
sys.exit(main())