- 按用户指示清空原有 25 技能内容,只提交 session-mechanism(57 文件) - 附 .gitignore(产物 + 本机凭据) - 令牌明文已脱敏(历史 .neodata_token 与 pitfalls 引用均不入库) - 本提交为孤儿提交(父提交为空),历史自此重新开始
257 lines
11 KiB
Python
257 lines
11 KiB
Python
# -*- 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())
|