Add Herdr update and live handoff menu actions

The Ctrl+A h command menu gains two host-scoped actions: a plain
`herdr update` that streams updater output into the popup, and an
experimental update-and-hot-restart flow that verifies a live server
handoff and reconnects the originating Hub tab once the popup closes.

Remote updates use the remote host's own proxy settings. A standalone
helper reads literal proxy assignments from shell profiles and running
Clash/Mihomo listeners without executing startup files; Windows reads
process, user and machine proxy variables through PowerShell.
This commit is contained in:
2026-10-03 02:51:44 +00:00
parent a5e8e32f1e
commit 6f933e1c80
10 changed files with 1130 additions and 38 deletions
+52 -1
View File
@@ -110,7 +110,7 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外
### Fork 当前 Agent 到新 Tab
按 **Ctrl+A h** 打开独立的 Herdr 命令菜单,选择“Fork 当前会话到新 Tab”,
按 Enter 或单击执行,Esc 关闭。菜单只显示当前 Agent、Workspace、主操作和必要的状态信息。
按 Enter 或单击执行,Esc 关闭。菜单显示当前 Agent、Workspace、Fork 和更新操作及必要的状态信息。
操作正在执行时需等待结果后再关闭,以免将关闭弹窗误认为取消已经发送的请求。
Sessions 弹窗仍使用 `Ctrl+A g`;两个菜单都使用 Hub 外层的 `Ctrl+A` 前缀。
快捷键集中定义在 `herdr_hub/control.py` 的 `CONTROL_KEY` 常量中。
@@ -124,6 +124,9 @@ Codex 执行 `codex fork UUID`;Claude 执行 `claude --resume SESSION_ID --for
支持本地 default / named session,以及通过 SSH 控制的远端 native / Windows SSH endpoint。
控制目标取自触发菜单的 Hub window metadata,不使用 popup 继承的 Herdr socket 或 pane 环境变量。
目标必须运行 Herdr 0.9+,并安装对应 Agent;当前 pane 必须已报告可用的 Agent session ID。
Fork 显示灰色时,菜单会说明原因;按 `Ctrl+R` 可重新读取当前 Agent 信息。
若提示尚未上报会话 ID,在该主机检查 `herdr integration status`,确认对应 Agent 的集成已加载。
识别出 Codex / Claude 并不代表已经取得 conversation ID;Hub 不会根据目录猜测会话。
远端控制使用非交互 SSH(需要已信任的主机密钥和可用认证);Windows 需要 PowerShell。
启动 blocked、失败、超时或响应不完整时,菜单保留错误信息,**不会自动关闭任何 Tab**。
超时并不代表远端没有创建或启动成功,请先检查提示中的新 Tab,再重新打开菜单重试。
@@ -132,6 +135,54 @@ CLI 入口是 `herdr-hub control`。弹窗打开时会锁定 Hub 当前激活的
再从其 Endpoint 对应的 Herdr server 读取当前页面的 Agent conversation。
更新后运行 `herdr-hub refresh` 配置现有 Hub 的快捷键。`herdr-hub doctor` 会显示入口和版本要求。
### 从命令菜单更新 Herdr
需要让运行中的服务器也使用新版本时,按 **Ctrl+A h → 更新并热重启 Herdr**。
此操作先更新当前 Tab 所在主机的 Herdr,再对当前 Session 执行实验性的 live handoff,
尝试保留 Agent、shell 和其他 pane 进程。它会核对服务器版本;确认使用更新后的版本后,
自动关闭菜单并重新连接打开菜单时的那个 Hub Tab。即使此前已安装新版本、服务器仍是旧版,
也会尝试热切换。该 Session 内的所有 Workspace/Tab 共用服务器,因此都会随之切换。
更新阶段使用 `herdr --session NAME update --handoff`,兼容 0.8.x 在非交互 SSH 下的更新要求。
原生更新已经尝试热切换却未切换成功时,不会再次发送请求;仅在安装文件未变化、旧服务器仍运行时补做热切换。
热切换适用于支持该能力的 Linux/macOS Herdr 服务器;远端还需有 `python3`。
Windows 不支持此入口,可以使用下面的普通更新。失败、超时、服务器不支持或未确认新版本时,
菜单保留诊断,不自动执行普通重启,也不重试热切换。连接中断时操作可能已经完成,请先检查
目标 Session。普通重启会结束 pane 进程;缺少会话 ID 的 Agent 不能保证自动恢复。
按 **Ctrl+A h**,选择“更新 Herdr”,按 Enter 或单击执行。更新目标跟随打开菜单时的
当前 Hub Tab:本地 Session 更新本机,远端 Session 通过 SSH 更新对应主机(包括 Windows)。
菜单会显示目标主机;更新作用于该主机的 Herdr 安装,而非单个 Session。当前 pane 没有
Agent 或无法读取 Agent 信息时,仍可使用更新操作。
该操作执行 `herdr update`,沿用目标主机已有的更新渠道,不传入 `--handoff`。
它适用于 Herdr 自带安装器管理的安装;Homebrew、mise、Nix 等安装需使用对应包管理器更新。
这个普通更新入口不自动重启 Session 或重新连接客户端。完成后可关闭菜单,按 `Ctrl+A R` 重新连接;
服务器端新功能可能仍需另行重启服务器。兼容的服务器会继续运行;旧版本升级若要求停止
Session 或交互确认,本菜单不会代为确认,请根据命令输出在终端处理。
更新在后台执行,实时显示命令输出,最长等待 5 分钟。小窗口自动使用紧凑布局,保证结果区可见。
下载器没有新输出时,菜单显示已执行时间和无新输出时间。
读取 Agent 信息期间也可选择更新。结果留在菜单内,可用 Page Up/Down 翻阅,Esc 关闭。
执行期间需等待结果;同一次菜单内更新只能执行一次,失败或超时也不会自动重试。
超时或 SSH 断开不代表目标主机已取消更新,请检查目标状态后再重新打开菜单。
远端更新使用远端自己的代理。优先保留 SSH 进程已有的 `http_proxy`、`https_proxy`、
`all_proxy`、`no_proxy`(兼容大写);Linux/macOS 还通过远端 `python3` 读取
`/etc/environment`、`/etc/profile`、`/etc/profile.d/*.sh`、`~/.config/environment.d/*.conf`
及用户 profile、bash/zsh 配置中的独立、无缩进的字面量赋值或 `export` 行。
不会执行整个 shell 配置文件,也不解析命令替换、变量引用或条件表达式。
没有上述代理变量时,还会读取远端 Clash Verge、Mihomo/Clash 常见运行配置中的
`mixed-port`、`port` 或 `socks-port`,确认对应回环端口可连接后用于本次更新。
Linux 下会优先从正在运行的 Mihomo/Clash 进程读取 `-f` / `-d` 指定的配置路径,支持 systemd 服务使用自定义目录。
这覆盖了 TUN 模式下没有环境变量的情况,不会修改桌面系统代理开关。
需要认证的应用代理、不在已支持路径中的配置及未运行的端口不会自动采用。
Windows 普通更新会补读远端用户和系统的代理环境变量。
检测结果只显示配置来源,不显示代理地址或凭据;配置仅传给本次更新进程。
没有检测到代理时沿用远端网络和 curl 自身配置,不自动借用本机代理。
“未检测到代理”只是网络方式说明;更新失败时,菜单会先显示末尾的具体错误,再列完整输出。
## 远端 Session 弹窗
弹窗由 tmux `display-popup` 和 Hub 内置选择器组成,不依赖 fzf。它会立即显示已打开的
+11 -1
View File
@@ -1452,7 +1452,17 @@ def command_control(arguments: argparse.Namespace) -> int:
# Endpoint metadata is authoritative; popup environment may belong to another server.
binary = resolve_herdr() if window.endpoint.kind == "local" else "herdr"
control = HerdrControl(window.endpoint, binary, hub.ssh_bin)
return curses.wrapper(ControlMenu(control).run)
menu = ControlMenu(control)
result = curses.wrapper(menu.run)
if menu.reconnect_requested:
# Close curses first; respawning the client must not destroy its popup.
current = next((item for item in hub.managed_windows() if item.window_id == window.window_id), None)
if current is None or current.endpoint != window.endpoint:
raise HubError("服务器已更新,但原 Hub Tab 已改变;请手动连接目标 Session")
hub.reconnect(window.window_id)
hub.wait_for_window_startup(window.window_id)
hub.display_message("Herdr 服务器已更新,当前 Tab 已重新连接")
return result
def command_keys(arguments: argparse.Namespace) -> int:
+244 -35
View File
@@ -1,4 +1,4 @@
"""Herdr command menu and non-destructive conversation forks."""
"""Herdr command menu, updates, and conversation forks."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
@@ -6,7 +6,11 @@ import curses
from dataclasses import dataclass
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import threading
import time
from typing import TYPE_CHECKING, Callable, Sequence
@@ -26,6 +30,7 @@ if TYPE_CHECKING:
CONTROL_KEY = "h"
CONTROL_SHORTCUT = "C-a h"
ROUTING_ENV = (
"HERDR_ENV",
"HERDR_SOCKET_PATH", "HERDR_CLIENT_SOCKET_PATH", "HERDR_SESSION",
"HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID",
"HERDR_ACTIVE_PANE_ID", "HERDR_ACTIVE_TAB_ID", "HERDR_ACTIVE_WORKSPACE_ID",
@@ -40,6 +45,16 @@ class ControlCancelled(RuntimeError):
"""A read-only control request cancelled while closing the popup."""
def update_failure(header: str, output: str) -> str:
"""Put the final diagnostic ahead of setup chatter in small popups."""
lines = [line.strip() for line in output.splitlines() if line.strip()]
diagnostic = next((line for line in reversed(lines) if line.startswith("curl:")), "")
if not diagnostic:
diagnostic = next((line for line in reversed(lines)
if not line.startswith("没有自动执行普通重启")), "无错误输出")
return f"{header}\n{diagnostic}\n\n完整命令输出(可翻页):\n{output}"
def object_field(data: dict, name: str) -> dict:
value = data.get(name)
if not isinstance(value, dict):
@@ -99,7 +114,12 @@ def parse_snapshot(payload: object) -> ForkSource:
raise ControlError("当前 pane 没有运行 Agent")
if kind not in ("codex", "claude"):
raise ControlError(f"暂不支持 Fork {kind};支持 Codex / Claude")
session = object_field(pane, "agent_session")
session = pane.get("agent_session")
if not isinstance(session, dict):
raise ControlError(
f"当前 {kind.capitalize()} 尚未向 Herdr 上报会话 ID,暂时无法 Fork。\n"
f"在目标主机检查 herdr integration status;确认集成已加载后按 Ctrl+R 重读。"
)
if session.get("kind") != "id" or session.get("agent") != kind:
raise ControlError("当前 Agent 尚未报告可 Fork 的 conversation ID")
session_id = string_field(session, "value")
@@ -135,8 +155,53 @@ class HerdrControl:
command: Sequence[str],
timeout: float,
cancel_event: threading.Event | None = None,
progress: Callable[[str], None] | None = None,
) -> subprocess.CompletedProcess:
environment = {k: v for k, v in os.environ.items() if k not in ROUTING_ENV}
if progress is not None:
# A file avoids pipe deadlocks and lets us show output before exit,
# including progress indicators that do not end in a newline.
try:
with tempfile.TemporaryFile() as output:
process = subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, env=environment)
deadline = time.monotonic() + timeout
started = time.monotonic()
last_output = started
last_notice = started
position = 0
captured = b""
try:
while True:
finished = process.poll() is not None
chunk = os.pread(output.fileno(), 65536, position)
if chunk:
position += len(chunk)
captured = (captured + chunk)[-65536:]
progress(decode_remote_output(captured).replace("\r", "\n"))
last_output = time.monotonic()
now = time.monotonic()
if not finished and now - last_notice >= 1:
progress(
decode_remote_output(captured).replace("\r", "\n")
+ f"\n已执行 {int(now - started)} 秒 · {int(now - last_output)} 秒无新输出"
)
last_notice = now
if finished and not chunk:
return subprocess.CompletedProcess(command, process.returncode, captured, b"")
if time.monotonic() >= deadline:
raise ControlError(
f"命令超时({timeout:g}s);操作可能已完成,请检查 Herdr\n"
+ decode_remote_output(captured)
)
if not chunk:
time.sleep(0.1)
finally:
if process.poll() is None:
process.kill()
process.wait()
except OSError as error:
raise ControlError(f"无法执行 Herdr 命令: {error}") from error
if cancel_event is not None and cancel_event.is_set():
raise ControlCancelled()
if cancel_event is not None:
@@ -231,15 +296,7 @@ class HerdrControl:
cancel_event: threading.Event | None = None,
) -> dict:
argv = ["--session", self.endpoint.session, *arguments]
if self.endpoint.kind == "remote":
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
self.endpoint.remote_transport)
command = remote_control_command(self.ssh_bin, entry, argv,
platform=self._remote_platform(entry, cancel_event),
unset_environment=ROUTING_ENV)
else:
command = [self.herdr_bin, *argv]
result = self._execute(command, timeout, cancel_event)
result = self._execute(self._command(argv, cancel_event), timeout, cancel_event)
stdout, stderr = decode_remote_output(result.stdout), decode_remote_output(result.stderr)
if result.returncode != 0:
raise ControlError((stderr or stdout or f"Herdr exited {result.returncode}").strip())
@@ -248,6 +305,85 @@ class HerdrControl:
except json.JSONDecodeError as error:
raise ControlError(f"Herdr 返回无效 JSON: {error}") from error
def _command(
self, argv: Sequence[str], cancel_event: threading.Event | None = None,
) -> list[str]:
if self.endpoint.kind == "remote":
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
self.endpoint.remote_transport)
return remote_control_command(self.ssh_bin, entry, argv,
platform=self._remote_platform(entry, cancel_event),
unset_environment=ROUTING_ENV)
return [self.herdr_bin, *argv]
def update(self, progress: Callable[[str], None] | None = None) -> str:
"""Run the host updater once; its output is text, not control API JSON."""
if progress:
progress("正在连接更新目标…")
command = self._command(["update"])
if self.endpoint.kind == "remote":
command = self._update_proxy(command, progress)
result = self._execute(command, timeout=300, progress=progress)
stdout, stderr = decode_remote_output(result.stdout), decode_remote_output(result.stderr)
output = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
if result.returncode != 0:
raise ControlError(update_failure(f"更新失败(退出码 {result.returncode})", output))
return "更新命令已完成。重新连接可使用更新后的客户端。\n" + output
def _update_proxy(
self, command: list[str], progress: Callable[[str], None] | None,
) -> list[str]:
"""Load proxy settings on the remote host, never from the Hub host."""
if self.platform == "windows":
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
self.endpoint.remote_transport)
return remote_control_command(
self.ssh_bin, entry, ["update"], platform="windows",
unset_environment=ROUTING_ENV, load_proxy_environment=True,
)
if self.platform != "posix":
return command
if progress:
progress("正在检测远端自身的代理配置…")
helper = Path(__file__).with_name("proxy_env.py").read_text(encoding="utf-8")
result = list(command)
result[-1] = (
"if command -v python3 >/dev/null 2>&1; then exec python3 -u -c "
+ shlex.quote(helper) + " " + shlex.quote(command[-1])
+ "; else echo '远端没有 python3,沿用远端现有代理环境及 curl 配置'; "
+ command[-1] + "; fi"
)
return result
def update_and_restart(self, progress: Callable[[str], None]) -> str:
"""Update and verify a live server handoff on the selected POSIX session."""
helper = str(Path(__file__).with_name("restart.py"))
if self.endpoint.kind == "local":
command = [sys.executable, helper, self.herdr_bin, self.endpoint.session]
else:
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
self.endpoint.remote_transport)
progress("正在检查远端热切换支持…")
if self._remote_platform(entry) != "posix":
raise ControlError("Windows 不支持热切换;请使用“更新 Herdr”,再安排普通重启。")
# Reuse the SSH destination/options, but run the standalone helper
# on the host that owns the Unix socket. No socket crosses SSH.
command = remote_control_command(self.ssh_bin, entry, [], platform="posix")
script = Path(helper).read_text(encoding="utf-8")
command[-1] = (
"".join(f"unset {key}; " for key in ROUTING_ENV)
+ 'if [ -x "$HOME/.local/bin/herdr" ]; then hub_herdr="$HOME/.local/bin/herdr"; '
'else hub_herdr=$(command -v herdr) || exit 127; fi; '
+ "exec python3 -u -c " + shlex.quote(script)
+ ' "$hub_herdr" ' + shlex.quote(self.endpoint.session)
)
command = self._update_proxy(command, progress)
result = self._execute(command, timeout=400, progress=progress)
output = decode_remote_output(result.stdout)
if result.returncode != 0:
raise ControlError(update_failure("更新/热切换失败;未自动执行普通重启。", output))
return output
def source(
self,
cancel_event: threading.Event | None = None,
@@ -319,10 +455,13 @@ class ControlMenu:
"Fork 当前会话到新 Tab",
"保留当前 Tab,在同一 Workspace 和工作目录继续",
),
("update", "更新 Herdr", "更新当前主机的 Herdr(使用已配置的更新渠道)"),
("update-restart", "更新并热重启 Herdr", "实验性热切换,保留进程;成功后自动重连当前 Tab"),
)
def __init__(self, control: HerdrControl) -> None:
self.control = control
self.reconnect_requested = False
def run(self, screen) -> int: # type: ignore[no-untyped-def]
import queue
@@ -336,8 +475,13 @@ class ControlMenu:
except (AttributeError, curses.error):
pass
source: ForkSource | None = None
source_error = ""
status = ""
selected = 0
update_attempted = False
pending_update: str | None = None
status_offset = 0
target = "本机" if self.control.endpoint.kind == "local" else self.control.endpoint.target
cancel_loading = threading.Event()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(self.control.source, cancel_loading)
@@ -346,22 +490,46 @@ class ControlMenu:
while not messages.empty():
status = messages.get()
if future is not None and future.done():
# The worker is finished: discard queued progress before
# publishing its final result so stale output cannot replace it.
while not messages.empty():
messages.get()
status_offset = 0
try:
value = future.result()
if phase == "load":
source = value
source_error = ""
status = ""
elif phase == "update":
status = value
elif phase == "update-restart":
self.reconnect_requested = True
return 0
else:
return 0
except ControlCancelled:
return 0
if not pending_update:
return 0
except Exception as error:
status = str(error)
if phase == "load":
source_error = status
if phase == "fork":
source = None # Avoid accidental duplicate forks after ambiguous failure.
source_error = status
future = None
if pending_update and future is None:
phase = pending_update
pending_update = None
update_attempted = True
method = self.control.update if phase == "update" else self.control.update_and_restart
future = executor.submit(method, messages.put)
status = f"正在更新 {target} 的 Herdr…"
status_offset = 0
screen.erase()
height, width = screen.getmaxyx()
compact = height < 16
if source is not None:
context = (
f"当前:{source.kind.capitalize()} · "
@@ -371,11 +539,18 @@ class ControlMenu:
context = "正在读取当前页面…"
else:
context = "无法读取当前页面"
self._add(screen, 1, 2, context, curses.A_BOLD)
action_top = 3
for index, (_, label, detail) in enumerate(self.ACTIONS):
y = action_top + index * 2
enabled = source is not None and future is None
if not compact:
self._add(screen, 1, 2, context, curses.A_BOLD)
heading = context if compact and selected == 0 else f"更新目标:{target}"
self._add(screen, 0 if compact else 2, 2, heading, curses.A_BOLD)
action_top = 1 if compact else 3
action_stride = 1 if compact else 2
for index, (action, label, detail) in enumerate(self.ACTIONS):
y = action_top + index * action_stride
enabled = (
source is not None and future is None if action == "fork"
else not update_attempted and (future is None or phase == "load")
)
attribute = (
curses.A_REVERSE
if enabled and index == selected
@@ -383,21 +558,27 @@ class ControlMenu:
if enabled
else curses.A_DIM
)
if action == "fork" and source is None:
label += "(读取中)" if phase == "load" and future is not None else "(不可用)"
self._add(screen, y, 2, f" {label} ", attribute)
self._add(screen, y + 1, 4, detail, curses.A_DIM)
if not compact:
self._add(screen, y + 1, 4, detail, curses.A_DIM)
status_top = action_top + len(self.ACTIONS) * 2 + 1
for offset, line in enumerate(
display_chunks(status, max(1, width - 5)) if status else []
):
status_top = action_top + len(self.ACTIONS) * action_stride + (0 if compact else 1)
status_lines = display_chunks(status, max(1, width - 5)) if status else []
visible_lines = max(1, height - status_top - 1)
status_offset = min(status_offset, max(0, len(status_lines) - visible_lines))
if future is not None and phase in ("update", "update-restart"):
status_offset = max(0, len(status_lines) - visible_lines)
for offset, line in enumerate(status_lines[status_offset:status_offset + visible_lines]):
self._add(screen, status_top + offset, 2, line, curses.A_DIM)
footer = (
"操作执行中,请等待结果"
if future is not None and phase == "fork"
else "Enter 执行 · Esc 关闭"
if future is not None and phase != "load"
else "Enter 执行 · Ctrl+R 重读 · PgUp/PgDn 结果 · Esc 关闭"
)
self._add(screen, height - 2, 2, footer, curses.A_DIM)
self._add(screen, height - 1, 2, footer, curses.A_DIM)
screen.refresh()
key = screen.getch()
if key == 27:
@@ -406,7 +587,19 @@ class ControlMenu:
return 0
if future is None:
return 0
status = "操作执行中;完成后可关闭(避免误以为已取消 Fork)。"
status = "操作执行中,请等待结果;关闭菜单不会取消已发送的操作。"
elif key == 18 and future is None:
source = None
source_error = ""
cancel_loading = threading.Event()
future = executor.submit(self.control.source, cancel_loading)
phase = "load"
status = "正在重新读取当前页面…"
status_offset = 0
elif key in (curses.KEY_PPAGE, curses.KEY_NPAGE):
status_offset = max(0, status_offset + (
visible_lines if key == curses.KEY_NPAGE else -visible_lines
))
elif key in (curses.KEY_UP, curses.KEY_DOWN):
selected = (selected + (1 if key == curses.KEY_DOWN else -1)) % len(self.ACTIONS)
else:
@@ -414,14 +607,10 @@ class ControlMenu:
if key == curses.KEY_MOUSE:
try:
_, x, y, _, state = curses.getmouse()
action_index = (y - action_top) // 2
action_index = (y - action_top) // action_stride
execute = (
1 <= x < width - 1
and 0 <= action_index < len(self.ACTIONS)
and y in {
action_top + action_index * 2,
action_top + action_index * 2 + 1,
}
and bool(
state
& (
@@ -434,10 +623,30 @@ class ControlMenu:
selected = action_index
except curses.error:
execute = False
if execute and future is None and source is not None:
phase = "fork"
future = executor.submit(self.control.fork, source, messages.put)
status = "正在 Fork…"
if execute:
action = self.ACTIONS[selected][0]
if action in ("update", "update-restart") and not update_attempted and phase == "load" and future is not None:
pending_update = action
cancel_loading.set()
status = "正在准备更新…"
elif action == "fork" and source is not None and future is None:
phase = "fork"
future = executor.submit(self.control.fork, source, messages.put)
status = "正在 Fork…"
status_offset = 0
elif action == "fork":
if future is None:
status = source_error or "尚未读取 Fork 会话信息,请按 Ctrl+R 重读。"
status_offset = 0
elif phase == "load":
status = "正在读取 Fork 会话信息,请稍候再执行。"
elif action in ("update", "update-restart") and not update_attempted and future is None:
phase = action
update_attempted = True
method = self.control.update if action == "update" else self.control.update_and_restart
future = executor.submit(method, messages.put)
status = f"正在更新 {target} 的 Herdr,请等待…"
status_offset = 0
@staticmethod
def _add(
+184
View File
@@ -0,0 +1,184 @@
"""Read literal remote proxy settings without executing shell startup files.
This standalone helper runs on the remote host; proxy values never leave it.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
import shlex
import socket
import sys
PROXY_KEYS = {"http_proxy", "https_proxy", "all_proxy", "no_proxy"}
def proxy_assignments(text: str) -> dict[str, str]:
values = {}
for line in text.splitlines():
# Only standalone, unindented literal assignments. Do not evaluate
# functions, shell commands, substitutions, or conditional expressions.
if not re.match(r"^(?:export\s+)?(?:https?_proxy|all_proxy|no_proxy)=", line, re.I):
continue
if any(marker in line for marker in ("$", "`", ";", "&&", "||")):
continue
try:
words = shlex.split(line, comments=True)
except ValueError:
continue
if words and words[0] == "export":
words = words[1:]
assignments = [word.split("=", 1) for word in words]
if not assignments or any(len(item) != 2 or item[0].lower() not in PROXY_KEYS for item in assignments):
continue
for key, value in assignments:
if "\0" not in value:
values[key.lower()] = value
return values
def config_paths(home: Path, shell: str) -> list[Path]:
paths = [Path("/etc/environment"), Path("/etc/profile")]
paths.extend(sorted(Path("/etc/profile.d").glob("*.sh")))
paths.extend(sorted((home / ".config/environment.d").glob("*.conf")))
paths.append(home / ".profile")
if Path(shell).name in ("zsh",):
paths.extend([home / ".zshenv", home / ".zprofile", home / ".zshrc"])
else:
paths.extend([home / ".bash_profile", home / ".bash_login", home / ".bashrc"])
return paths
def configure_proxy(environment: dict[str, str], paths: list[Path]) -> list[str]:
# An existing remote SSH environment (even an explicit empty value) wins.
inherited = {key.lower() for key in environment if key.lower() in PROXY_KEYS}
loaded = {}
sources = []
for path in paths:
try:
values = proxy_assignments(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError):
continue
values = {key: value for key, value in values.items() if key not in inherited}
if values:
loaded.update(values)
sources.append(str(path))
environment.update(loaded)
# curl ignores uppercase HTTP_PROXY; normalize remote values, preserving
# explicit lowercase precedence and NO_PROXY exclusions.
for key in PROXY_KEYS:
if key not in environment and key.upper() in environment:
environment[key] = environment[key.upper()]
return sources
def application_proxy_paths(home: Path) -> list[Path]:
"""Known runtime config locations; never inspect downloaded subscriptions."""
return running_proxy_paths() + [
home / ".local/share/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml",
home / ".config/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml",
home / ".config/clash-verge/clash-verge.yaml",
home / ".config/mihomo/config.yaml",
home / ".config/clash/config.yaml",
]
def running_proxy_paths(proc_root: Path = Path("/proc")) -> list[Path]:
"""Find the config explicitly used by a running Clash/Mihomo core."""
paths = []
try:
processes = list(proc_root.iterdir())
except OSError:
return paths
for process in processes:
if not process.name.isdigit():
continue
try:
arguments = (process / "cmdline").read_bytes().decode().rstrip("\0").split("\0")
except (OSError, UnicodeError):
continue
if not arguments or not re.fullmatch(r"(?:mihomo|clash)(?:[-.][A-Za-z0-9_.-]+)?", Path(arguments[0]).name):
continue
config = None
directory = None
for index, argument in enumerate(arguments[1:], 1):
flag, separator, value = argument.partition("=")
if not separator and index + 1 < len(arguments):
value = arguments[index + 1]
if flag in ("-f", "--config") and value:
config = Path(value)
elif flag in ("-d", "--config-dir") and value:
directory = Path(value)
if config is None and directory is not None:
config = directory / "config.yaml"
if config is None:
continue
if not config.is_absolute():
try:
config = (process / "cwd").resolve(strict=True) / config
except OSError:
continue
if config not in paths:
paths.append(config)
return paths
def configure_application_proxy(environment: dict[str, str], paths: list[Path]) -> str | None:
# An explicit environment setting (including an empty/disabled value)
# takes precedence over a proxy application's independent listener.
if any(key.lower() in {"http_proxy", "https_proxy", "all_proxy"} for key in environment):
return None
for path in paths:
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
# This deliberately reads only simple top-level listener settings,
# not arbitrary YAML, provider credentials or subscription profiles.
fields = dict(re.findall(
r"^(mixed-port|port|socks-port|bind-address|authentication):[ \t]*(.*)$",
text, re.M,
))
if "authentication" in fields and fields["authentication"].strip() != "[]":
continue
try:
bind = shlex.split(fields.get("bind-address", "'*'"), comments=True)
except ValueError:
continue
if len(bind) != 1 or bind[0] not in ("*", "0.0.0.0", "127.0.0.1", "::", "::1", "localhost"):
continue
host = "::1" if bind[0] in ("::", "::1") else "127.0.0.1"
for key, scheme in (("mixed-port", "http"), ("port", "http"), ("socks-port", "socks5h")):
match = re.fullmatch(r"[\"']?(\d+)[\"']?\s*(?:#.*)?", fields.get(key, ""))
if not match:
continue
port = int(match[1])
if not 1 <= port <= 65535:
continue
try:
with socket.create_connection((host, port), timeout=1):
pass
except OSError:
continue
address = f"[{host}]" if host == "::1" else host
proxy = f"{scheme}://{address}:{port}"
environment.update(http_proxy=proxy, https_proxy=proxy, all_proxy=proxy)
return str(path)
return None
if __name__ == "__main__":
environment = dict(os.environ)
sources = configure_proxy(environment, config_paths(Path.home(), environment.get("SHELL", "bash")))
application_source = configure_application_proxy(environment, application_proxy_paths(Path.home()))
if application_source:
sources.append(application_source + "(应用代理端口可连接)")
active = any(environment.get(key) for key in ("https_proxy", "all_proxy", "http_proxy"))
if active:
source = "、".join(sources) if sources else "SSH 环境变量"
print("使用远端自身代理,配置来源:" + source, flush=True)
else:
print("网络方式:远端直连(未发现可用代理配置;此行不是错误)。", flush=True)
os.execve("/bin/sh", ["sh", "-c", sys.argv[1]], environment)
+15 -1
View File
@@ -717,6 +717,7 @@ def remote_control_command(
*,
platform: str,
unset_environment: Sequence[str] = (),
load_proxy_environment: bool = False,
) -> list[str]:
"""Build one non-replayed mutation with platform-specific argv encoding.
@@ -746,8 +747,21 @@ def remote_control_command(
return "'" + value.replace("'", "''") + "'"
unset = "".join(f"Remove-Item Env:{key} -ErrorAction SilentlyContinue; " for key in unset_environment)
proxy_setup = ""
if load_proxy_environment:
proxy_setup = (
"foreach ($key in @('http_proxy','https_proxy','all_proxy','no_proxy')) { "
"$value=[Environment]::GetEnvironmentVariable($key,'Process'); "
"if ([string]::IsNullOrEmpty($value)) { "
"$value=[Environment]::GetEnvironmentVariable($key,'User'); "
"if ([string]::IsNullOrEmpty($value)) { $value=[Environment]::GetEnvironmentVariable($key,'Machine') }; "
"if (![string]::IsNullOrEmpty($value)) { [Environment]::SetEnvironmentVariable($key,$value,'Process') } } }; "
"if ($env:https_proxy -or $env:http_proxy -or $env:all_proxy) { "
"Write-Output 'Using remote proxy environment (process/user/machine).' "
"} else { Write-Output 'No remote proxy environment found; using remote network defaults.' }; "
)
script_text = (
"$ErrorActionPreference='Stop'; " + unset
"$ErrorActionPreference='Stop'; " + unset + proxy_setup
+ "$exe=Join-Path $HOME '.local\\bin\\herdr.exe'; "
"if (!(Test-Path -LiteralPath $exe)) { $exe=(Get-Command herdr -ErrorAction Stop).Source }; "
"$p=New-Object System.Diagnostics.Process; "
+97
View File
@@ -0,0 +1,97 @@
"""Standalone POSIX update/handoff helper, also sent to remote Python over SSH."""
from __future__ import annotations
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
import uuid
def request(socket_path: str, method: str, params: dict, timeout: float = 10) -> dict:
request_id = uuid.uuid4().hex
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(timeout)
connection.connect(socket_path)
connection.sendall((json.dumps({"id": request_id, "method": method, "params": params}) + "\n").encode())
with connection.makefile("rb") as stream:
raw = stream.readline(1024 * 1024)
if not raw.endswith(b"\n"):
raise RuntimeError("Herdr 控制响应不完整;请检查 Session 状态后再重试")
response = json.loads(raw)
if not isinstance(response, dict) or response.get("id") != request_id:
raise RuntimeError("Herdr 控制响应 ID 不匹配")
if response.get("error"):
raise RuntimeError(json.dumps(response["error"], ensure_ascii=False))
result = response.get("result")
if not isinstance(result, dict):
raise RuntimeError("Herdr 控制响应缺少结果")
return result
def installed_version(binary: str) -> str:
version = subprocess.check_output([binary, "--version"], text=True, timeout=10).strip()
if not version.startswith("herdr ") or not version[6:].strip():
raise RuntimeError("无法识别已安装的 Herdr 版本:" + version)
return version[6:].strip()
def binary_identity(binary: str) -> tuple[int, int, int, int]:
info = os.stat(binary)
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns
def update_and_handoff(binary: str, session: str) -> None:
binary = str(Path(binary).absolute())
status = subprocess.check_output(
[binary, "--session", session, "status", "server"], text=True, timeout=10,
)
fields = dict(line.split(": ", 1) for line in status.splitlines() if ": " in line)
socket_path = fields.get("socket", "")
if fields.get("status") != "running" or not os.path.isabs(socket_path):
raise RuntimeError("当前 Session 没有可用的运行中服务器,不能执行热切换")
before = request(socket_path, "ping", {})
if before.get("capabilities", {}).get("live_handoff") is not True:
raise RuntimeError("当前服务器不支持热切换;可先使用“更新 Herdr”,再安排普通重启")
print(f"更新目标 Session:{session};当前服务器:{before.get('version', '未知')}", flush=True)
identity_before = binary_identity(binary)
# 0.8.x plain update refuses to complete with live sessions and no TTY.
# Let the native updater perform its opt-in handoff, scoped to this session.
subprocess.run([binary, "--session", session, "update", "--handoff"], check=True, timeout=300)
version = installed_version(binary)
current = request(socket_path, "ping", {})
if current.get("version") != version:
if binary_identity(binary) != identity_before:
raise RuntimeError(
f"已安装 {version},但原生热切换后服务器仍是 {current.get('version', '未知')};"
"不会重复发送热切换请求或强制重启,请检查上方原生命令输出"
)
print(f"正在热切换服务器到 {version},保留运行中的进程…", flush=True)
# An unchanged binary means the updater may have returned 'up to date'
# before touching an older running server. Only then send a handoff.
request(socket_path, "server.live_handoff", {
"import_exe": str(Path(binary).resolve()), "expected_version": version,
}, timeout=40)
deadline = time.monotonic() + 20
while True:
try:
current = request(socket_path, "ping", {}, timeout=2)
if current.get("version") == version:
break
except (OSError, ValueError, RuntimeError):
pass
if time.monotonic() >= deadline:
raise RuntimeError("热切换后未确认新服务器就绪;请检查状态,不会自动停止 Session")
time.sleep(.2)
print(f"服务器已确认使用 {version},可以重新连接。", flush=True)
if __name__ == "__main__":
try:
update_and_handoff(sys.argv[1], sys.argv[2])
except Exception as error:
print(f"更新/热切换未完成:{error}\n没有自动执行普通重启;请检查当前 Session。", file=sys.stderr, flush=True)
raise SystemExit(1)
+227
View File
@@ -6,6 +6,9 @@ import curses
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import threading
import time
import unittest
@@ -18,6 +21,7 @@ from herdr_hub.control import (
ControlMenu,
HerdrControl,
parse_snapshot,
update_failure,
)
from herdr_hub.remotes import RemoteEntry, remote_control_command
@@ -41,6 +45,13 @@ def completed(payload, code=0):
class SnapshotTests(unittest.TestCase):
def test_missing_session_reports_identity_problem_not_version(self):
payload = snapshot()
del payload["result"]["snapshot"]["panes"][0]["agent_session"]
with self.assertRaisesRegex(ControlError, "尚未向 Herdr 上报会话 ID") as caught:
parse_snapshot(payload)
self.assertNotIn("需要 Herdr 0.9", str(caught.exception))
def test_focused_pane_and_exact_native_arguments(self):
for kind, args in (("codex", ["fork", SESSION_ID]),
("claude", ["--resume", SESSION_ID, "--fork-session"])):
@@ -68,6 +79,99 @@ class SnapshotTests(unittest.TestCase):
class ControlTests(unittest.TestCase):
def test_windows_hot_restart_rejected_before_update(self):
control = HerdrControl(Endpoint.remote("windows-host", remote_transport="ssh"), "unused")
control.platform = "windows"
with patch.object(control, "_execute") as execute:
with self.assertRaisesRegex(ControlError, "Windows 不支持热切换"):
control.update_and_restart(lambda _: None)
execute.assert_not_called()
def test_remote_hot_restart_runs_helper_on_exact_host(self):
control = HerdrControl(Endpoint.remote("ssh://me@host:2222", "named"), "unused", "fake-ssh")
control.platform = "posix"
with patch.object(control, "_execute", return_value=subprocess.CompletedProcess([], 0, b"ok", b"")) as execute:
control.update_and_restart(lambda _: None)
command = execute.call_args.args[0]
self.assertEqual(command[0], "fake-ssh")
self.assertIn("host", command)
self.assertIn("2222", command)
self.assertIn("exec python3 -u -c", command[-1])
self.assertIn(' "$hub_herdr" named', command[-1])
self.assertIn("unset HERDR_ENV;", command[-1])
def test_streaming_shows_unterminated_output_before_process_exits(self):
with tempfile.TemporaryDirectory() as directory:
marker = Path(directory) / "seen"
script = (
"import os, sys, time\n"
"from pathlib import Path\n"
"os.write(2, b'downloading 10%')\n"
"deadline = time.monotonic() + 3\n"
"while not Path(sys.argv[1]).exists():\n"
" if time.monotonic() > deadline: sys.exit(9)\n"
" time.sleep(.01)\n"
"print(' done')\n"
)
updates = []
def progress(value):
updates.append(value)
marker.touch()
result = HerdrControl(Endpoint.local(), "unused")._execute(
[sys.executable, "-c", script, str(marker)], 5, progress=progress
)
self.assertEqual(result.returncode, 0)
self.assertIn("downloading 10%", updates[0])
self.assertIn(b"done", result.stdout)
def test_streaming_timeout_preserves_diagnostic(self):
with self.assertRaisesRegex(ControlError, "waiting for network"):
HerdrControl(Endpoint.local(), "unused")._execute(
[sys.executable, "-c", "import os,time; os.write(2,b'waiting for network'); time.sleep(10)"],
.3, progress=lambda _: None,
)
def test_update_local_is_host_scoped_and_accepts_plain_text(self):
result = subprocess.CompletedProcess([], 0, b"Already up to date", b"notice")
with patch.dict(os.environ, {"HERDR_SESSION": "wrong"}), patch(
"herdr_hub.control.subprocess.run", return_value=result
) as run:
output = HerdrControl(Endpoint.local("named"), "/fake/herdr").update()
self.assertIn("Already up to date", output)
self.assertIn("notice", output)
self.assertEqual(run.call_args.args[0], ["/fake/herdr", "update"])
self.assertEqual(run.call_args.kwargs["timeout"], 300)
self.assertEqual(run.call_args.kwargs["stdin"], subprocess.DEVNULL)
self.assertNotIn("HERDR_SESSION", run.call_args.kwargs["env"])
def test_update_remote_routes_once_on_both_platforms(self):
for transport, path in (("native", "/home/me/sessions"),
("ssh", "C:\\Users\\me\\sessions")):
listing = {"sessions": [{"name": "named", "running": True, "session_dir": path}]}
failure = subprocess.CompletedProcess([], 1, b"download started", b"network error")
with self.subTest(transport=transport), patch(
"herdr_hub.control.subprocess.run", side_effect=[completed(listing), failure]
) as run:
control = HerdrControl(Endpoint.remote("host", "named", remote_transport=transport), "unused", "fake-ssh")
with self.assertRaisesRegex(ControlError, "network error"):
control.update()
self.assertEqual(run.call_count, 2)
command = run.call_args.args[0]
self.assertEqual(command[0], "fake-ssh")
self.assertIn("host", command)
script = command[-1]
if transport == "ssh":
script = base64.b64decode(script.split()[-1]).decode("utf-16le")
self.assertIn("update", script)
self.assertNotIn("--session", script)
self.assertNotIn("--handoff", script)
def test_update_timeout_is_not_replayed(self):
with patch("herdr_hub.control.subprocess.run", side_effect=subprocess.TimeoutExpired("herdr", 300)) as run:
with self.assertRaisesRegex(ControlError, "操作可能已完成"):
HerdrControl(Endpoint.local(), "herdr").update()
run.assert_called_once()
def test_local_explicit_session_and_clean_environment(self):
for session in ("default", "named"):
with self.subTest(session=session), patch.dict(os.environ, {
@@ -234,6 +338,35 @@ class ImmediateExecutor:
class MenuTests(unittest.TestCase):
def test_small_popup_shows_download_failure_before_proxy_notice(self):
self.screen.getmaxyx.return_value = (8, 71)
self.control.update_and_restart.side_effect = ControlError(update_failure(
"更新失败", "未检测到代理\n正在下载\ncurl: (28) Connection timed out\n没有自动执行普通重启;请检查当前 Session。"
))
self.run_menu([curses.KEY_DOWN, curses.KEY_DOWN, 10, 27])
rendered = str(self.screen.addstr.call_args_list)
self.assertIn("curl: (28) Connection timed out", rendered)
self.assertNotIn("未检测到代理", rendered)
def test_hot_restart_success_requests_reconnect(self):
self.control.update_and_restart.return_value = "verified"
self.screen.getch.side_effect = [curses.KEY_DOWN, curses.KEY_DOWN, 10]
menu = ControlMenu(self.control)
with patch("herdr_hub.control.ThreadPoolExecutor", ImmediateExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
self.assertEqual(menu.run(self.screen), 0)
self.assertTrue(menu.reconnect_requested)
self.control.update_and_restart.assert_called_once()
def test_hot_restart_failure_stays_visible_without_reconnect(self):
self.control.update_and_restart.side_effect = ControlError("handoff failed")
self.screen.getch.side_effect = [curses.KEY_DOWN, curses.KEY_DOWN, 10, 10, 27]
menu = ControlMenu(self.control)
with patch("herdr_hub.control.ThreadPoolExecutor", ImmediateExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
self.assertEqual(menu.run(self.screen), 0)
self.assertFalse(menu.reconnect_requested)
self.control.update_and_restart.assert_called_once()
self.assertIn("handoff failed", str(self.screen.addstr.call_args_list))
def setUp(self):
self.control = Mock(endpoint=Endpoint.local())
self.control.source.return_value = parse_snapshot(snapshot())
@@ -268,8 +401,102 @@ class MenuTests(unittest.TestCase):
self.run_menu([10, 27])
self.control.fork.assert_not_called()
def test_refresh_restores_fork_after_session_identity_becomes_available(self):
self.screen.getmaxyx.return_value = (8, 71)
self.control.source.side_effect = [ControlError("尚未上报会话 ID"), parse_snapshot(snapshot())]
self.run_menu([10, 18, 10])
self.assertEqual(self.control.source.call_count, 2)
self.control.fork.assert_called_once()
self.assertIn("不可用", str(self.screen.addstr.call_args_list))
def test_compact_mouse_fork_still_executes(self):
self.screen.getmaxyx.return_value = (8, 71)
with patch("curses.getmouse", return_value=(0, 3, 1, 0, curses.BUTTON1_PRESSED)):
self.run_menu([curses.KEY_MOUSE])
self.control.fork.assert_called_once()
self.control.update.assert_not_called()
def test_update_without_agent_keeps_result_and_prevents_repeat(self):
self.control.source.side_effect = ControlError("没有 Agent")
self.control.update.return_value = "更新成功"
self.run_menu([curses.KEY_DOWN, 10, 10, 27])
self.control.update.assert_called_once()
self.control.fork.assert_not_called()
rendered = str(self.screen.addstr.call_args_list)
self.assertIn("更新成功", rendered)
self.assertIn("更新目标:本机", rendered)
def test_click_update_reports_remote_target_and_failure(self):
self.control.endpoint = Endpoint.remote("my-host")
self.control.update.side_effect = ControlError("更新失败:连接断开")
with patch("curses.getmouse", return_value=(0, 3, 5, 0, curses.BUTTON1_PRESSED)):
self.run_menu([curses.KEY_MOUSE, 10, 27])
self.control.update.assert_called_once()
rendered = str(self.screen.addstr.call_args_list)
self.assertIn("更新目标:my-host", rendered)
self.assertIn("更新失败:连接断开", rendered)
def test_update_output_can_be_scrolled(self):
self.control.update.return_value = "\n".join(f"line-{i}" for i in range(60))
self.run_menu([curses.KEY_DOWN, 10, curses.KEY_NPAGE, curses.KEY_PPAGE, 27])
self.assertIn("line-20", str(self.screen.addstr.call_args_list))
def test_update_result_visible_in_actual_small_popup(self):
self.screen.getmaxyx.return_value = (8, 71)
self.control.update.return_value = "更新命令已完成\nAlready up to date"
self.run_menu([curses.KEY_DOWN, 10, 27])
calls = self.screen.addstr.call_args_list
results = [call.args for call in calls if "Already up to date" in call.args[2]]
self.assertTrue(results, "结果必须在真实的 8 行弹窗内绘制")
self.assertTrue(all(3 <= args[0] < 7 for args in results))
self.assertTrue(all(0 <= call.args[0] < 8 for call in calls))
def test_compact_mouse_update_row(self):
self.screen.getmaxyx.return_value = (8, 71)
self.control.update.return_value = "完成"
with patch("curses.getmouse", return_value=(0, 3, 2, 0, curses.BUTTON1_PRESSED)):
self.run_menu([curses.KEY_MOUSE, 27])
self.control.update.assert_called_once()
self.control.fork.assert_not_called()
def test_update_during_snapshot_load_is_not_ignored(self):
cancel = None
class LoadingFuture:
def done(self):
return cancel.is_set()
def result(self):
raise ControlCancelled()
class LoadingExecutor(ImmediateExecutor):
def submit(inner, fn, *args):
nonlocal cancel
if fn == self.control.source:
cancel = args[0]
return LoadingFuture()
return ImmediateFuture(fn, *args)
self.control.update.return_value = "完成"
self.screen.getch.side_effect = [curses.KEY_DOWN, 10, -1, 27]
with patch("herdr_hub.control.ThreadPoolExecutor", LoadingExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
ControlMenu(self.control).run(self.screen)
self.control.update.assert_called_once()
class CliControlTests(unittest.TestCase):
def test_hot_restart_reconnects_original_window_after_popup_closes(self):
args = build_parser().parse_args(["control"])
endpoint = Endpoint.local()
original = ManagedWindow("@17", endpoint.key, "Local", False, True, endpoint)
other = ManagedWindow("@18", "local:other", "Other", False, True, Endpoint.local("other"))
hub = Mock(ssh_bin="fake-ssh")
hub.managed_windows.side_effect = [[original], [original, other]]
def close_popup(run):
hub.reconnect.assert_not_called()
run.__self__.reconnect_requested = True
return 0
with patch("herdr_hub.cli.make_hub", return_value=hub), patch("herdr_hub.cli.curses.wrapper", side_effect=close_popup):
self.assertEqual(command_control(args), 0)
hub.reconnect.assert_called_once_with("@17")
hub.wait_for_window_startup.assert_called_once_with("@17")
def test_active_window_metadata_selects_control_endpoint(self):
args = build_parser().parse_args(["control"])
hub = Mock(ssh_bin="fake-ssh")
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import base64
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from herdr_hub.cli import Endpoint
from herdr_hub.control import HerdrControl
from herdr_hub.proxy_env import configure_application_proxy, configure_proxy, proxy_assignments, running_proxy_paths
class RemoteProxyTests(unittest.TestCase):
def test_running_mihomo_custom_config_is_discovered(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
core = root / "1277"
core.mkdir()
(core / "cmdline").write_bytes(
b"/opt/mihomo-linux-arm64\0-d\0/home/orangepi/repo/clash\0-f\0/home/orangepi/repo/clash/config-systemd.yaml\0"
)
unrelated = root / "1278"
unrelated.mkdir()
(unrelated / "cmdline").write_bytes(b"python3\0-c\0mihomo\0-f\0/irrelevant.yaml\0")
self.assertEqual(running_proxy_paths(root), [Path("/home/orangepi/repo/clash/config-systemd.yaml")])
def test_running_clash_listener_used_when_shell_has_no_proxy(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "clash-verge.yaml"
path.write_text("mixed-port: 7897\nbind-address: '*'\nallow-lan: false\n")
environment = {"no_proxy": "localhost,internal"}
with patch("herdr_hub.proxy_env.socket.create_connection") as connect:
source = configure_application_proxy(environment, [path])
self.assertEqual(source, str(path))
self.assertEqual(environment["https_proxy"], "http://127.0.0.1:7897")
self.assertEqual(environment["no_proxy"], "localhost,internal")
connect.assert_called_once_with(("127.0.0.1", 7897), timeout=1)
def test_stopped_application_and_authenticated_proxy_not_adopted(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "config.yaml"
for contents in ("mixed-port: 7897\n", "mixed-port: 7897\nauthentication:\n - user:secret\n"):
path.write_text(contents)
environment = {}
with patch("herdr_hub.proxy_env.socket.create_connection", side_effect=ConnectionRefusedError):
self.assertIsNone(configure_application_proxy(environment, [path]))
self.assertEqual(environment, {})
def test_application_detection_respects_existing_proxy_and_disable(self):
for value in ("", "http://configured:8080"):
environment = {"HTTPS_PROXY": value}
with patch("herdr_hub.proxy_env.socket.create_connection") as connect:
self.assertIsNone(configure_application_proxy(environment, [Path("/unused")]))
connect.assert_not_called()
self.assertEqual(environment, {"HTTPS_PROXY": value})
def test_literal_exports_and_uppercase_are_read_without_shell_evaluation(self):
values = proxy_assignments('''
export https_proxy="http://remote-user:secret@127.0.0.1:7890" # local to remote
HTTP_PROXY=http://remote-host:8080
export no_proxy='localhost,127.0.0.1'
export all_proxy=$(dangerous_command)
export all_proxy=http://other:8080; dangerous_command
export all_proxy=http://inside-function:8080
''')
self.assertEqual(values, {
"https_proxy": "http://remote-user:secret@127.0.0.1:7890",
"http_proxy": "http://remote-host:8080",
"no_proxy": "localhost,127.0.0.1",
})
def test_remote_environment_wins_including_explicit_disable(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".bashrc"
path.write_text("export https_proxy=http://file:7890\nexport all_proxy=http://fallback:7890\n")
environment = {"HTTPS_PROXY": "http://remote-env:7890", "all_proxy": ""}
configure_proxy(environment, [path])
self.assertEqual(environment["https_proxy"], "http://remote-env:7890")
self.assertEqual(environment["all_proxy"], "")
def test_wrapper_loads_remote_bashrc_and_never_embeds_local_proxy(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
marker = root / "should-not-exist"
remote_proxy = "http://remote-user:remote-secret@127.0.0.1:7891"
(root / ".bashrc").write_text(
"case $- in *i*) ;; *) return;; esac\n"
+ f"export https_proxy={shlex.quote(remote_proxy)}\n"
+ f"touch {shlex.quote(str(marker))}\n"
)
controller = HerdrControl(Endpoint.remote("host"), "unused")
controller.platform = "posix"
child = shlex.join([sys.executable, "-c", "import os; assert os.environ['https_proxy'] == " + repr(remote_proxy)])
with patch.dict(os.environ, {"https_proxy": "http://local-secret@127.0.0.1:9999"}):
command = controller._update_proxy(["ssh", "host", child], None)
self.assertEqual(command[:-1], ["ssh", "host"])
self.assertNotIn("local-secret", command[-1])
environment = {k: v for k, v in os.environ.items() if k.lower() not in {
"http_proxy", "https_proxy", "all_proxy", "no_proxy",
}}
environment.update(HOME=directory, SHELL="/bin/bash")
result = subprocess.run(["sh", "-c", command[-1]], env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("使用远端自身代理", result.stdout)
self.assertNotIn("remote-secret", result.stdout + result.stderr)
self.assertFalse(marker.exists())
def test_windows_loads_only_remote_environment_scopes(self):
controller = HerdrControl(Endpoint.remote("windows-host"), "unused")
controller.platform = "windows"
with patch.dict(os.environ, {"https_proxy": "http://local-secret@127.0.0.1:9999"}):
command = controller._update_proxy(["ssh", "windows-host", "unused"], None)
script = base64.b64decode(command[-1].split()[-1]).decode("utf-16le")
for scope in ("Process", "User", "Machine"):
self.assertIn(f"GetEnvironmentVariable($key,'{scope}')", script)
self.assertNotIn("local-secret", script)
self.assertNotIn("-R", command)
if __name__ == "__main__":
unittest.main()
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
import contextlib
import io
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
from herdr_hub.cli import Endpoint
from herdr_hub.control import ControlError, HerdrControl
from herdr_hub.restart import update_and_handoff
class HandoffTests(unittest.TestCase):
def setUp(self):
identity = patch("herdr_hub.restart.binary_identity", return_value=(1, 1, 1, 1))
identity.start()
self.addCleanup(identity.stop)
def test_failed_update_never_sends_handoff(self):
with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch(
"herdr_hub.restart.request", return_value={"capabilities": {"live_handoff": True}}
) as call, patch("herdr_hub.restart.subprocess.run", side_effect=subprocess.CalledProcessError(1, "update")), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(subprocess.CalledProcessError):
update_and_handoff("/fake/herdr", "named")
self.assertEqual([c.args[1] for c in call.call_args_list], ["ping"])
def test_native_082_update_uses_handoff_and_does_not_repeat_it(self):
old = {"version": "0.8.2", "capabilities": {"live_handoff": True}}
with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch(
"herdr_hub.restart.request", side_effect=[old, {"version": "0.9.1"}]
) as call, patch("herdr_hub.restart.subprocess.run") as run, patch(
"herdr_hub.restart.installed_version", return_value="0.9.1"
), contextlib.redirect_stdout(io.StringIO()):
update_and_handoff("/fake/herdr", "named")
self.assertEqual(run.call_args.args[0], ["/fake/herdr", "--session", "named", "update", "--handoff"])
self.assertEqual([c.args[1] for c in call.call_args_list], ["ping", "ping"])
def test_native_handoff_failure_after_install_is_not_replayed(self):
old = {"version": "0.8.2", "capabilities": {"live_handoff": True}}
with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch(
"herdr_hub.restart.request", side_effect=[old, old]
) as call, patch("herdr_hub.restart.subprocess.run"), patch(
"herdr_hub.restart.installed_version", return_value="0.9.1"
), patch("herdr_hub.restart.binary_identity", side_effect=[(1, 1, 1, 1), (1, 2, 1, 1)]), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(RuntimeError, "不会重复发送热切换"):
update_and_handoff("/fake/herdr", "named")
self.assertEqual([c.args[1] for c in call.call_args_list], ["ping", "ping"])
def test_unverified_handoff_is_failure_not_success(self):
old = {"version": "0.9.0", "capabilities": {"live_handoff": True}}
with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch(
"herdr_hub.restart.request", side_effect=[old, old, {}, old]
) as call, patch("herdr_hub.restart.subprocess.run"), patch(
"herdr_hub.restart.installed_version", return_value="0.9.1"
), patch("herdr_hub.restart.time.monotonic", side_effect=[0, 21]), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(RuntimeError, "未确认新服务器就绪"):
update_and_handoff("/fake/herdr", "named")
self.assertEqual([c.args[1] for c in call.call_args_list].count("server.live_handoff"), 1)
def test_real_helper_updates_old_server_even_when_binary_already_latest(self):
self._run_real_helper()
def test_real_helper_failure_does_not_stop_or_retry_server(self):
self._run_real_helper(fail_handoff=True)
def _run_real_helper(self, fail_handoff=False):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
socket_path = root / "api.sock"
record = root / "commands.jsonl"
binary = root / "herdr"
binary.write_text(
f"#!{sys.executable}\n"
"import json, os, sys\n"
f"with open({str(record)!r}, 'a') as f: f.write(json.dumps(sys.argv[1:])+'\\n')\n"
"if os.environ.get('HERDR_ENV') == '1': sys.exit(8)\n"
"if sys.argv[1:] == ['--version']: print('herdr 0.9.1')\n"
"elif sys.argv[1:] == ['--session', 'named', 'status', 'server']:\n"
f" print('status: running\\nsocket: {socket_path}')\n"
"elif sys.argv[1:] == ['--session', 'named', 'update', '--handoff']:\n"
" print('already up to date (0.9.1)', flush=True)\n"
"else: sys.exit(9)\n"
)
binary.chmod(0o755)
methods = []
done = threading.Event()
errors = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(socket_path))
server.listen()
server.settimeout(.1)
def serve():
version = "0.9.0"
try:
while not done.is_set():
try:
connection, _ = server.accept()
except socket.timeout:
continue
with connection, connection.makefile("rb") as stream:
message = json.loads(stream.readline())
methods.append(message)
response = {"id": message["id"]}
if message["method"] == "server.live_handoff":
if fail_handoff:
response["error"] = {"message": "handoff refused"}
else:
version = "0.9.1"
response["result"] = {}
elif message["method"] == "ping":
response["result"] = {"version": version, "capabilities": {"live_handoff": True}}
else:
raise AssertionError(message)
connection.sendall((json.dumps(response) + "\n").encode())
except Exception as error:
errors.append(error)
thread = threading.Thread(target=serve)
thread.start()
output = []
try:
with patch.dict(os.environ, {"HERDR_ENV": "1", "HERDR_SESSION": "wrong"}):
control = HerdrControl(Endpoint.local("named"), str(binary))
if fail_handoff:
with self.assertRaisesRegex(ControlError, "handoff refused"):
control.update_and_restart(output.append)
else:
result = control.update_and_restart(output.append)
self.assertIn("服务器已确认使用 0.9.1", result)
finally:
done.set()
thread.join(3)
server.close()
self.assertFalse(errors)
handoffs = [m for m in methods if m["method"] == "server.live_handoff"]
self.assertEqual(len(handoffs), 1)
self.assertEqual(handoffs[0]["params"]["import_exe"], str(binary.resolve()))
self.assertEqual(handoffs[0]["params"]["expected_version"], "0.9.1")
commands = [json.loads(line) for line in record.read_text().splitlines()]
self.assertEqual(commands.count(["--session", "named", "update", "--handoff"]), 1)
self.assertFalse(any("stop" in command for command in commands))
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -8,6 +8,7 @@ import tempfile
import time
import unittest
import uuid
from unittest.mock import patch
from herdr_hub.cli import (
BOOTSTRAP_WINDOW_NAME,
@@ -15,11 +16,32 @@ from herdr_hub.cli import (
HubError,
TmuxHub,
WINDOW_SHORT_LABEL_OPTION,
build_parser,
command_control,
)
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
class TmuxIntegrationTests(unittest.TestCase):
def test_verified_handoff_reconnects_only_originating_client(self) -> None:
self.hub.ensure_server()
original = self.hub.create_window(Endpoint.local("first"), str(self.fake_herdr))
other = self.hub.create_window(Endpoint.local("second"), str(self.fake_herdr))
self.hub.select_window(original.window_id)
def pid(window):
return self.hub.run(["display-message", "-p", "-t", window.window_id, "#{pane_pid}"]).stdout.strip()
original_pid, other_pid = pid(original), pid(other)
def close_popup(run):
self.hub.select_window(other.window_id)
run.__self__.reconnect_requested = True
return 0
with patch("herdr_hub.cli.make_hub", return_value=self.hub), patch(
"herdr_hub.cli.resolve_herdr", return_value=str(self.fake_herdr)
), patch("herdr_hub.cli.curses.wrapper", side_effect=close_popup):
self.assertEqual(command_control(build_parser().parse_args(["control"])), 0)
self.assertNotEqual(pid(original), original_pid)
self.assertEqual(pid(other), other_pid)
def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary_directory.cleanup)