diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c72953e..3a097328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ [中文](CHANGELOG_zh.md) +## v4.0.2 + +Fix stale Claude activity and coordinate device upgrades with their Relay. +Wire protocol remains v72. See the bilingual [release notes](docs/releases/v4.0.2.md). + +- Retire anonymous activity received before the native human-input echo when + that response ends. Preserve unrelated background tasks and support existing + persistent Claude services without restarting them or resubmitting prompts. +- Check/update the paired VPS before activating a device update; skip an already + current compatible Relay. Preserve the exact remote transaction across lost + SSH connections. The downloaded installer also covers the first v4.0.1 upgrade. +- Show authenticated device/server version mismatches in the Web device list + and active conversation without changing the wire protocol. +- Allow explicit registration of existing immutable Mac installations, retaining + their root, LaunchAgent identity, environment and log paths. +- Initialize Work stores before optional Codex readiness checks so a slow probe + cannot cause a false first-install migration failure. + ## v4.0.1 Add managed release updates and verify Codex shared connections after activation. diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 65869e10..46d297d8 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -2,6 +2,20 @@ [English](CHANGELOG.md) +## v4.0.2 + +修复 Claude 假运行状态,并让设备更新与 VPS 协调执行。通信协议保持 v72。 +详见[双语发布说明](docs/releases/v4.0.2.md)。 + +- 原生用户回显前收到的匿名活动,随对应回复结束正确收尾;保留无关后台任务, + 兼容已有独立 Claude 服务,不重启服务或重发提示词。 +- 设备更新先检查并升级配对的 VPS;VPS 已更新且协议兼容时跳过。SSH 断开后核查 + 同一远端事务,新安装器也覆盖从 v4.0.1 发起的首次升级。 +- 网页设备列表和当前会话显示经过认证的设备/服务端版本不一致提示。 +- 允许显式注册已有 Mac 不可变安装,保留目录、LaunchAgent、环境与日志位置。 +- Work 数据库先初始化,再进行可选 Codex 连接检查,避免检查缓慢导致首次安装 + 被误判为数据库迁移失败。 + ## v4.0.1 新增 Release 更新命令,并在激活后检查 Codex 共享连接。通信协议仍为 v72。 diff --git a/README.md b/README.md index 123c4f72..b0a4875d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · Web / PWA / TUI -**产品版本:v4.0.1** · Wire protocol v72 +**产品版本:v4.0.2** · Wire protocol v72 [English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) · [终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md) diff --git a/README_en.md b/README_en.md index a379a7f9..278625dc 100644 --- a/README_en.md +++ b/README_en.md @@ -4,7 +4,7 @@ Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · Web / PWA / TUI -**Product version: v4.0.1** · Wire protocol v72 +**Product version: v4.0.2** · Wire protocol v72 [中文](README.md) · [Engine comparison](#engines-and-features) · [Quick start](#quick-start) · [Terminal workspace](#terminal-workspace) · [Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md) diff --git a/cc_remote/__init__.py b/cc_remote/__init__.py index 08fd6547..9c7f1884 100644 --- a/cc_remote/__init__.py +++ b/cc_remote/__init__.py @@ -5,4 +5,4 @@ - control link: client <-> relay(WS) <-> wrapper <-> ClaudeSDKClient <-> cc """ -__version__ = "4.0.1" +__version__ = "4.0.2" diff --git a/cc_remote/__main__.py b/cc_remote/__main__.py index 3bd30672..f3182716 100644 --- a/cc_remote/__main__.py +++ b/cc_remote/__main__.py @@ -16,6 +16,7 @@ def main(argv: list[str] | None = None) -> int: command.add_argument("--check", action="store_true", help="check without downloading or restarting") command.add_argument("--version", dest="target_version", help="select an exact stable version") command.add_argument("--role", choices=("relay", "wrapper"), help="required when both roles are installed") + command.add_argument("--relay-ssh", help="SSH alias or user@host for Relay upgrades (saved after verification)") command.add_argument( "--allow-protocol-change", action="store_true", help="activate a protocol change during a coordinated multi-machine upgrade", @@ -25,6 +26,7 @@ def main(argv: list[str] | None = None) -> int: return update( role=args.role, target_version=args.target_version, check=args.check, allow_protocol_change=args.allow_protocol_change, + relay_ssh=args.relay_ssh, ) except (UpdateError, OSError) as exc: print(f"ERROR: {exc}", file=sys.stderr) diff --git a/cc_remote/claude_steering.py b/cc_remote/claude_steering.py index 363781a2..423f9e3e 100644 --- a/cc_remote/claude_steering.py +++ b/cc_remote/claude_steering.py @@ -101,6 +101,14 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict: kind = origin.get("kind") if isinstance(origin, dict) else None child = value.get("parent_tool_use_id") or value.get("parentToolUseID") if not child: + if managed_active and is_managed_input(value, pending_compact=True): + # A native request can precede its replayed human input. That + # anonymous activity now belongs to the consumed human response + # and must settle at its Result. Keep explicit task origins; + # they can still have their own, later terminal boundary. + for entry in self._backgrounds.values(): + if entry["origin_key"] is None: + entry["managed"] = True message = value.get("message") content = message.get("content") if isinstance(message, dict) else None tool_result = isinstance(content, list) and any( @@ -141,20 +149,24 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict: }} elif value.get("type") == "result": ended = background_end_ids(value) - if not ended: - if kind in (None, "human"): - # An unattributed Result closes the physical response, - # including its in-turn task inputs. An older autonomous - # response must not be consumed by a new human terminal. - if managed_active: - ended = tuple(key for key, entry in self._backgrounds.items() - if entry["managed"]) - elif kind is None: - ended = tuple(self._backgrounds) - else: - # A precise unrelated origin remains authoritative. - ended = tuple(key for key, entry in self._backgrounds.items() - if entry["origin_key"] in (None, _origin_key(origin))) + local_ends = () + if kind in (None, "human"): + # An unattributed Result closes the physical response, + # including its in-turn task inputs. An older autonomous + # response must not be consumed by a new human terminal. + if managed_active: + local_ends = tuple(key for key, entry in self._backgrounds.items() + if entry["managed"]) + elif kind is None: + local_ends = tuple(self._backgrounds) + else: + # A precise unrelated origin remains authoritative. + local_ends = tuple(key for key, entry in self._backgrounds.items() + if entry["origin_key"] in (None, _origin_key(origin))) + # An older service can journal some ends without knowing about + # the controller's implicit pre-input claim. Retain its exact + # boundaries and include locally established consumption too. + ended = tuple(dict.fromkeys((*ended, *local_ends))) if ended: value = {**value, "__cc_background_ends": list(ended)} if len(ended) == 1: diff --git a/cc_remote/relay/pairing.py b/cc_remote/relay/pairing.py index 666997b3..cdf40104 100644 --- a/cc_remote/relay/pairing.py +++ b/cc_remote/relay/pairing.py @@ -14,16 +14,20 @@ from __future__ import annotations import asyncio +from collections import OrderedDict +import json +import re import uuid from typing import Awaitable, Callable, Optional from fastapi import WebSocket, WebSocketDisconnect +from cc_remote import __version__ from cc_remote.config import RelayConfig from cc_remote.log import logger from cc_remote.protocol import ( Error, ProtocolError, WrapperDisconnected, WrapperReconnected, - deserialize, is_client_message, serialize, + PROTOCOL_VERSION, deserialize, is_client_message, serialize, ERR_BUSY, ERR_WRAPPER_OFFLINE, ERR_WRAPPER_ALREADY_CONNECTED, ERR_PROTOCOL, ) from cc_remote.relay.forward import ClientConn, SlowClientError @@ -69,6 +73,32 @@ def __init__( # Once a new generation owns client_id, no old generation can enter a # wrapper send after that point. self._wrapper_send_lock = asyncio.Lock() + self._wrapper_versions: OrderedDict[str, dict] = OrderedDict() + + def remember_wrapper_version(self, machine_id: str, protocol: int, version: str | None) -> None: + if not isinstance(version, str) or not re.fullmatch(r"\d{1,6}\.\d{1,6}\.\d{1,6}", version): + version = None + self._wrapper_versions[machine_id] = { + "wrapper_version": version, "relay_version": __version__, + "wrapper_protocol": protocol, "relay_protocol": PROTOCOL_VERSION, + } + self._wrapper_versions.move_to_end(machine_id) + while len(self._wrapper_versions) > MAX_NAMED_WRAPPERS + 1: + self._wrapper_versions.popitem(last=False) + + def device_version(self, machine_id: str) -> dict: + info = self._wrapper_versions.get(machine_id) + if info and (info["wrapper_protocol"] != PROTOCOL_VERSION + or info["wrapper_version"] != __version__): + return {"compatibility": dict(info)} + return {} + + @property + def versioned_machine_ids(self) -> tuple[str, ...]: + return tuple(self._wrapper_versions) + + def forget_wrapper_version(self, machine_id: str) -> None: + self._wrapper_versions.pop(machine_id, None) @property def wrapper_connected(self) -> bool: @@ -165,6 +195,27 @@ async def serve_wrapper( except ProtocolError as e: log.warning("bad frame from wrapper", error=str(e)) mismatch = "protocol version mismatch" in str(e) + if mismatch and not announced: + # Version diagnostics must remain readable even when + # strict wire decoding rejects an old peer. Only an + # authenticated, correctly scoped hello can publish it. + try: + hello = json.loads(raw) + peer_id = hello.get("machine_id") or "default" + peer_version = hello.get("v") + valid = ( + hello.get("type") == "hello" and hello.get("role") == "wrapper" + and isinstance(peer_id, str) + and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}", peer_id) + and type(peer_version) is int and 0 < peer_version < 1000000 + and expected_machine_id in (None, peer_id) + ) + if (valid and (authorize_machine is None or await authorize_machine(peer_id)) + and self._wrapper_for(peer_id) is None): + self.remember_wrapper_version(peer_id, peer_version, + getattr(ws, "headers", {}).get("x-cc-remote-version")) + except (ValueError, TypeError, AttributeError): + pass try: await ws.send_text(serialize(Error( code=ERR_PROTOCOL, @@ -258,6 +309,8 @@ async def serve_wrapper( pass return log.info("wrapper connected", machine_id=machine_id) + self.remember_wrapper_version(machine_id, PROTOCOL_VERSION, + getattr(ws, "headers", {}).get("x-cc-remote-version")) assert machine_id is not None await self._on_wrapper_msg(msg, machine_id) except WebSocketDisconnect: diff --git a/cc_remote/relay/server.py b/cc_remote/relay/server.py index b6f26da2..af9a963f 100644 --- a/cc_remote/relay/server.py +++ b/cc_remote/relay/server.py @@ -864,12 +864,13 @@ async def device_list(req: Request) -> JSONResponse: "last_seen": record.last_seen, "online": record.machine_id in connected, "managed": True, + **hub.device_version(record.machine_id), } for record in records ] known = {record.machine_id for record in records} visible_legacy = { - machine_id for machine_id in connected + machine_id for machine_id in connected | set(hub.versioned_machine_ids) if claims.allows_machine(machine_id) } if "*" not in claims.machines: @@ -884,6 +885,7 @@ async def device_list(req: Request) -> JSONResponse: "last_seen": None, "online": machine_id in connected, "managed": False, + **hub.device_version(machine_id), }) pairing_expires_at = await devices.pairing_expires_at(subject) return JSONResponse( @@ -1007,6 +1009,7 @@ async def device_revoke(machine_id: str, req: Request) -> JSONResponse: if not revoked: return JSONResponse({"error": "not_found"}, status_code=404) await hub.disconnect_wrapper(machine_id, reason="device revoked") + hub.forget_wrapper_version(machine_id) await viewers.disconnect_machine(machine_id) return JSONResponse({"ok": True}, headers={"Cache-Control": "no-store"}) @@ -1162,10 +1165,13 @@ async def dynamic_wrapper_authorized(machine_id: str) -> bool: @app.get("/healthz") async def healthz() -> JSONResponse: + from cc_remote import __version__ + return JSONResponse( { "ok": True, "protocol": PROTOCOL_VERSION, + "version": __version__, "wrapper_connected": hub.wrapper_connected, "machines": hub.machine_ids, "clients": hub.client_count, diff --git a/cc_remote/update.py b/cc_remote/update.py index 7b07a9cb..bfa8688b 100644 --- a/cc_remote/update.py +++ b/cc_remote/update.py @@ -1,7 +1,8 @@ """Update managed Release installations through the existing role installers. -No model imports, credentials, remote shell access, or service restarts belong -here. Activation and rollback remain owned by deploy/install-{role}.sh. +No model imports or direct service restarts belong here. Activation and +rollback remain owned by deploy/install-{role}.sh; update_relay coordinates +the configured upstream through its existing SSH administration boundary. """ from __future__ import annotations @@ -64,7 +65,8 @@ def role(self) -> str: def installation_roots(system: str) -> dict[str, Path]: if system == "darwin": - return {"wrapper": Path.home() / "Library/Application Support/cc-remote"} + return {"wrapper": Path(os.environ.get("CC_REMOTE_MANAGED_ROOT") + or Path.home() / "Library/Application Support/cc-remote")} return {"relay": Path("/opt/cc-remote"), "wrapper": Path("/opt/cc-remote-wrapper")} @@ -95,6 +97,10 @@ def read_installation(root: Path, system: str, machine: str) -> Installation: user = metadata.get("user") if not isinstance(user, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", user) or user == "root": raise UpdateError("wrapper installation has no valid original service user") + label = metadata.get("service_label") + if label is not None and (system != "darwin" or not isinstance(label, str) + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]{0,127}", label)): + raise UpdateError("invalid managed macOS service label") elif not isinstance(metadata.get("domain"), str) or not re.fullmatch( r"[a-z0-9][a-z0-9.-]*\.[a-z0-9.-]+", metadata["domain"] ): @@ -316,7 +322,7 @@ def run_installer(command: list[str], lock_descriptor: int) -> int: def update(*, role: str | None, target_version: str | None, check: bool, - allow_protocol_change: bool = False) -> int: + allow_protocol_change: bool = False, relay_ssh: str | None = None) -> int: system, machine = host_platform() installation = select_installation(role, system, machine) repository = release_repository() @@ -336,9 +342,22 @@ def update(*, role: str | None, target_version: str | None, check: bool, installation = fresh current = version_tuple(installation.manifest["product_version"]) print(f"{installation.role}: installed {installation.manifest['product_version']}; selected {version}", flush=True) + relay = None + if installation.role == "wrapper": + from cc_remote.update_relay import RelayUpdate + + relay = RelayUpdate(installation, relay_ssh) + if check: + relay.inspect() + elif relay_ssh: + raise UpdateError("--relay-ssh applies only to a device (wrapper) update") if selected <= current: if target_version and selected < current: raise UpdateError("update does not downgrade private state; use the documented rollback procedure") + if relay and not check and selected == current: + require_independent_terminal() + relay.ensure(version, installation.manifest["protocol_version"], + allow_protocol_change=allow_protocol_change) print("Already up to date." if selected == current else "Installed version is newer than the latest release.") return 0 if check: @@ -365,11 +384,18 @@ def update(*, role: str | None, target_version: str | None, check: bool, fresh = read_installation(installation.root, system, machine) if fresh != installation: raise UpdateError("installation changed while downloading; inspect it before retrying") + if relay: + relay.ensure(version, target["protocol_version"], allow_protocol_change=allow_protocol_change) command = ["bash", str(bundle / "deploy" / f"install-{installation.role}.sh"), str(bundle)] if installation.role == "relay": command += ["--domain", installation.metadata["domain"]] elif system == "linux": command += ["--user", installation.metadata["user"]] + elif installation.metadata.get("service_label"): + command += ["--install-root", str(installation.root), + "--service-label", installation.metadata["service_label"]] + if installation.role == "wrapper" and allow_protocol_change: + command += ["--allow-protocol-change"] print("Activating with the release installer; previous release retained for rollback.", flush=True) if run_installer(command, lock_descriptor): raise UpdateError("installer did not complete successfully; inspect its rollback report before retrying") diff --git a/cc_remote/update_relay.py b/cc_remote/update_relay.py new file mode 100644 index 00000000..e3495346 --- /dev/null +++ b/cc_remote/update_relay.py @@ -0,0 +1,282 @@ +"""Coordinate a device update with its configured Relay's managed installer.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import plistlib +import pwd +import re +import shlex +import subprocess +import sys +import time +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener +import uuid + +from dotenv import dotenv_values + +from cc_remote.update import Installation, UpdateError, version_tuple +from deploy.install_cli import _atomic_file +from deploy.release_manifest import load_manifest + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def relay_origin(installation: Installation) -> str: + if installation.manifest["os"] == "darwin": + environment = {} + label = installation.metadata.get("service_label", "com.muggle.cc-remote.wrapper") + service = Path.home() / "Library/LaunchAgents" / f"{label}.plist" + try: + if service.exists(): + environment = plistlib.loads(service.read_bytes()).get("EnvironmentVariables", {}) + value = environment.get("RELAY_URL") + if not value: + path = Path(environment.get("CC_REMOTE_DEVICE_CONFIG") or Path.home() / ".cc-remote/device.json") + value = json.loads(path.read_text()).get("relay_url") + except (OSError, ValueError, AttributeError) as exc: + raise UpdateError("cannot read this device's Relay URL; check its pairing configuration") from exc + else: + config = {**dotenv_values("/etc/cc-remote/wrapper.env"), + **dotenv_values("/etc/cc-remote/device.env")} + value = config.get("RELAY_URL") + if not isinstance(value, str): + raise UpdateError("this device has no configured Relay URL") + parsed = urlsplit(value) + if (parsed.scheme not in {"ws", "wss", "http", "https"} or not parsed.hostname + or parsed.username or parsed.password or parsed.query or parsed.fragment): + raise UpdateError("invalid configured Relay URL") + scheme = "https" if parsed.scheme in {"wss", "https"} else "http" + return f"{scheme}://{parsed.netloc}" + + +def _get_json(url: str) -> dict: + try: + request = Request(url, headers={"Cache-Control": "no-cache", "User-Agent": "cc-remote-updater"}) + with build_opener(_NoRedirect).open(request, timeout=15) as response: + payload = response.read(65537) + if len(payload) > 65536: + raise ValueError("oversized response") + value = json.loads(payload) + if not isinstance(value, dict): + raise ValueError("invalid response") + return value + except (OSError, ValueError) as exc: + raise UpdateError("cannot verify Relay health/version; no local service was changed") from exc + + +def relay_release(origin: str) -> dict: + health = _get_json(f"{origin}/healthz") + if health.get("ok") is not True: + raise UpdateError("Relay is not healthy; inspect it before updating this device") + # v4.0.1 advertised product version only in the served Web manifest. + manifest = _get_json(f"{origin}/cc-remote-build.json") + version_tuple(manifest.get("version")) + protocol = manifest.get("protocol") + if (type(protocol) is not int or protocol < 1 or protocol != health.get("protocol") + or health.get("version", manifest["version"]) != manifest["version"]): + raise UpdateError("Relay and Web versions are inconsistent; inspect the server deployment") + return {"version": manifest["version"], "protocol": protocol} + + +def _ssh_target(value: str) -> str: + if not isinstance(value, str) or not re.fullmatch( + r"(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_\[][A-Za-z0-9_.:\[\]-]{0,253}", value, + ): + raise UpdateError("--relay-ssh must be an SSH host alias or user@host; use ~/.ssh/config for ports/keys") + return value + + +class RelayUpdate: + def __init__(self, installation: Installation, ssh_target: str | None = None): + self.installation = installation + self.origin = relay_origin(installation) + self.settings = installation.root / "update.json" + self.journal = installation.root / "upstream-update.json" + stored = {} + if self.settings.exists(): + try: + stored = json.loads(self.settings.read_text()) + if not isinstance(stored, dict): + raise ValueError("invalid settings") + except (OSError, ValueError) as exc: + raise UpdateError("invalid update.json; inspect the saved Relay update configuration") from exc + target = ssh_target or os.environ.get("CC_REMOTE_RELAY_SSH") or stored.get("relay_ssh") + self.target = _ssh_target(target) if target else None + + def inspect(self) -> dict: + info = relay_release(self.origin) + print(f"Relay {self.origin}: v{info['version']} (protocol {info['protocol']})", flush=True) + return info + + def save_settings(self) -> None: + if self.target: + _atomic_file(self.settings, (json.dumps({"relay_ssh": self.target}) + "\n").encode(), 0o600) + + def _ssh(self, command: list[str], *, timeout: int = 30) -> str: + if not self.target: + raise UpdateError( + "Relay also needs updating. Set CC_REMOTE_RELAY_SSH=user@host, " + "or use --relay-ssh user@host with the new updater/installer; " + "its SSH account must be allowed to run the managed installer with sudo" + ) + args = ["/usr/bin/ssh", "-oBatchMode=yes", "-oConnectTimeout=10", + "-oServerAliveInterval=10", "-oServerAliveCountMax=2", "--", self.target, + shlex.join(command)] + if self.installation.manifest["os"] == "linux" and os.geteuid() == 0: + # Local activation runs as root, SSH remains the original user's + # identity. Do not copy private keys or use root's unrelated config. + user = self.installation.metadata["user"] + try: + pwd.getpwnam(user) + except KeyError as exc: + raise UpdateError("the original Wrapper service user no longer exists") from exc + args = ["sudo", "-H", "-u", user, "--", *args] + try: + response = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + raise UpdateError("Relay SSH connection unavailable; inspect the recorded upstream transaction before retrying") from exc + if response.returncode: + # Do not print arbitrary remote output or credential helper output. + raise UpdateError("Relay SSH command failed; inspect SSH/sudo access and the recorded upstream transaction") + return response.stdout + + def _verify_host(self) -> None: + script = ( + "import json,sys;sys.path.insert(0,'/opt/cc-remote/current');" + "from cc_remote.update import read_installation,host_platform;" + "from pathlib import Path;" + "i=read_installation(Path('/opt/cc-remote'),*host_platform());" + "print(json.dumps({'domain':i.metadata['domain'],'version':i.manifest['product_version']}))" + ) + try: + info = json.loads(self._ssh(["sudo", "-n", "/opt/cc-remote/current/.venv/bin/python", + "-I", "-c", script])) + except (ValueError, TypeError) as exc: + raise UpdateError("cannot verify the SSH host's managed Relay installation") from exc + if not isinstance(info, dict) or info.get("domain") != urlsplit(self.origin).hostname: + raise UpdateError("the SSH host does not manage this device's Relay domain; nothing was activated") + + def _write_transaction(self, transaction: dict) -> None: + _atomic_file(self.journal, (json.dumps(transaction, sort_keys=True) + "\n").encode(), 0o600) + + def _finish(self, transaction: dict) -> None: + unit = transaction.get("unit") + if (not isinstance(unit, str) or not re.fullmatch(r"cc-remote-update-[a-f0-9]{32}", unit) + or transaction.get("origin") != self.origin or transaction.get("ssh") != self.target): + raise UpdateError("upstream transaction does not match this Relay; inspect upstream-update.json") + version_tuple(transaction.get("version")) + if type(transaction.get("protocol")) is not int or transaction["protocol"] < 1: + raise UpdateError("invalid upstream transaction protocol; inspect upstream-update.json") + deadline = time.monotonic() + 900 + while time.monotonic() < deadline: + output = self._ssh(["sudo", "-n", "systemctl", "show", f"{unit}.service", + "--property=LoadState,ActiveState,SubState,Result,ExecMainStatus"]) + state = dict(line.split("=", 1) for line in output.splitlines() if "=" in line) + if state.get("LoadState") != "loaded": + raise UpdateError(f"Relay transaction outcome unknown: inspect {unit}.service; no second update was started") + if state.get("ActiveState") == "failed": + raise UpdateError(f"Relay update failed: inspect journalctl -u {unit}.service and its rollback report") + if state.get("SubState") == "exited": + if state.get("Result") != "success" or state.get("ExecMainStatus") != "0": + raise UpdateError(f"Relay update did not succeed; inspect {unit}.service") + info = self.inspect() + if (info["version"] != transaction["version"] + or info["protocol"] != transaction["protocol"]): + raise UpdateError("Relay updater exited but the public version does not match; local activation stopped") + self._write_transaction({**transaction, "complete": True}) + return + time.sleep(2) + raise UpdateError(f"Relay update still pending: {unit}.service; run update again to inspect the same transaction") + + def ensure(self, version: str, protocol: int, *, allow_protocol_change: bool) -> None: + # A lost SSH acknowledgement is not permission to launch a second job. + if self.journal.exists(): + try: + transaction = json.loads(self.journal.read_text()) + if not isinstance(transaction, dict): + raise ValueError("invalid journal") + except (OSError, ValueError) as exc: + raise UpdateError("cannot read upstream-update.json; inspect the previous update") from exc + if transaction.get("complete") is not True: + self._finish(transaction) + info = self.inspect() + if version_tuple(info["version"]) >= version_tuple(version): + if info["protocol"] != protocol: + raise UpdateError("Relay is newer but incompatible with the selected device release; select a matching release") + print("Relay is already current; updating this device only.", flush=True) + self.save_settings() + return + if info["protocol"] != protocol and not allow_protocol_change: + raise UpdateError("Relay protocol changes; arrange the device upgrades and repeat with --allow-protocol-change") + if not self.target and sys.stdin.isatty(): + try: + self.target = _ssh_target(input("Relay needs updating. Existing SSH admin host (user@host or alias): ").strip()) + except (EOFError, KeyboardInterrupt) as exc: + raise UpdateError("Relay update cancelled; no local service was changed") from exc + self._verify_host() + self.save_settings() + unit = f"cc-remote-update-{uuid.uuid4().hex}" + transaction = {"unit": unit, "origin": self.origin, "ssh": self.target, + "version": version, "protocol": protocol, "complete": False} + self._write_transaction(transaction) + command = ["sudo", "-n", "systemd-run", "--unit", unit, + "--property=Type=oneshot", "--property=RemainAfterExit=yes", "--", + "/usr/local/bin/cc-remote", "update", "--role", "relay", "--version", version] + if allow_protocol_change: + command.append("--allow-protocol-change") + print(f"Updating Relay first; independent server transaction: {unit}", flush=True) + self._ssh(command) + self._finish(transaction) + + +def installer_main(argv: list[str] | None = None) -> int: + """New-bundle preflight, including callers running the old v4.0.1 updater. + + The role installer already holds the installation lock and has built and + validated this bundle. This check runs before stopping the local Wrapper. + Older Release installs may not yet have installation.json, so use the + service identity validated by the installer instead of registering early. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", required=True, type=Path) + parser.add_argument("--bundle", required=True, type=Path) + parser.add_argument("--user", required=True) + parser.add_argument("--service-label") + parser.add_argument("--relay-ssh") + parser.add_argument("--allow-protocol-change", action="store_true") + args = parser.parse_args(argv) + try: + current = args.root / "current" + previous = current.resolve(strict=True) + if not current.is_symlink() or previous.parent != (args.root / "releases").resolve(): + raise UpdateError("current must identify an existing immutable Wrapper release") + old = load_manifest(previous / "release-manifest.json") + target = load_manifest(args.bundle / "release-manifest.json") + if (old["role"] != "wrapper" or target["role"] != "wrapper" + or any(old[key] != target[key] for key in ("os", "arch"))): + raise UpdateError("upstream preflight requires matching Wrapper installations") + if old["protocol_version"] != target["protocol_version"] and not args.allow_protocol_change: + raise UpdateError("protocol changes; coordinate all devices and use --allow-protocol-change") + metadata = {"schema": 1, "role": "wrapper", "user": args.user} + if args.service_label: + metadata["service_label"] = args.service_label + installation = Installation(args.root, previous, old, metadata) + RelayUpdate(installation, args.relay_ssh).ensure( + target["product_version"], target["protocol_version"], + allow_protocol_change=args.allow_protocol_change, + ) + except (OSError, ValueError, UpdateError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(installer_main()) diff --git a/cc_remote/wrapper/__main__.py b/cc_remote/wrapper/__main__.py index 66697218..1798d4d5 100644 --- a/cc_remote/wrapper/__main__.py +++ b/cc_remote/wrapper/__main__.py @@ -38,6 +38,9 @@ async def main() -> None: session_pages=machine.viewer_pages, home_pages=HomePages(cfg.state_dir / "viewer-home-pages.json")).run()) try: + # The installer verifies Work schemas independently of optional Codex + # readiness. A slow daemon probe must not look like a failed migration. + await machine.initialize_work() # The official Codex TUI can share a thread only when its durable # app-server daemon already owns the control plane. Prepare it before # Relay startup so a terminal opened after this service cannot win a diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index 0d9e4e9d..ae3740c7 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -2566,6 +2566,7 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): getattr(cfg, "claude_work_root", fallback_work / "claude"), getattr(cfg, "codex_work_root", fallback_work / "codex"), ) + self._work_initialized = False self._claude_work_profile_migration_ok = ( self._claude_profile_migration_ok ) @@ -8852,6 +8853,12 @@ async def _delete_claude_btw_transcripts( # ---- lifecycle ---- + async def initialize_work(self) -> None: + """Make installation-critical stores ready before optional engine probes.""" + if not self._work_initialized: + await asyncio.to_thread(self._work.initialize) + self._work_initialized = True + async def prepare_codex_daemons(self) -> None: """Start every Code shared daemon before native terminals can race it. @@ -8918,7 +8925,7 @@ def _publish_codex_readiness(self, rows: list[dict]) -> None: async def run(self) -> None: self._cleanup_tmp() - await asyncio.to_thread(self._work.initialize) + await self.initialize_work() # A previous process may have died while a Claude /btw fork was live. # Remove its persisted private transcript before accepting any client # command or publishing SessionList. diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py index 9e62622c..2ebb4ec2 100644 --- a/cc_remote/wrapper/sdk.py +++ b/cc_remote/wrapper/sdk.py @@ -1566,8 +1566,8 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: if parse_raw and isinstance(data, dict): managed_active = self._turn_active and ( self._managed_input_seen - or (self._pending_compact and self._message_route_owner != "background" - and is_managed_input(data, pending_compact=True))) + or is_managed_input(data, pending_compact=( + self._pending_compact and self._message_route_owner != "background"))) data = self._steers.annotate(data, managed_active=managed_active) steer = data.get("__cc_steer") if parse_raw else None intermediate = bool(parse_raw and data.get("__cc_steer_intermediate")) diff --git a/cc_remote/wrapper/transport.py b/cc_remote/wrapper/transport.py index 197c4fa7..8dfc04fb 100644 --- a/cc_remote/wrapper/transport.py +++ b/cc_remote/wrapper/transport.py @@ -15,6 +15,7 @@ from websockets.asyncio.client import connect +from cc_remote import __version__ from cc_remote.log import logger from cc_remote.protocol import ProtocolError, deserialize, serialize @@ -136,7 +137,7 @@ async def _run(self) -> None: backoff = 1.0 while not self._stop: try: - headers = {"Authorization": f"Bearer {self.token}"} + headers = {"Authorization": f"Bearer {self.token}", "X-CC-Remote-Version": __version__} kw: dict = { "additional_headers": headers, "max_size": self.max_size, diff --git a/deploy/README.md b/deploy/README.md index 21147f2a..50fbf378 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -86,16 +86,30 @@ deployment. response into a shell. - `cc-remote update` — local management command registered by the role installers (`scripts/cc-remote`, `cc_remote/update.py`, `install_cli.py`). It discovers only - standard installs carrying non-secret `installation.json` metadata, downloads + registered installs carrying non-secret `installation.json` metadata, downloads and validates one stable role bundle, then calls its existing role installer. `--check` performs no activation; `--version` selects an exact published version. Both roles on one host require `--role`. Protocol changes require a coordinated maintenance window and `--allow-protocol-change`. SDK/service-contract changes defer to the Claude service migration procedure. The installer inherits the update lock so a disconnected caller cannot accidentally start a second update. - The command must run outside the managed Wrapper/Relay process tree. It does - not update remote machines, restart the independent Claude service, adopt - source/Docker/custom layouts, prune releases, or automate downgrade rollback. + The command must run outside the managed Wrapper/Relay process tree. Device + updates first verify the paired Relay; an already-current compatible Relay is + skipped. Otherwise `--relay-ssh` selects an existing administrator's SSH target + (saved in private `update.json`). After verifying its managed domain, an OS-owned + systemd job invokes the remote role installer. The private upstream transaction + records that exact job before launch; an unknown outcome is inspected on retry, + never blindly resubmitted. Public readiness precedes device activation. Pairing + credentials do not authorize this operation. Other devices update individually. + The new Wrapper role installer repeats upstream verification before stopping + the local service, covering the first upgrade launched by v4.0.1's older + updater. `CC_REMOTE_RELAY_SSH` supplies an existing SSH admin target to that + older caller; an interactive terminal can request it when missing. First + pairing is separate from this existing-installation upgrade path. + Explicit Mac registration can bind an existing immutable root and LaunchAgent; + future installs preserve that layout and service identity. The command does not + restart the independent Claude service, adopt source/Docker layouts, prune + releases, or automate downgrade rollback. Direct role installers use the same per-installation `.update.lock` before reading rollback state or changing services. They validate the inherited file descriptor from a managed update; an environment marker cannot bypass the lock. diff --git a/deploy/install-wrapper.sh b/deploy/install-wrapper.sh index 472e3e71..93b5dcac 100755 --- a/deploy/install-wrapper.sh +++ b/deploy/install-wrapper.sh @@ -12,7 +12,7 @@ usage() { cat >&2 <<'EOF' Usage: install-wrapper.sh BUNDLE --relay https://remote.example.com --pair PAIR-CODE [--name LABEL] - install-wrapper.sh BUNDLE [--user USER] + install-wrapper.sh BUNDLE [--user USER] [--relay-ssh USER@HOST] [--allow-protocol-change] The relay and pair arguments are required for the first install. They may be omitted on upgrades when a device credential already exists. Linux installs @@ -29,6 +29,10 @@ relay="" pair_code="" device_name="" target_user="${CC_REMOTE_INSTALL_USER:-}" +install_root="" +installed_service_label="" +relay_ssh="" +allow_protocol_change=0 replace_pair=0 while [ "$#" -gt 0 ]; do case "$1" in @@ -56,6 +60,25 @@ while [ "$#" -gt 0 ]; do replace_pair=1 shift ;; + --install-root) + [ "$#" -ge 2 ] || usage + install_root="$2" + shift 2 + ;; + --service-label) + [ "$#" -ge 2 ] || usage + installed_service_label="$2" + shift 2 + ;; + --relay-ssh) + [ "$#" -ge 2 ] || usage + relay_ssh="$2" + shift 2 + ;; + --allow-protocol-change) + allow_protocol_change=1 + shift + ;; *) die "unknown wrapper installer argument: $1" ;; esac done @@ -125,14 +148,26 @@ if [ "$system" = darwin ]; then if [ -z "$target_home" ] || [ ! -d "$target_home" ]; then die "HOME is invalid" fi - appdir="$target_home/Library/Application Support/cc-remote" + appdir="${install_root:-$target_home/Library/Application Support/cc-remote}" config_dir="$target_home/.cc-remote" device_file="$config_dir/device.json" - service_file="$target_home/Library/LaunchAgents/com.muggle.cc-remote.wrapper.plist" - service_label="com.muggle.cc-remote.wrapper" + service_label="${installed_service_label:-com.muggle.cc-remote.wrapper}" + case "$service_label" in + *[!A-Za-z0-9.-]*|""|[.-]*) die "invalid macOS service label" ;; + esac + case "$appdir" in + /*) ;; + *) die "macOS install root must be absolute" ;; + esac + if [ -n "$install_root" ] || [ -n "$installed_service_label" ]; then + [ -L "$appdir/current" ] || die "custom root must already have an immutable current release" + [ -f "$target_home/Library/LaunchAgents/$service_label.plist" ] || die "custom LaunchAgent does not exist" + fi + service_file="$target_home/Library/LaunchAgents/$service_label.plist" log_dir="$target_home/Library/Logs/cc-remote" cli_path="$target_home/.local/bin/cc-remote" else + [ -z "$install_root$installed_service_label" ] || die "custom install roots are supported only on macOS" [ "$(id -u)" -eq 0 ] || die "Linux wrapper installation must run as root" command -v systemctl >/dev/null 2>&1 || die "systemd is required" command -v getent >/dev/null 2>&1 || die "getent is required" @@ -173,6 +208,25 @@ fi --python "$python_runtime" python "$bundle/deploy/install_lock.py" \ --verify-fd "$CC_REMOTE_INSTALL_LOCK_FD" "$appdir" +if [ "$system" = darwin ] && { [ -n "$install_root" ] || [ -n "$installed_service_label" ]; }; then + "$bundle/bin/uv" run --no-project --no-env-file --managed-python \ + --python "$python_runtime" python - "$appdir" "$service_file" "$service_label" <<'PY' +from pathlib import Path +import plistlib +import sys + +root, service = map(Path, sys.argv[1:3]) +with service.open("rb") as stream: + payload = plistlib.load(stream) +arguments = payload.get("ProgramArguments", []) +if (payload.get("Label") != sys.argv[3] or len(arguments) < 3 + or arguments[1:3] != ["-m", "cc_remote.wrapper"] + or Path(arguments[0]).resolve() != (root / "current/.venv/bin/python").resolve() + or Path(payload.get("WorkingDirectory", "")).resolve() != (root / "current").resolve()): + raise SystemExit("custom root and LaunchAgent do not identify the same Wrapper") +PY +fi + "$bundle/bin/uv" run --no-project --no-env-file --managed-python \ --python "$python_runtime" python "$bundle/deploy/install_cli.py" \ --destination "$cli_path" --check @@ -427,6 +481,27 @@ if [ ! -f "$device_file" ] || [ -L "$device_file" ]; then fi chmod 0600 "$device_file" +# Older update commands only invoke the downloaded installer. Check the Relay +# here too, before stopping or switching the local Wrapper, so their first +# upgrade has the same server-first behavior. Fresh pairing is a separate flow. +if [ -n "$previous" ] && [ -z "$pair_code" ]; then + upstream_args=(--root "$appdir" --bundle "$target" --user "$target_user") + if [ "$system" = darwin ]; then + upstream_args+=(--service-label "$service_label") + fi + if [ -n "$relay_ssh" ]; then + upstream_args+=(--relay-ssh "$relay_ssh") + fi + if [ "$allow_protocol_change" -eq 1 ]; then + upstream_args+=(--allow-protocol-change) + fi + ( + cd "$target" + PYTHONPATH="$target" "$target/.venv/bin/python" \ + -m cc_remote.update_relay "${upstream_args[@]}" + ) +fi + # Mutable Work metadata and private wrapper control state live outside immutable # release directories. Capture them before activation so a code rollback also # restores the schemas understood by the previous wrapper. @@ -465,7 +540,7 @@ if [ "$system" = darwin ]; then "$target/.venv/bin/python" - \ "$target/deploy/com.muggle.cc-remote.wrapper.plist.in" \ "$service_file" "$current" "$target_home" "$log_dir" \ - "$service_backup" <<'PY' + "$service_backup" "$service_label" <<'PY' from pathlib import Path import plistlib import sys @@ -502,9 +577,14 @@ if any(marker in text for marker in values): raise SystemExit("unresolved LaunchAgent template marker") staged = destination.with_name(f".{destination.name}.new") payload = plistlib.loads(text.encode("utf-8")) +payload["Label"] = sys.argv[7] # Operator configuration (including the independent Claude service endpoint) # survives a Wrapper upgrade. New installs still use the secret-free template. payload["EnvironmentVariables"].update(previous_environment) +if previous_plist is not None: + for key in ("StandardOutPath", "StandardErrorPath"): + if isinstance(previous.get(key), str): + payload[key] = previous[key] staged.write_bytes(plistlib.dumps(payload)) staged.replace(destination) PY @@ -613,8 +693,11 @@ fi # Keep registration after the interruptible readiness check. Once it succeeds, # post-install output failures must not roll back a registered installation. -"$target/.venv/bin/python" "$target/deploy/install_cli.py" \ - --root "$appdir" --destination "$cli_path" --role wrapper --user "$target_user" +cli_args=(--root "$appdir" --destination "$cli_path" --role wrapper --user "$target_user") +if [ "$system" = darwin ]; then + cli_args+=(--service-label "$service_label") +fi +"$target/.venv/bin/python" "$target/deploy/install_cli.py" "${cli_args[@]}" activation_committed=1 echo diff --git a/deploy/install.sh b/deploy/install.sh index b29e8ff3..1057b922 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -2,7 +2,7 @@ # Download, verify, and run a role-specific cc-remote release installer. set -euo pipefail -VERSION="${CC_REMOTE_VERSION:-4.0.1}" +VERSION="${CC_REMOTE_VERSION:-4.0.2}" REPOSITORY="${CC_REMOTE_GITHUB_REPOSITORY:-muggle-stack/cc-remote}" BASE_URL="${CC_REMOTE_RELEASE_BASE_URL:-https://github.com/$REPOSITORY/releases/download/v$VERSION}" diff --git a/deploy/install_cli.py b/deploy/install_cli.py index 673d7fec..263a6681 100644 --- a/deploy/install_cli.py +++ b/deploy/install_cli.py @@ -6,6 +6,8 @@ import json import os from pathlib import Path +import re +import shlex import stat import tempfile @@ -38,7 +40,7 @@ def _atomic_file(destination: Path, content: bytes, mode: int) -> None: def install_cli(root: Path, destination: Path, *, role: str, user: str | None = None, - domain: str | None = None) -> None: + domain: str | None = None, service_label: str | None = None) -> None: check_destination(destination) current = root / "current" if not current.is_symlink() or current.resolve().parent != (root / "releases").resolve(): @@ -48,6 +50,18 @@ def install_cli(root: Path, destination: Path, *, role: str, user: str | None = if not content.startswith(_HEADER) or not stat.S_ISREG(source.stat().st_mode): raise ValueError("release management launcher is missing or invalid") metadata = {"schema": 1, "role": role} + if service_label is not None: + if (role != "wrapper" or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]{0,127}", service_label) + or not root.is_absolute() or any(char in str(root) for char in "\r\n\0")): + raise ValueError("invalid macOS installation root or service label") + # Explicit registration can adopt an existing immutable Mac layout. + # Bind both launcher and future installer to that same root/LaunchAgent. + metadata["service_label"] = service_label + marker = b"set -euo pipefail\n" + if content.count(marker) != 1: + raise ValueError("release management launcher cannot bind the installation root") + content = content.replace(marker, marker + ( + f"export CC_REMOTE_MANAGED_ROOT={shlex.quote(str(root))}\n").encode(), 1) if role == "wrapper": if not user or user == "root": raise ValueError("wrapper management requires the original service user") @@ -80,6 +94,7 @@ def main() -> int: parser.add_argument("--role", choices=("relay", "wrapper")) parser.add_argument("--user") parser.add_argument("--domain") + parser.add_argument("--service-label") args = parser.parse_args() try: if args.check: @@ -87,7 +102,8 @@ def main() -> int: else: if args.root is None or args.role is None: parser.error("--root and --role are required for registration") - install_cli(args.root, args.destination, role=args.role, user=args.user, domain=args.domain) + install_cli(args.root, args.destination, role=args.role, user=args.user, domain=args.domain, + service_label=args.service_label) except (OSError, ValueError) as exc: parser.exit(1, f"ERROR: {exc}\n") return 0 diff --git a/docs/installation.md b/docs/installation.md index 7ea9226f..e027ba9b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -28,11 +28,11 @@ Web,Wrapper 包只含本机控制端;两者都自带 `uv`,安装时创建 ### 1)下载并校验引导脚本 在 GitHub Release 页面确认版本与 release attestation,再在待安装机器下载同一版本的 -`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.1`;请先确认对应版本已发布,或替换为已选定的已发布 tag +`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.2`;请先确认对应版本已发布,或替换为已选定的已发布 tag (变量中不带开头的 `v`)。该路径不会自动安装尚未发布的开发分支: ```bash -export CC_REMOTE_VERSION=4.0.1 +export CC_REMOTE_VERSION=4.0.2 release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}" curl -fLO "$release_base/install.sh" curl -fLO "$release_base/SHA256SUMS" @@ -110,13 +110,34 @@ Linux 位于 `/usr/local/bin/cc-remote`。macOS 需把 `~/.local/bin` 加到 `PA ```bash cc-remote update --check # 只查版本,不下载安装包或重启服务 -cc-remote update # 更新本机组件到最新稳定 Release +cc-remote update # 先检查并更新 VPS,再更新本机到同一稳定 Release ``` `--version` 可选择一个已发布的准确版本,不执行降级。若同机安装了两个角色, 加 `--role relay` 或 `--role wrapper`。Linux 自动请求 `sudo`,并保留安装时的 -Wrapper 服务用户;macOS 以原桌面用户运行。只更新本机选择的角色,Relay 会带上 Web, -其他机器的 Wrapper 需分别执行更新。 +Wrapper 服务用户;macOS 以原桌面用户运行。设备更新先检查已配对 VPS 的版本; +VPS 已是目标版本且协议兼容时,只更新本机。否则先更新 VPS 的 Relay + Web, +公网验活成功后再切换本机。其他设备随后各自执行更新,不会重复升级已经更新的 VPS。 + +首次需要从设备升级 VPS 时,指定一次已有管理权限的 SSH 主机,成功后保存供后续使用: + +```bash +cc-remote update --relay-ssh operator@relay-host +``` + +也可以使用 `~/.ssh/config` 中的别名。该 SSH 账号需要能通过 `sudo -n` 运行服务器的 +托管更新器和 systemd;Linux 设备使用安装时的普通服务用户连接 SSH。不会把设备配对 +凭据当作服务器管理权限,也不会复制 SSH 私钥。若不能验证服务器版本或没有管理权限, +本机不会先行切换。VPS 更新由独立 systemd 任务运行;连接断开后再次执行命令只核查 +`upstream-update.json` 记录的同一事务,不重复启动安装。失败时按记录中的 unit 查看 +日志与回滚结果。 + +从 v4.0.1 首次升级时,旧命令尚不认识 `--relay-ssh`,但下载的新安装器同样会先 +检查 VPS。交互终端缺少 SSH 配置时会提示输入已有管理主机;自动化可在 Mac 使用 +`CC_REMOTE_RELAY_SSH=operator@relay-host cc-remote update`,Linux 使用 +`sudo CC_REMOTE_RELAY_SSH=operator@relay-host cc-remote update`。连接信息验证后保存, +无需每次填写。直接运行新 Wrapper 安装器升级也会进行这个检查,并支持 +`--relay-ssh` 和 `--allow-protocol-change`。 命令校验 SHA-256、安装包路径、平台和版本,随后复用原安装器的不可变切换、状态快照 与失败回滚;保留账号、配对和外部配置,不清理旧 release,也不会重新配对。同版本 @@ -127,10 +148,18 @@ Wrapper 服务用户;macOS 以原桌面用户运行。只更新本机选择的 通信协议变化时命令默认停止。先安排所有机器的维护窗口,按部署文档顺序使用 `--version` 固定同一版并附加 `--allow-protocol-change`;这只表示已安排协调升级, -不会自动管理远端机器。更新完成后重新加载 Web/PWA。 +会先更新 VPS、再更新当前设备;其余设备仍需在维护窗口内逐一更新。 +页面与设备中心会显示具体设备与服务端的版本不兼容提示。更新完成后重新加载 Web/PWA。 v4.0.0 及更早版本尚未安装这个命令,需先按下面的方式升级到包含它的版本一次。 -源码、自定义目录和 Docker 部署继续使用各自流程,命令不会自动接管它们。 +源码和 Docker 部署继续使用各自流程,命令不会自动接管它们。已有 Mac 不可变 Release +布局可在确认当前目录与 LaunchAgent 对应后,用 `deploy/install_cli.py --root ... +--destination ~/.local/bin/cc-remote --role wrapper --user ... --service-label ...` 显式注册; +后续更新保留该目录、服务标签与原有环境。未注册的自定义部署保持原样。 + +默认安装根目录:Mac 为 `~/Library/Application Support/cc-remote/`,Linux 设备为 +`/opt/cc-remote-wrapper/`,VPS 为 `/opt/cc-remote/`。每个根目录的 `releases/` 保存版本, +`current` 指向活动版本,私有配置和状态不放进 release。 旧版本升级同一台机器时,下载新版本 `install.sh` 后重新执行即可。Relay 仍传 `--domain`;Wrapper 已有设备凭据时只需: diff --git a/docs/installation_en.md b/docs/installation_en.md index 21e86822..d9d2105b 100644 --- a/docs/installation_en.md +++ b/docs/installation_en.md @@ -30,13 +30,13 @@ repository, install Node, or paste tokens into service definitions. ### 1) Download and verify the bootstrap Confirm the version and release attestation on GitHub, then download -`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.1`; +`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.2`; first confirm that it is published, or replace it with the published tag you selected (without the leading `v`). This does not select an unpublished development-branch build: ```bash -export CC_REMOTE_VERSION=4.0.1 +export CC_REMOTE_VERSION=4.0.2 release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}" curl -fLO "$release_base/install.sh" curl -fLO "$release_base/SHA256SUMS" @@ -129,14 +129,35 @@ independent terminal or SSH connection: ```bash cc-remote update --check # No bundle download or service restart -cc-remote update # Latest stable Release for the local component +cc-remote update # Check/update the Relay first, then this device ``` Use `--version` to select an exact published version; downgrades are refused. If both roles are installed, select `--role relay` or `--role wrapper`. Linux requests `sudo` and retains the original Wrapper service user; macOS runs -as the desktop user. Only the selected local role is updated. Relay includes Web; -Wrappers on other machines must be updated separately. +as the desktop user. Device updates check their paired Relay first. An up-to-date, +compatible Relay is skipped; otherwise Relay + Web are updated and publicly +health-checked before local activation. Other devices update separately and skip +the already-updated Relay. + +For the first upstream upgrade, run `cc-remote update --relay-ssh operator@relay-host` +(an SSH config alias is also supported). The target is saved for subsequent +updates. This SSH account needs noninteractive sudo access to the managed updater +and systemd. Linux uses the original Wrapper user's SSH identity. Pairing tokens +never grant server administration, and private keys are not copied. Missing access +or an unverifiable Relay stops local activation. The server runs an independent +systemd job; after a lost connection, the next invocation checks the same recorded +`upstream-update.json` transaction instead of launching another. Inspect the +recorded unit's logs and rollback report on failure. + +When upgrading from v4.0.1, the old command does not recognize `--relay-ssh`, but +the downloaded installer still checks the Relay before local activation. An +interactive terminal asks for the existing SSH admin host when needed. For +automation, use `CC_REMOTE_RELAY_SSH=operator@relay-host cc-remote update` on Mac, +or `sudo CC_REMOTE_RELAY_SSH=operator@relay-host cc-remote update` on Linux. +Verified connection settings are saved for future upgrades. Direct upgrades with +the new Wrapper installer perform the same check and accept `--relay-ssh` and +`--allow-protocol-change`. The command verifies SHA-256, archive paths, platform and version before calling the existing immutable installer with its state snapshot and failed-activation @@ -151,12 +172,23 @@ and require the [Claude service migration procedure](claude-session-service.md). A wire-protocol change stops by default. Arrange a maintenance window on every machine, pin the same `--version`, and add `--allow-protocol-change` in the order -specified by the deployment guide. This flag acknowledges coordination; it does -not manage remote machines. Reload Web/PWA clients afterwards. +specified by the deployment guide. This updates Relay before the current device; +the remaining devices still need updating during the maintenance window. The Web +device list and conversation show incompatible device/server versions. Reload +Web/PWA clients afterwards. v4.0.0 and earlier do not install this command. Upgrade once using the procedure -below to a release that includes it. Source, custom-directory and Docker installs -keep their own upgrade procedure and are not automatically adopted. +below to a release that includes it. Source and Docker installs keep their own +upgrade procedure. Existing immutable Mac layouts can be explicitly registered +with `deploy/install_cli.py --root ... --destination ~/.local/bin/cc-remote +--role wrapper --user ... --service-label ...` after verifying the root and +LaunchAgent match. Future updates preserve that root, label and environment. +Unregistered custom layouts are never automatically adopted. + +Default roots are `~/Library/Application Support/cc-remote/` on Mac, +`/opt/cc-remote-wrapper/` for Linux devices and `/opt/cc-remote/` on the VPS. +Each keeps immutable versions under `releases/` and selects one with `current`; +private configuration/state stays outside release directories. To upgrade an older installation, download the new version's `install.sh` and rerun it. Relay still needs `--domain`; a previously paired Wrapper needs only: diff --git a/docs/releases/v4.0.2.md b/docs/releases/v4.0.2.md new file mode 100644 index 00000000..5d9d787d --- /dev/null +++ b/docs/releases/v4.0.2.md @@ -0,0 +1,67 @@ +# cc-remote v4.0.2 + +## 中文 + +v4.0.2 修复 Claude 回复结束后仍显示 running、下一条消息被误当作引导而拒收的问题, +并补齐设备与 VPS 的协调更新。产品版本 **4.0.2**,通信协议保持 **v72**。 + +### 本次修复 + +- 原生用户消息回显之前收到的临时活动,随对应回复结束正确清除;独立子代理和其他 + 后台续跑仍按自身生命周期处理。兼容已有独立 Claude 服务,不重启原生会话。 +- `cc-remote update` 先检查配对的 VPS;服务器已更新时只更新本机,否则先更新 + Relay + Web,确认公网健康后再切换本机。SSH 断开后继续检查原事务,不重复安装。 +- 设备中心和会话页显示设备与服务端的版本差异,协议不兼容时指出需要更新哪一端。 +- 支持显式注册已有 Mac 自定义不可变目录;更新保留原目录、服务标签、环境和日志。 +- 可选 Codex 连接检查不再挡住 Work 数据库初始化,避免首次安装出现误回滚。 + +### 升级 + +在独立终端执行 `cc-remote update`。从 v4.0.1 发起的首次升级也由新安装器先检查 +VPS;需要升级服务器、但尚未配置管理通道时,终端会询问已有 SSH 主机。 + +自动化首次升级可用: + +```bash +# Mac +CC_REMOTE_RELAY_SSH=operator@relay-host cc-remote update + +# Linux +sudo CC_REMOTE_RELAY_SSH=operator@relay-host cc-remote update +``` + +该 SSH 账号需有服务器的非交互 sudo 管理权限;设备配对凭据不会变成服务器管理 +权限。没有管理权限时先停在检查阶段,本机服务保持原样。VPS 已是目标版本时无需 SSH。 +其他设备随后各自执行更新,已更新的 VPS 会跳过。更新后重新加载 Web/PWA。 + +v4.0.0 尚无更新命令,按[安装与升级](https://github.com/muggle-stack/cc-remote/blob/v4.0.2/docs/installation.md) +升级一次。旧 Release 保留不变。Electron、DSH 和 computer-use 开发分支不纳入本次发布。 + +## English + +v4.0.2 fixes Claude sessions that remained running after a completed reply and +rejected the next prompt as steering. Product version **4.0.2**, wire protocol **v72**. + +- Retire pre-input anonymous activity at the matching human terminal while + preserving independent tasks and continuations. Existing persistent Claude + services remain running; prompts are not resubmitted. +- Coordinate device updates with the paired Relay. Update Relay + Web first, + verify public health, then activate the device. Skip an already-current Relay + and inspect the same remote job after a lost SSH connection. +- Show authenticated device/server version differences and protocol update hints. +- Preserve explicitly registered custom Mac roots, service labels, environment + and log paths across updates. +- Initialize Work stores before optional Codex probes to avoid false first-install + migration failures when daemon checks are slow. + +Run `cc-remote update` in an independent terminal. The new installer also checks +the Relay when called by v4.0.1's old updater. If needed, an interactive terminal +asks for an existing SSH admin target; automation can use `CC_REMOTE_RELAY_SSH` +as shown above. The SSH account needs noninteractive sudo administration rights; +pairing credentials never grant server administration. Missing access stops +before local activation. An already-current Relay needs no SSH access. Update +other devices individually and reload Web/PWA afterwards. + +v4.0.0 needs a one-time [installer upgrade](https://github.com/muggle-stack/cc-remote/blob/v4.0.2/docs/installation_en.md). +Existing releases remain available. Electron, DSH and computer-use development +branches are outside this release. diff --git a/scripts/cc-remote b/scripts/cc-remote index daaa3376..893534e7 100755 --- a/scripts/cc-remote +++ b/scripts/cc-remote @@ -14,7 +14,7 @@ if [ "$(uname -s)" = Linux ] && [ "$(id -u)" -ne 0 ]; then fi case "$(uname -s)" in - Darwin) roots=("$HOME/Library/Application Support/cc-remote/current") ;; + Darwin) roots=("${CC_REMOTE_MANAGED_ROOT:-$HOME/Library/Application Support/cc-remote}/current") ;; Linux) selected_role="" role_requested=0 diff --git a/tests/test_claude_background_completion.py b/tests/test_claude_background_completion.py index 5b56b639..e283b33e 100644 --- a/tests/test_claude_background_completion.py +++ b/tests/test_claude_background_completion.py @@ -9,13 +9,91 @@ from cc_remote.wrapper.sdk import ClaudeBackgroundBoundary, ClaudeServiceReplayRequired, SdkHandle from tests.test_claude_autocompact import _machine_with_sdk from tests.test_claude_service import environment, released -from tests.test_claude_steering import NativeClient, assistant, result, until, user +from tests.test_claude_steering import NativeClient, assistant, requesting, result, until, user def task_input(uid, origin): return {**user(uid, "background task finished"), "origin": origin} +@pytest.mark.asyncio +@pytest.mark.parametrize("replay", [False, True]) +@pytest.mark.parametrize("start", [ + requesting(), + {"type": "stream_event", "uuid": "request", "session_id": "native-session", + "event": {"type": "message_start", "message": {"id": "answer", "content": []}}}, +], ids=["request", "stream"]) +async def test_activity_before_human_echo_settles_and_accepts_next_prompt(replay, start): + async with environment() as (service, attach): + client = await attach() + worker = service.sessions[client.id] + native = worker.client + sdk = SdkHandle(WrapperConfig(claude_service_socket=client.connection.socket_path)) + sdk.refresh_goal = AsyncMock(return_value=None) + sdk.applied_auto_compact_mode = sdk.auto_compact_mode + sdk.applied_auto_compact_threshold_tokens = sdk.auto_compact_threshold_tokens + sdk.applied_effort = sdk.effort + sdk.force_reconnect = AsyncMock(side_effect=AssertionError("must preserve native process")) + machine, transport, ctx = _machine_with_sdk(sdk) + machine._configure_claude_sdk_callbacks(ctx, sdk) + runner = None + try: + if replay: + client.next_turn = {"id": "human", "prompt": "inspect"} + await client.query("inspect") + await client.detach() + await released(worker) + else: + sdk.client = client + sdk._start_message_pump() + ctx.state = "running" + ctx.active_msg_id = "human" + runner = ctx.turn_task = asyncio.create_task(machine._run_turn(ctx, "inspect")) + await until(lambda: native.prompts == ["inspect"]) + + for frame in [start.copy(), user("native-human", "inspect"), { + "type": "system", "subtype": "task_started", "task_id": "still-running", + "tool_use_id": "child-tool", "task_type": "local_bash", + "description": "background check", "uuid": "child-start", + "session_id": ctx.session_id, + }, assistant("answer", [{"type": "text", "text": "done"}]), result()]: + await native.queue.put(frame) + await until(lambda: worker.journal.seq == 5) + if replay: + sdk.service_metadata = worker.metadata.copy() + sdk.service_defer_events = True + await sdk.connect(resume_id="native-session", cwd="/tmp") + from cc_remote.wrapper.claude_service import activate + + await activate(machine, ctx) + await until(lambda: ctx.turn_task is None and sdk._background_callbacks_pending == 0) + assert ctx.state == "idle" + assert not ctx.claude_background_followups + assert not sdk._steers.background_id + assert worker.turn is None and worker.background_start is None + assert ctx.claude_active_tasks == {"still-running"} + assert sum(e.type == "turn_end" for e in transport.sent) == 1 + + # Completion releases the next human input without interrupting the + # independent child or resubmitting the recovered prompt. + ctx.state = "running" + ctx.active_msg_id = "next-human" + runner = ctx.turn_task = asyncio.create_task(machine._run_turn(ctx, "next question")) + await until(lambda: native.prompts == ["inspect", "next question"]) + await native.queue.put(user("native-next", "next question")) + await native.queue.put(assistant("next-answer", [{"type": "text", "text": "next answer"}])) + await native.queue.put(result()) + await asyncio.wait_for(runner, 3) + await until(lambda: ctx.state == "idle") + assert ctx.claude_active_tasks == {"still-running"} + assert native.interrupts == 0 and not native.closed + finally: + if ctx.turn_task: + ctx.turn_task.cancel() + await asyncio.gather(ctx.turn_task, return_exceptions=True) + await sdk.detach_for_shutdown() + + @pytest.mark.asyncio @pytest.mark.parametrize("persistent", [False, True]) @pytest.mark.parametrize("managed", [False, True]) diff --git a/tests/test_claude_compaction_flow.py b/tests/test_claude_compaction_flow.py index df33f07c..ab7db037 100644 --- a/tests/test_claude_compaction_flow.py +++ b/tests/test_claude_compaction_flow.py @@ -10,7 +10,7 @@ from cc_remote.protocol import GetContext, ProcessEvent from cc_remote.wrapper.claude_compaction import compact_metadata -from cc_remote.wrapper.sdk import SdkHandle +from cc_remote.wrapper.sdk import ClaudeBackgroundBoundary, SdkHandle from cc_remote.wrapper.stream import StreamTranslator from cc_remote.wrapper.work_context import recover_claude_context_usage from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk @@ -136,7 +136,15 @@ async def on_background(message, _turn): handle.release_background_messages() await asyncio.wait_for(handle._background_callbacks_drained.wait(), 1) assert not any(isinstance(m, SystemMessage) for m in messages) - assert [m.subtype for m in background] == ["status", "compact_boundary"] + assert [m.subtype for m in background if isinstance(m, SystemMessage)] == [ + "status", "compact_boundary"] + # The human terminal also retires the anonymous pre-input activity. + # Its boundary is internal lifecycle bookkeeping, not a second + # compact event or a duplicate native Result in the history. + assert len(background) == 3 + assert isinstance(background[-1], ClaudeBackgroundBoundary) + assert background[-1].identities == (background[0]._cc_background_start["id"],) + assert not any(isinstance(m, ResultMessage) for m in background) assert isinstance(messages[-1], ResultMessage) finally: await handle._stop_message_pump() diff --git a/tests/test_claude_steering.py b/tests/test_claude_steering.py index 8036aaf4..2976b543 100644 --- a/tests/test_claude_steering.py +++ b/tests/test_claude_steering.py @@ -322,6 +322,27 @@ def test_only_main_activity_starts_an_implicit_continuation(): assert ended["__cc_background_end"] == identity and pending.background_id is None +def test_human_echo_adopts_only_implicit_activity_with_legacy_terminal_annotations(): + pending = PendingSteers() + explicit = pending.annotate({**user("older-task"), "origin": ORIGIN})["__cc_background_start"] + # A journal may restore an explicit older continuation beside a controller's + # implicit request claim; neither may erase the other's provenance. + implicit = {"id": "implicit-request", "origin_key": None, "origin": None, "managed": False} + pending.annotate({**requesting(), "__cc_background_start": implicit}) + pending.annotate(user("native-human"), managed_active=True) + absorbed = pending.annotate({**user("absorbed"), "origin": { + "kind": "task-notification", "taskId": "current-task", + }, "__cc_background_start": { + "id": "service-task", "origin_key": '["task-notification", "task", "current-task"]', + "origin": {"kind": "task-notification", "taskId": "current-task"}, "managed": True, + }}, managed_active=True)["__cc_background_start"] + ended = pending.annotate({**result(), "__cc_background_ends": [absorbed["id"]]}, managed_active=True) + assert set(ended["__cc_background_ends"]) == {implicit["id"], absorbed["id"]} + assert pending.background_id == explicit["id"] + pending.annotate({**result(), "origin": ORIGIN}) + assert pending.background_id is None + + @pytest.mark.asyncio @pytest.mark.parametrize("replayed_user", [False, True]) @pytest.mark.parametrize("echo_before_detach", [False, True]) diff --git a/tests/test_devices.py b/tests/test_devices.py index 4a5073f4..8b6b5fae 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -13,7 +13,7 @@ from cc_remote.config import RelayConfig, WrapperConfig from cc_remote import device as device_cli -from cc_remote.protocol import Hello, deserialize, serialize +from cc_remote.protocol import PROTOCOL_VERSION, Hello, deserialize, serialize from cc_remote.relay.auth import SESSION_COOKIE_NAME, session_token_claims from cc_remote.relay import server from cc_remote.relay.devices import DeviceStore @@ -93,6 +93,62 @@ async def scenario(): assert token.encode() not in path.read_bytes() +def test_incompatible_device_is_visible_only_to_its_owner_and_clears_on_reconnect(tmp_path): + cfg = _cfg(tmp_path) + app = create_app(cfg) + with TestClient(app, base_url=cfg.public_origin) as client: + client.post("/api/login", json={"password": cfg.login_password}) + code = client.post("/api/devices/pairing").json()["code"] + paired = client.post("/api/devices/pair", json={ + "code": code, "label": "old device", "platform": "linux", "hostname": "device", + }).json() + machine_id = paired["machine_id"] + headers = {"authorization": f"Bearer {paired['token']}", "x-cc-remote-version": "4.0.0"} + for claimed in ("another-device", machine_id): + with client.websocket_connect("/ws", headers=headers) as wrapper: + wrapper.send_text(json.dumps({ + "v": PROTOCOL_VERSION - 1, "type": "hello", "role": "wrapper", + "machine_id": claimed, + })) + assert wrapper.receive_json()["code"] == "protocol" + with pytest.raises(WebSocketDisconnect): + wrapper.receive_text() + rows = client.get("/api/devices").json()["devices"] + assert [item["machine_id"] for item in rows] == [machine_id] + if claimed != machine_id: + assert "compatibility" not in rows[0] + assert rows[0]["online"] is False + info = rows[0]["compatibility"] + assert info["wrapper_protocol"] == PROTOCOL_VERSION - 1 + assert info["relay_protocol"] == PROTOCOL_VERSION + assert info["wrapper_version"] == "4.0.0" + with TestClient(app, base_url=cfg.public_origin) as stranger: + assert stranger.get("/api/devices").status_code == 401 + # v4.0.1 peers share the wire protocol but do not send a product header. + # Expose the missing version instead of claiming their release matches. + headers.pop("x-cc-remote-version") + with client.websocket_connect("/ws", headers=headers) as wrapper: + wrapper.send_text(serialize(Hello(role="wrapper", machine_id=machine_id))) + _wait_for_device_online(client, machine_id) + legacy = client.get("/api/devices").json()["devices"][0]["compatibility"] + assert legacy["wrapper_version"] is None + assert legacy["wrapper_protocol"] == PROTOCOL_VERSION + deadline = time.monotonic() + 1 + while client.get("/api/devices").json()["devices"][0]["online"]: + assert time.monotonic() < deadline, "old connection did not detach" + time.sleep(0.01) + from cc_remote import __version__ + + headers["x-cc-remote-version"] = __version__ + with client.websocket_connect("/ws", headers=headers) as wrapper: + wrapper.send_text(serialize(Hello(role="wrapper", machine_id=machine_id))) + _wait_for_device_online(client, machine_id) + deadline = time.monotonic() + 1 + while "compatibility" in client.get("/api/devices").json()["devices"][0]: + assert time.monotonic() < deadline, "new hello version did not arrive" + time.sleep(0.01) + + def test_browser_pairs_lists_renames_and_revokes_dynamic_wrapper(tmp_path): server._pair_limiter.reset() cfg = _cfg(tmp_path) diff --git a/tests/test_install_commit.py b/tests/test_install_commit.py index 28af447e..823de123 100644 --- a/tests/test_install_commit.py +++ b/tests/test_install_commit.py @@ -94,9 +94,10 @@ def test_wrapper_registration_matches_the_activation_commit(tmp_path, previous_i else: assert result.returncode == (1 if phase == "fail-output" else 0), result.stderr assert current.resolve() == target - assert cli.read_bytes() == launcher + bound = f"export CC_REMOTE_MANAGED_ROOT={shlex.quote(str(root))}\n".encode() + assert cli.read_bytes().replace(bound, b"", 1) == launcher assert json.loads(metadata.read_text()) == { - "schema": 1, "role": "wrapper", "user": "fixture-user", + "schema": 1, "role": "wrapper", "user": "fixture-user", "service_label": "fixture-wrapper", } if phase == "fail-output": assert "activation was committed" in result.stderr diff --git a/tests/test_product_version.py b/tests/test_product_version.py index d0e9a505..cb297837 100644 --- a/tests/test_product_version.py +++ b/tests/test_product_version.py @@ -13,7 +13,7 @@ def test_product_version_is_consistent_across_runtime_and_web_metadata(): - assert __version__ == "4.0.1" + assert __version__ == "4.0.2" assert re.fullmatch(r"[1-9]\d*\.\d+\.\d+", __version__) package = json.loads((ROOT / "web/package.json").read_text()) diff --git a/tests/test_update.py b/tests/test_update.py index f6c9da3f..f47b396c 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -18,6 +18,7 @@ import pytest from cc_remote import update as updater +from cc_remote import update_relay from cc_remote.__main__ import main from deploy.install_cli import check_destination, install_cli from deploy import install_cli as cli_installer @@ -61,6 +62,8 @@ def native_lock(path): return acquire_install_lock(path) monkeypatch.setattr(updater, "acquire_install_lock", native_lock) monkeypatch.setattr(updater, "require_independent_terminal", lambda: None) + monkeypatch.setattr(update_relay, "relay_origin", lambda _: "https://remote.example.test") + monkeypatch.setattr(update_relay, "relay_release", lambda _: {"version": "4.0.1", "protocol": 72}) return updater.read_installation(root, system, "arm64") @@ -259,6 +262,7 @@ def test_protocol_change_requires_explicit_coordinated_upgrade(tmp_path, monkeyp monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) assert main(["update", "--version", "4.0.1"]) == 1 assert not marker.exists() + monkeypatch.setattr(update_relay, "relay_release", lambda _: {"version": "4.0.1", "protocol": 73}) assert main(["update", "--version", "4.0.1", "--allow-protocol-change"]) == 0 assert len(json.loads(marker.read_text())) == 1 @@ -529,10 +533,27 @@ def fail_metadata(target, content, mode): assert destination.read_bytes() == previous +def test_explicit_macos_registration_binds_existing_root_and_launch_agent(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + source = installation.release / "bin/cc-remote" + source.parent.mkdir() + source.write_bytes((ROOT / "scripts/cc-remote").read_bytes()) + destination = tmp_path / "local/bin/cc-remote" + install_cli(installation.root, destination, role="wrapper", user="service-user", + service_label="org.example.cc-remote") + assert f"export CC_REMOTE_MANAGED_ROOT={shlex.quote(str(installation.root))}" in destination.read_text() + installation = updater.read_installation(installation.root, "darwin", "arm64") + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + assert main(["update", "--version", "4.0.1"]) == 0 + calls = json.loads(marker.read_text()) + assert calls[0][1:] == ["--install-root", str(installation.root), "--service-label", "org.example.cc-remote"] + + def test_macos_upgrade_preserves_operator_environment_and_service_socket(tmp_path): # Execute the installer's actual plist-rendering program against private fixtures. script = (ROOT / "deploy/install-wrapper.sh").read_text() - marker = '"$service_backup" <<\'PY\'\n' + marker = '"$service_backup" "$service_label" <<\'PY\'\n' program = script.split(marker, 1)[1].split("\nPY\n", 1)[0] prior = tmp_path / "previous.plist" environment = { @@ -546,8 +567,9 @@ def test_macos_upgrade_preserves_operator_environment_and_service_socket(tmp_pat subprocess.run([ sys.executable, "-", str(ROOT / "deploy/com.muggle.cc-remote.wrapper.plist.in"), str(destination), str(tmp_path / "current"), str(tmp_path / "home"), - str(tmp_path / "logs"), str(prior), + str(tmp_path / "logs"), str(prior), "org.example.cc-remote", ], input=program, text=True, check=True) result = plistlib.loads(destination.read_bytes()) assert all(result["EnvironmentVariables"][key] == value for key, value in environment.items()) assert result["ProgramArguments"][0] == str(tmp_path / "current/.venv/bin/python") + assert result["Label"] == "org.example.cc-remote" diff --git a/tests/test_update_installer_relay.py b/tests/test_update_installer_relay.py new file mode 100644 index 00000000..10766b9c --- /dev/null +++ b/tests/test_update_installer_relay.py @@ -0,0 +1,119 @@ +"""The downloaded installer must also coordinate an old updater's first upgrade.""" +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys + +import pytest + +from cc_remote import update_relay +from tests.test_update import _installation, _manifest + + +@pytest.mark.parametrize("registered", [False, True]) +@pytest.mark.parametrize("relay_current", [False, True]) +def test_bundle_preflight_updates_relay_before_touching_local_service( + tmp_path, monkeypatch, registered, relay_current, +): + installation = _installation(tmp_path, monkeypatch) + if not registered: + (installation.root / "installation.json").unlink() + bundle = tmp_path / "downloaded" + bundle.mkdir() + (bundle / "release-manifest.json").write_text(json.dumps(_manifest(version="4.0.2"))) + remote = {"version": "4.0.2" if relay_current else "4.0.1", "protocol": 72} + monkeypatch.setattr(update_relay, "relay_release", lambda _: dict(remote)) + monkeypatch.setenv("CC_REMOTE_RELAY_SSH", "operator@relay") + calls = [] + + def ssh(self, command, **kwargs): + assert (installation.root / "current").resolve() == installation.release + assert (installation.root / "native-service-alive").read_text() == "still running\n" + calls.append(command) + if "-c" in command: + return '{"domain":"remote.example.test"}' + if "systemd-run" in command: + assert command[-2:] == ["--version", "4.0.2"] + remote["version"] = "4.0.2" + return "LoadState=loaded\nActiveState=active\nSubState=exited\nResult=success\nExecMainStatus=0\n" + + monkeypatch.setattr(update_relay.RelayUpdate, "_ssh", ssh) + assert update_relay.installer_main([ + "--root", str(installation.root), "--bundle", str(bundle), "--user", "service-user", + ]) == 0 + assert bool(calls) is not relay_current + assert (installation.root / "current").resolve() == installation.release + assert (installation.root / "installation.json").exists() is registered + + +@pytest.mark.parametrize("outcome", ["current", "unreachable", "protocol-change"]) +def test_real_installer_block_runs_new_bundle_preflight_before_local_stop(tmp_path, outcome): + root = tmp_path / "installed" + previous = root / "releases/old" + previous.mkdir(parents=True) + (root / "current").symlink_to(previous) + (previous / "release-manifest.json").write_text(json.dumps(_manifest(version="4.0.1"))) + target = root / "releases/new" + (target / ".venv/bin").mkdir(parents=True) + (target / "release-manifest.json").write_text(json.dumps(_manifest(version="4.0.2"))) + if outcome == "protocol-change": + manifest = _manifest(version="4.0.2") + manifest["protocol_version"] = 73 + (target / "release-manifest.json").write_text(json.dumps(manifest)) + repo = Path(__file__).resolve().parents[1] + driver = tmp_path / "new-bundle.py" + driver.write_text( + f"import sys; sys.path.insert(0, {str(repo)!r})\n" + "from cc_remote import update_relay as m\n" + "m.relay_origin = lambda _: 'https://remote.example.test'\n" + f"outcome = {outcome!r}\n" + "def read(origin):\n" + " if outcome == 'unreachable': raise m.UpdateError('Relay unavailable')\n" + " return {'version': '4.0.2', 'protocol': 72}\n" + "m.relay_release = read\n" + "raise SystemExit(m.installer_main(sys.argv[1:]))\n" + ) + python = target / ".venv/bin/python" + python.write_text( + "#!/bin/sh\n" + '[ "$1" = "-m" ] && [ "$2" = "cc_remote.update_relay" ] || exit 98\n' + f"shift 2\nexec {shlex.quote(sys.executable)} {shlex.quote(str(driver))} \"$@\"\n" + ) + python.chmod(0o755) + source = (repo / "deploy/install-wrapper.sh").read_text() + start = source.index("# Older update commands") + stop = source.index("# Mutable Work metadata", start) + assert stop < source.index('if [ "$service_was_running" -eq 1 ]; then', stop) + settings = {"previous": str(previous), "pair_code": "", "appdir": str(root), + "target": str(target), "target_user": "service-user", "system": "darwin", + "service_label": "org.example.wrapper", "relay_ssh": "", "allow_protocol_change": "0"} + marker = tmp_path / "local-service-stopped" + # A v4.0.1 caller has no new CLI flags or coordination environment marker. + harness = "set -euo pipefail\n" + "\n".join( + f"{key}={shlex.quote(value)}" for key, value in settings.items() + ) + "\n" + source[start:stop] + f"touch {shlex.quote(str(marker))}\n" + result = subprocess.run(["bash", "-c", harness], capture_output=True, text=True, + env={key: value for key, value in os.environ.items() + if key != "CC_REMOTE_RELAY_SSH"}, timeout=10) + assert result.returncode == (0 if outcome == "current" else 1), result.stderr + assert marker.exists() is (outcome == "current") + assert (root / "current").resolve() == previous + + +def test_first_upgrade_can_request_an_existing_ssh_target(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + monkeypatch.delenv("CC_REMOTE_RELAY_SSH", raising=False) + relay = update_relay.RelayUpdate(installation) + remote = {"version": "4.0.0", "protocol": 72} + monkeypatch.setattr(update_relay, "relay_release", lambda _: dict(remote)) + monkeypatch.setattr(update_relay.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _: "operator@relay") + def verify(): + assert relay.target == "operator@relay" + monkeypatch.setattr(relay, "_verify_host", verify) + monkeypatch.setattr(relay, "_ssh", lambda *args, **kwargs: "") + monkeypatch.setattr(relay, "_finish", lambda _: remote.update(version="4.0.1")) + relay.ensure("4.0.1", 72, allow_protocol_change=False) + assert json.loads(relay.settings.read_text()) == {"relay_ssh": "operator@relay"} diff --git a/tests/test_update_relay.py b/tests/test_update_relay.py new file mode 100644 index 00000000..87f58f4f --- /dev/null +++ b/tests/test_update_relay.py @@ -0,0 +1,115 @@ +"""Offline end-to-end updater coordination; no SSH or live service mutations.""" +import json + +import pytest + +from cc_remote import update_relay +from cc_remote.__main__ import main +from cc_remote.update import UpdateError +from tests.test_update import _bundle, _installation + + +@pytest.mark.parametrize("relay_current", [False, True]) +def test_device_update_updates_relay_first_or_skips_it_when_current(tmp_path, monkeypatch, relay_current): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + remote = {"version": "4.0.1" if relay_current else "4.0.0", "protocol": 72} + monkeypatch.setattr(update_relay, "relay_release", lambda _: dict(remote)) + calls = [] + + def ssh(self, command, **kwargs): + assert not marker.exists(), "device was activated before the Relay" + calls.append(command) + if "-c" in command: + return json.dumps({"domain": "remote.example.test", "version": remote["version"]}) + if "systemd-run" in command: + assert command[-5:] == ["update", "--role", "relay", "--version", "4.0.1"] + remote["version"] = "4.0.1" + return "" + return "LoadState=loaded\nActiveState=active\nSubState=exited\nResult=success\nExecMainStatus=0\n" + + monkeypatch.setattr(update_relay.RelayUpdate, "_ssh", ssh) + assert main(["update", "--version", "4.0.1", "--relay-ssh", "operator@relay"]) == 0 + assert marker.exists() + assert bool(calls) is not relay_current + assert json.loads((installation.root / "update.json").read_text()) == {"relay_ssh": "operator@relay"} + if not relay_current: + assert json.loads((installation.root / "upstream-update.json").read_text())["complete"] + + +def test_interrupted_upstream_update_rechecks_the_same_job_without_relaunch(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + remote = {"version": "4.0.0", "protocol": 72} + monkeypatch.setattr(update_relay, "relay_release", lambda _: dict(remote)) + launches = [] + + def ssh(self, command, **kwargs): + if "-c" in command: + return '{"domain":"remote.example.test","version":"4.0.0"}' + if "systemd-run" in command: + launches.append(command) + raise UpdateError("SSH acknowledgement lost") + remote["version"] = "4.0.1" + return "LoadState=loaded\nActiveState=active\nSubState=exited\nResult=success\nExecMainStatus=0\n" + + monkeypatch.setattr(update_relay.RelayUpdate, "_ssh", ssh) + assert main(["update", "--version", "4.0.1", "--relay-ssh", "relay"]) == 1 + assert not marker.exists() + transaction = json.loads((installation.root / "upstream-update.json").read_text()) + assert transaction["complete"] is False + assert main(["update", "--version", "4.0.1"]) == 0 + assert len(launches) == 1 + assert marker.exists() + + +@pytest.mark.parametrize("failure", ["wrong-host", "failed-job", "unknown-job", "no-access"]) +def test_failed_upstream_verification_never_activates_device(tmp_path, monkeypatch, failure): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + monkeypatch.setattr(update_relay, "relay_release", lambda _: {"version": "4.0.0", "protocol": 72}) + + def ssh(self, command, **kwargs): + if failure == "no-access": + raise UpdateError("no SSH access") + if "-c" in command: + return json.dumps({"domain": "wrong.example" if failure == "wrong-host" else "remote.example.test"}) + if "systemd-run" in command: + return "" + return "LoadState=not-found\n" if failure == "unknown-job" else "LoadState=loaded\nActiveState=failed\n" + + monkeypatch.setattr(update_relay.RelayUpdate, "_ssh", ssh) + assert main(["update", "--version", "4.0.1", "--relay-ssh", "relay"]) == 1 + assert not marker.exists() + assert (installation.root / "current").resolve() == installation.release + + +def test_current_device_still_checks_upstream_and_check_mode_never_runs_ssh(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + monkeypatch.setattr(update_relay, "relay_release", lambda _: {"version": "3.9.9", "protocol": 72}) + before = set(installation.root.iterdir()) + monkeypatch.setattr(update_relay.RelayUpdate, "_ssh", lambda *a, **kw: pytest.fail("unexpected SSH")) + assert main(["update", "--check", "--version", "4.0.0", "--relay-ssh", "relay"]) == 0 + assert set(installation.root.iterdir()) == before + monkeypatch.setattr(update_relay.RelayUpdate, "_verify_host", lambda _: (_ for _ in ()).throw(UpdateError("need SSH"))) + assert main(["update", "--version", "4.0.0"]) == 1 + + +@pytest.mark.parametrize("target", ["-oProxyCommand=bad", "host;touch /tmp/no", "user@host\ncmd", "ssh://host"]) +def test_ssh_target_is_data_not_shell_syntax(tmp_path, monkeypatch, target): + installation = _installation(tmp_path, monkeypatch) + with pytest.raises(UpdateError, match="SSH host alias"): + update_relay.RelayUpdate(installation, target) + + +def test_legacy_relay_requires_web_and_protocol_to_agree(monkeypatch): + payloads = {"/healthz": {"ok": True, "protocol": 72}, + "/cc-remote-build.json": {"version": "4.0.1", "protocol": 72}} + monkeypatch.setattr(update_relay, "_get_json", lambda url: payloads[url.removeprefix("https://relay")]) + assert update_relay.relay_release("https://relay") == {"version": "4.0.1", "protocol": 72} + payloads["/cc-remote-build.json"]["protocol"] = 73 + with pytest.raises(UpdateError, match="inconsistent"): + update_relay.relay_release("https://relay") diff --git a/tests/test_wrapper_startup.py b/tests/test_wrapper_startup.py index 4c25af4b..60823d8c 100644 --- a/tests/test_wrapper_startup.py +++ b/tests/test_wrapper_startup.py @@ -119,7 +119,7 @@ def unexpected(): assert receipt["profiles"] == [{"profile": "primary", "status": "disabled"}] -def test_wrapper_entrypoint_prepares_codex_before_run(monkeypatch) -> None: +def test_wrapper_entrypoint_initializes_work_before_optional_codex_probe(monkeypatch) -> None: events: list[str] = [] cfg = WrapperConfig() @@ -132,7 +132,11 @@ class FakeMachine: def __init__(self, _cfg, _transport) -> None: events.append("machine") + async def initialize_work(self) -> None: + events.append("work-ready") + async def prepare_codex_daemons(self) -> None: + assert "work-ready" in events events.append("prepare") async def run(self) -> None: @@ -160,4 +164,21 @@ async def run(self): asyncio.run(wrapper_main.main()) - assert events == ["scrub", "transport", "machine", "prepare", "run", "viewer-start", "viewer-stop"] + assert events == ["scrub", "transport", "machine", "work-ready", "prepare", "run", "viewer-start", "viewer-stop"] + + +def test_work_initialization_is_not_repeated_when_run_follows_prewarm(tmp_path): + cfg = WrapperConfig() + cfg.state_dir = tmp_path / "state" + cfg.claude_work_root = tmp_path / "claude" + cfg.codex_work_root = tmp_path / "codex" + machine = WrapperMachine(cfg, _Transport()) + calls = [] + machine._work = SimpleNamespace(initialize=lambda: calls.append("initialized")) + + async def run(): + await machine.initialize_work() + await machine.initialize_work() + + asyncio.run(run()) + assert calls == ["initialized"] diff --git a/web/package-lock.json b/web/package-lock.json index 79baa437..7ded58d6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "4.0.1", + "version": "4.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "4.0.1", + "version": "4.0.2", "license": "MIT", "dependencies": { "@tanstack/react-virtual": "3.14.8", diff --git a/web/package.json b/web/package.json index d3f3364a..02a1950a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "4.0.1", + "version": "4.0.2", "author": "muggle", "license": "MIT", "type": "module", diff --git a/web/public/cc-remote-build.json b/web/public/cc-remote-build.json index 841efd59..e3270f8f 100644 --- a/web/public/cc-remote-build.json +++ b/web/public/cc-remote-build.json @@ -1,4 +1,4 @@ { - "version": "4.0.1", + "version": "4.0.2", "protocol": 72 } diff --git a/web/src/App.tsx b/web/src/App.tsx index 9e0211c7..499167c4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -50,6 +50,7 @@ import { WorkDashboardSheet } from "./components/WorkDashboardSheet"; import type { HookDraft, SkillDraft } from "./components/CapabilitiesSheet"; import { TerminalControl } from "./components/TerminalControl"; import { DeviceSheet, type PairingState, type RemoteDevice } from "./components/DeviceSheet"; +import { deviceVersionNotice } from "./device-version"; import { EngineSelector } from "./components/EngineSelector"; import { claudeProfileIdForSession, @@ -1551,7 +1552,7 @@ export default function App() { } setDevicesLoadState("loading"); let cancelled = false; - void fetch("/api/devices", { + const refreshDevices = () => void fetch("/api/devices", { credentials: "same-origin", cache: "no-store", }).then(async (response) => response.ok ? response.json() : null) .then((payload) => { @@ -1577,8 +1578,10 @@ export default function App() { }).catch(() => { if (!cancelled) setDevicesLoadState("error"); }); - return () => { cancelled = true; }; - }, [authed, machineId]); + refreshDevices(); + const timer = window.setInterval(refreshDevices, 15000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [authed, machineId, state.connState, state.wrapperOnline]); useEffect(() => { if (!("serviceWorker" in navigator)) return; @@ -5498,6 +5501,7 @@ export default function App() { }; const activeDevice = remoteDevices.find( (device) => device.machine_id === machineId); + const versionNotice = deviceVersionNotice(activeDevice?.compatibility); const activeDeviceOnline = state.connState === "connected" && state.wrapperOnline; // A native client can advance the transcript without a wrapper-owned turn. // Present that mirrored activity as running in every status surface while @@ -5691,6 +5695,7 @@ export default function App() { /> + {versionNotice &&
{versionNotice}
} {device.label}{device.platform || "手工配置"}{device.hostname ? ` · ${device.hostname}` : ""} - {device.online ? "在线" : "离线"} + {device.compatibility && device.compatibility.wrapper_protocol !== device.compatibility.relay_protocol + ? "需要更新" : device.online ? "在线" : "离线"} + {deviceVersionNotice(device.compatibility) &&

+ {deviceVersionNotice(device.compatibility)} +

} {editing === device.machine_id &&
{ event.preventDefault(); void saveLabel(device); }}>