diff --git a/.agents/skills/cc-remote-deploy/SKILL.md b/.agents/skills/cc-remote-deploy/SKILL.md index 08d3f801..93082e04 100644 --- a/.agents/skills/cc-remote-deploy/SKILL.md +++ b/.agents/skills/cc-remote-deploy/SKILL.md @@ -45,6 +45,14 @@ Check the daily CLI and Wrapper's actual connection to the **same official daemon**, not just matching session files or a healthy Web UI. Accounts keep separate `CODEX_HOME` boundaries; Work's private control plane is not changed. +Release installers print Wrapper startup's per-account connection result. This +uses the actual service user/environment and a fresh release/process-bound +receipt; it never sends a model turn. Treat it as transport readiness only: +normal terminal auto-discovery still requires the acceptance evidence below. +Startup reuses existing native servers without restarting them or changing +Codex's separate cloud remote-control setting. Preserve and report explicit +`off`, missing CLI, incompatible versions or failed probes. + Do not assume npm versus standalone decides sharing. Verify ordinary `codex resume ` routing; explicit `--remote` success is not proof of automatic discovery. Never kill a live CLI, force takeover, modify shell aliases, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cbc0e0c..cbdcb340 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,6 +159,8 @@ jobs: PYTHONPATH="$bundle" "$bundle/.smoke-venv/bin/python" \ -m deploy.release_manifest "$bundle" \ --role "$RELEASE_ROLE" --os "$RELEASE_OS" --arch "$RELEASE_ARCH" + PYTHONPATH="$bundle" "$bundle/.smoke-venv/bin/python" -m cc_remote --help + PYTHONPATH="$bundle" "$bundle/.smoke-venv/bin/python" -m cc_remote update --help if [ "$RELEASE_ROLE" = wrapper ]; then PYTHONPATH="$bundle" "$bundle/.smoke-venv/bin/python" -c \ 'from cc_remote.wrapper.machine import WrapperMachine; assert WrapperMachine' diff --git a/CHANGELOG.md b/CHANGELOG.md index 608d4726..4c72953e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,22 @@ [中文](CHANGELOG_zh.md) -## Unreleased +## v4.0.1 + +Add managed release updates and verify Codex shared connections after activation. +The wire protocol remains v72. See the bilingual [release notes](docs/releases/v4.0.1.md) +for the one-time upgrade from v4.0.0. + +- Add `cc-remote update` for installer-managed local Relay/Wrapper releases, + with stable-version discovery, check-only and pinned-version modes, verified + bundles, preserved service identity, exclusive activation and protocol-change + guards. Reuse immutable installation and rollback; leave independent Claude + services and custom deployments under their existing lifecycle. +- Preserve operator-provided macOS Wrapper environment settings during upgrades. +- Prepare account-scoped Codex daemons without restarting active native clients, + check CLI and Wrapper transport readiness, and print the result after activation. + Report unavailable shared connections instead of silently falling back to a + private server; preserve explicit opt-outs and existing terminal tasks. ## v4.0.0 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index bc8bed50..65869e10 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -2,7 +2,18 @@ [English](CHANGELOG.md) -## 未发布 +## v4.0.1 + +新增 Release 更新命令,并在激活后检查 Codex 共享连接。通信协议仍为 v72。 +从 v4.0.0 首次升级的方法见[双语发布说明](docs/releases/v4.0.1.md)。 + +- 新增 `cc-remote update`,用于安装器管理的本机 Relay/Wrapper:发现最新稳定版、 + 只查更新、指定版本、校验安装包、保留服务身份、避免并发激活,并拦截未协调的协议 + 升级。复用不可变安装与回滚,独立 Claude 服务和自定义部署保留原有生命周期。 +- macOS Wrapper 升级时保留用户原有环境配置。 +- 按账号准备 Codex 共享 daemon,保留正在运行的原生客户端,检查 CLI 与 Wrapper + 的实际连接并在安装后显示结果。默认共享连接不可用时明确报错;保留用户关闭共享的 + 设置,旧的独立终端任务结束后再重新打开。 ## v4.0.0 diff --git a/README.md b/README.md index e075a1fa..123c4f72 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · Web / PWA / TUI -**产品版本:v4.0.0** · Wire protocol v72 +**产品版本:v4.0.1** · Wire protocol v72 [English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) · [终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md) @@ -218,6 +218,10 @@ CLAUDE_BIN= ## 安装与升级 +包含管理命令的 Release 安装支持 `cc-remote update --check` 检查更新、 +`cc-remote update` 升级。v4.0.0 首次过渡、角色选择和协议协调升级见 +[后续更新](docs/installation.md#后续更新)。 + | 场景 | 文档 | |---|---| | 使用当前功能(推荐)、部署开发分支 | [源码部署](docs/installation.md#source-install):使用同一份测试通过的快照 | diff --git a/README_en.md b/README_en.md index 133499e0..a379a7f9 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.0** · Wire protocol v72 +**Product version: v4.0.1** · 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) @@ -243,6 +243,11 @@ below or configure a restricted LAN/Tailscale entry point. ## Install and upgrade +Release installations that include the management CLI support +`cc-remote update --check` and `cc-remote update`; see +[subsequent updates](docs/installation_en.md#subsequent-updates) for the initial +upgrade from v4.0.0, role selection and coordinated protocol upgrades. + | Scenario | Guide | |---|---| | Use current features (recommended) or a development branch | [Source deployment](docs/installation_en.md#source-install): use one tested snapshot | diff --git a/cc_remote/__init__.py b/cc_remote/__init__.py index e1fc0abe..08fd6547 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.0" +__version__ = "4.0.1" diff --git a/cc_remote/__main__.py b/cc_remote/__main__.py new file mode 100644 index 00000000..3bd30672 --- /dev/null +++ b/cc_remote/__main__.py @@ -0,0 +1,38 @@ +"""Release-management CLI: python -m cc_remote (or cc-remote).""" +from __future__ import annotations + +import argparse +import sys + +from cc_remote import __version__ +from cc_remote.update import UpdateError, update + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="cc-remote") + parser.add_argument("--version", action="version", version=f"cc-remote {__version__}") + commands = parser.add_subparsers(dest="command", required=True) + command = commands.add_parser("update", help="update a local Release installation") + 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( + "--allow-protocol-change", action="store_true", + help="activate a protocol change during a coordinated multi-machine upgrade", + ) + args = parser.parse_args(argv) + try: + return update( + role=args.role, target_version=args.target_version, check=args.check, + allow_protocol_change=args.allow_protocol_change, + ) + except (UpdateError, OSError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("Update interrupted. Inspect the installation before retrying.", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cc_remote/update.py b/cc_remote/update.py new file mode 100644 index 00000000..7b07a9cb --- /dev/null +++ b/cc_remote/update.py @@ -0,0 +1,380 @@ +"""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. +""" +from __future__ import annotations + +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +import ast +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import re +import subprocess +import sys +import tarfile +import tempfile +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen + +from deploy.release_manifest import ReleaseManifestError, load_manifest +from deploy.install_lock import LOCK_FD_ENV, InstallLockError, acquire_install_lock + + +class UpdateError(ValueError): + pass + + +_VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") +_MAX_ARCHIVE = 256 * 1024 * 1024 +_MAX_EXPANDED = 1024 * 1024 * 1024 + + +def version_tuple(value: str) -> tuple[int, ...]: + if not isinstance(value, str) or len(value) > 64 or not _VERSION.fullmatch(value): + raise UpdateError("version must be an exact stable version, such as 4.0.1") + return tuple(map(int, value.split("."))) + + +def host_platform() -> tuple[str, str]: + system = platform.system().lower() + machine = platform.machine().lower() + machine = {"aarch64": "arm64", "amd64": "x86_64"}.get(machine, machine) + if system not in {"linux", "darwin"} or machine not in {"x86_64", "arm64"}: + raise UpdateError("published updates support macOS/Linux on x86_64 or arm64") + return system, machine + + +@dataclass(frozen=True) +class Installation: + root: Path + release: Path + manifest: dict + metadata: dict + + @property + def role(self) -> str: + return self.manifest["role"] + + +def installation_roots(system: str) -> dict[str, Path]: + if system == "darwin": + return {"wrapper": Path.home() / "Library/Application Support/cc-remote"} + return {"relay": Path("/opt/cc-remote"), "wrapper": Path("/opt/cc-remote-wrapper")} + + +def read_installation(root: Path, system: str, machine: str) -> Installation: + metadata_path = root / "installation.json" + current = root / "current" + if metadata_path.is_symlink() or not current.is_symlink(): + raise UpdateError(f"not a managed Release installation: {root}") + try: + metadata = json.loads(metadata_path.read_text()) + release = current.resolve(strict=True) + manifest = load_manifest(release / "release-manifest.json") + except (OSError, ValueError, TypeError) as exc: + raise UpdateError(f"cannot read managed installation at {root}") from exc + if release.parent != (root / "releases").resolve(): + raise UpdateError(f"current points outside the managed releases directory: {root}") + if ( + not isinstance(metadata, dict) or type(metadata.get("schema")) is not int + or metadata["schema"] != 1 + or metadata.get("role") != manifest["role"] + or (manifest["os"], manifest["arch"]) != (system, machine) + ): + raise UpdateError(f"installation metadata does not match this host: {root}") + version_tuple(manifest["product_version"]) + if type(manifest["protocol_version"]) is not int or manifest["protocol_version"] < 1: + raise UpdateError("installation has an invalid protocol version") + if manifest["role"] == "wrapper": + 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") + elif not isinstance(metadata.get("domain"), str) or not re.fullmatch( + r"[a-z0-9][a-z0-9.-]*\.[a-z0-9.-]+", metadata["domain"] + ): + raise UpdateError("relay installation has no valid original domain") + return Installation(root, release, manifest, metadata) + + +def select_installation(role: str | None, system: str, machine: str) -> Installation: + found = [] + for expected_role, root in installation_roots(system).items(): + # Standard roots have fixed roles. Do not read unrelated installations + # when an operator explicitly selects one role for an update. + if role is not None and expected_role != role: + continue + if not (root / "installation.json").exists(): + continue + installation = read_installation(root, system, machine) + if installation.role != expected_role: + raise UpdateError(f"installation role does not match its managed directory: {root}") + found.append(installation) + if not found: + raise UpdateError( + "no managed Release installation found; install a release containing this " + "command first. Source, Docker and custom deployments keep their own upgrade procedure" + ) + if len(found) != 1: + raise UpdateError("both roles are installed; select --role relay or --role wrapper") + return found[0] + + +def release_repository() -> str: + value = os.environ.get("CC_REMOTE_GITHUB_REPOSITORY", "muggle-stack/cc-remote") + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", value): + raise UpdateError("CC_REMOTE_GITHUB_REPOSITORY must be owner/repository") + return value + + +def latest_version(repository: str) -> str: + request = Request( + f"https://api.github.com/repos/{repository}/releases/latest", + headers={"Accept": "application/vnd.github+json", "User-Agent": "cc-remote-updater"}, + ) + try: + with urlopen(request, timeout=20) as response: + payload = response.read(1024 * 1024 + 1) + if len(payload) > 1024 * 1024: + raise ValueError("oversized response") + data = json.loads(payload) + tag = data["tag_name"] + if data.get("draft") is not False or data.get("prerelease") is not False or not tag.startswith("v"): + raise ValueError("not a stable release") + version = tag[1:] + version_tuple(version) + return version + except HTTPError as exc: + raise UpdateError(f"GitHub release lookup failed (HTTP {exc.code}); try later or use --version") from exc + except (URLError, OSError, ValueError, KeyError, TypeError, AttributeError) as exc: + raise UpdateError("cannot determine the latest stable release; try later or use --version") from exc + + +def release_base(repository: str, version: str) -> str: + value = os.environ.get( + "CC_REMOTE_RELEASE_BASE_URL", + f"https://github.com/{repository}/releases/download/v{version}", + ).rstrip("/") + parsed = urlsplit(value) + if ( + parsed.scheme not in {"https", "file"} or parsed.username or parsed.password + or parsed.query or parsed.fragment + or (parsed.scheme == "https" and not parsed.hostname) + or (parsed.scheme == "file" and (parsed.netloc or not parsed.path.startswith("/"))) + ): + raise UpdateError("release base must be an HTTPS URL or an absolute local file:// directory") + return value + + +def _download(url: str, destination: Path, limit: int) -> None: + result = subprocess.run( + ["curl", "--fail", "--location", "--proto", "=https,file", "--proto-redir", "=https", + "--tlsv1.2", "--connect-timeout", "15", "--max-time", "300", + "--retry", "2", "--retry-max-time", "60", "--max-filesize", str(limit), + "--silent", "--show-error", "--output", str(destination), url], + check=False, + ) + if result.returncode or not destination.is_file() or destination.stat().st_size > limit: + raise UpdateError(f"download failed for {destination.name}; no service was changed") + + +def download_bundle(installation: Installation, version: str, base: str, stage: Path) -> Path: + manifest = installation.manifest + name = f"cc-remote-{installation.role}-v{version}-{manifest['os']}-{manifest['arch']}.tar.gz" + checksum = stage / "SHA256SUMS" + archive_path = stage / name + _download(f"{base}/SHA256SUMS", checksum, 1024 * 1024) + matches = [] + try: + lines = checksum.read_text().splitlines() + except UnicodeError as exc: + raise UpdateError("release checksum file is invalid") from exc + for line in lines: + fields = line.split() + if len(fields) == 2 and fields[1].lstrip("*").removeprefix("./") == name: + matches.append(fields[0]) + if len(matches) != 1 or not re.fullmatch(r"[0-9a-fA-F]{64}", matches[0]): + raise UpdateError(f"missing or ambiguous SHA256SUMS entry for {name}") + _download(f"{base}/{name}", archive_path, _MAX_ARCHIVE) + with archive_path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + if digest != matches[0].lower(): + raise UpdateError("release SHA-256 verification failed; no service was changed") + prefix = f"cc-remote-{installation.role}-v{version}" + try: + with tarfile.open(archive_path, "r:gz") as archive: + members = [] + names = set() + expanded = 0 + for member in archive: + members.append(member) + path = PurePosixPath(member.name) + expanded += member.size + if ( + not path.parts or path.parts[0] != prefix or ".." in path.parts + or path.is_absolute() or not (member.isfile() or member.isdir()) + or path.as_posix() in names or expanded > _MAX_EXPANDED + or len(members) > 65536 + ): + raise UpdateError("release archive contains unsafe entries") + names.add(path.as_posix()) + archive.extractall(stage, members=members, filter="data") + except (tarfile.TarError, EOFError) as exc: + raise UpdateError("release archive is invalid") from exc + bundle = stage / prefix + try: + target = load_manifest(bundle / "release-manifest.json") + except (ReleaseManifestError, TypeError) as exc: + raise UpdateError("downloaded release manifest is invalid") from exc + for key in ("role", "os", "arch"): + if target[key] != manifest[key]: + raise UpdateError(f"downloaded release {key} does not match the installation") + if target["product_version"] != version: + raise UpdateError("downloaded release does not match the selected version") + if type(target["protocol_version"]) is not int or target["protocol_version"] < 1: + raise UpdateError("downloaded release has an invalid protocol version") + return bundle + + +def _claude_contract(release: Path) -> tuple[str, int]: + """Read constants without importing an SDK or executing the new release.""" + try: + lock = (release / "requirements-wrapper.lock").read_text() + sdk = re.search(r"^claude-agent-sdk==([0-9.]+)(?:\s|$)", lock, re.MULTILINE) + tree = ast.parse((release / "cc_remote/claude_service/wire.py").read_text()) + for statement in tree.body: + if isinstance(statement, ast.Assign) and any( + isinstance(name, ast.Name) and name.id == "VERSION" for name in statement.targets + ): + wire = ast.literal_eval(statement.value) + if sdk and type(wire) is int and wire > 0: + return sdk[1], wire + except (OSError, ValueError, SyntaxError): + pass + raise UpdateError("cannot verify Claude service compatibility; use the documented service upgrade procedure") + + +@contextmanager +def update_lock(root: Path): + try: + descriptor = acquire_install_lock(root) + except InstallLockError as exc: + raise UpdateError(str(exc)) from exc + try: + # The installer inherits this descriptor so loss of its controller does + # not let a second update enter an activation whose result is unknown. + yield descriptor + finally: + os.close(descriptor) + + +def require_independent_terminal() -> None: + """A restart must not kill its own updater halfway through activation.""" + pid = os.getppid() + for _ in range(128): + if pid <= 1: + return + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "ppid=", "-o", "args="], + capture_output=True, text=True, timeout=5, check=False, + ) + except subprocess.TimeoutExpired as exc: + raise UpdateError("cannot verify updater ancestry; run from an independent terminal") from exc + fields = result.stdout.strip().split(maxsplit=1) + if result.returncode or len(fields) != 2: + raise UpdateError("cannot verify updater ancestry; run from an independent terminal") + if re.search(r"(?:^|\s)-m\s+cc_remote\.(?:wrapper|relay)(?:\s|$)", fields[1]): + raise UpdateError("run update from an independent terminal/SSH session, outside cc-remote") + try: + parent = int(fields[0]) + except ValueError as exc: + raise UpdateError("cannot verify updater ancestry; run from an independent terminal") from exc + if parent == pid: + break + pid = parent + raise UpdateError("cannot verify updater ancestry") + + +def run_installer(command: list[str], lock_descriptor: int) -> int: + # subprocess.run kills its child on KeyboardInterrupt. A role installer may + # already be rolling back in its INT trap, so leave it alive until it exits. + process = subprocess.Popen( + command, pass_fds=(lock_descriptor,), + env={**os.environ, LOCK_FD_ENV: str(lock_descriptor)}, + ) + while True: + try: + return process.wait() + except KeyboardInterrupt: + print("\nWaiting for installer shutdown/rollback; do not start a second update.", file=sys.stderr) + + +def update(*, role: str | None, target_version: str | None, check: bool, + allow_protocol_change: bool = False) -> int: + system, machine = host_platform() + installation = select_installation(role, system, machine) + repository = release_repository() + version = target_version or latest_version(repository) + selected = version_tuple(version) + if not check and system == "linux" and os.geteuid() != 0: + raise UpdateError("Linux activation needs root; run sudo cc-remote update") + if not check and system == "darwin" and os.geteuid() == 0: + raise UpdateError("run macOS updates as the desktop user, without sudo") + with (nullcontext() if check else update_lock(installation.root)) as lock_descriptor: + if not check: + # A switched current link is provisional until its installer releases + # the lock. Re-read it before even reporting a no-op as successful. + fresh = read_installation(installation.root, system, machine) + if fresh.role != installation.role: + raise UpdateError("installation role changed; inspect it before retrying") + installation = fresh + current = version_tuple(installation.manifest["product_version"]) + print(f"{installation.role}: installed {installation.manifest['product_version']}; selected {version}", flush=True) + if selected <= current: + if target_version and selected < current: + raise UpdateError("update does not downgrade private state; use the documented rollback procedure") + print("Already up to date." if selected == current else "Installed version is newer than the latest release.") + return 0 + if check: + print(f"Update available: cc-remote update --role {installation.role} --version {version}") + return 0 + require_independent_terminal() + with tempfile.TemporaryDirectory(prefix="cc-remote-update-") as temporary: + stage = Path(temporary) + print(f"Downloading and verifying {installation.role} v{version}...", flush=True) + bundle = download_bundle(installation, version, release_base(repository, version), stage) + target = load_manifest(bundle / "release-manifest.json") + if installation.role == "wrapper" and _claude_contract(installation.release) != _claude_contract(bundle): + raise UpdateError( + "Claude SDK/service compatibility changes in this release; drain native work and " + "follow docs/claude-session-service.md before upgrading. No service was changed" + ) + if target["protocol_version"] != installation.manifest["protocol_version"]: + if not allow_protocol_change: + raise UpdateError( + "wire protocol changes in this release; coordinate Relay/Web and every Wrapper, " + "then repeat with --allow-protocol-change. No service was changed" + ) + print("Protocol upgrade: coordinate every machine and reload Web/PWA clients.", flush=True) + fresh = read_installation(installation.root, system, machine) + if fresh != installation: + raise UpdateError("installation changed while downloading; inspect it before retrying") + 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"]] + 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") + active = read_installation(installation.root, system, machine) + if active.manifest != target: + raise UpdateError("installer returned without activating the selected release; inspect current") + print(f"Updated {installation.role} to {version}.") + return 0 diff --git a/cc_remote/wrapper/codex_daemon.py b/cc_remote/wrapper/codex_daemon.py index 24fc13be..d0378940 100644 --- a/cc_remote/wrapper/codex_daemon.py +++ b/cc_remote/wrapper/codex_daemon.py @@ -416,6 +416,8 @@ def _ensure_managed_daemon_nofile( codex_bin: str, env: Mapping[str, str], lifecycle: Mapping[str, Any], + *, + allow_local_listener: bool = False, ) -> Optional[bool]: """Raise and verify the actual managed Linux daemon's file limit. @@ -454,10 +456,19 @@ def _ensure_managed_daemon_nofile( except OSError: return False argv = tuple(value for value in raw_cmdline.split(b"\0") if value) + listeners = [ + argv[index + 1] for index, value in enumerate(argv[:-1]) + if value == b"--listen" + ] + [value.split(b"=", 1)[1] for value in argv if value.startswith(b"--listen=")] + expected_socket = os.fsencode(str(daemon_root.parent / "app-server-control/app-server-control.sock")) + local_listener = allow_local_listener and listeners in ( + [b"unix://" + expected_socket], + [b"unix://"], + ) if ( executable != expected_executable or b"app-server" not in argv - or b"--remote-control" not in argv + or (b"--remote-control" not in argv and not local_listener) ): return False try: @@ -684,11 +695,13 @@ def __init__( socket_path: Optional[str] = None, command_timeout: float = _COMMAND_TIMEOUT, require_shared: bool = False, + allow_restart: bool = True, ): self.mode = codex_daemon_mode(mode) self.socket_path = socket_path self.command_timeout = max(1.0, float(command_timeout)) self.require_shared = bool(require_shared) + self.allow_restart = bool(allow_restart) self._lock = asyncio.Lock() self._capability_identity: Optional[tuple[object, ...]] = None self._capable = False @@ -707,6 +720,7 @@ def strict_shared_affinity(self) -> bool: """Whether a verified managed daemon must not degrade to stdio.""" return bool( self.require_shared + or (not self.allow_restart and self._ready is not None) or ( self._ready is not None and self._ready.verified_remote_control @@ -904,6 +918,9 @@ async def ensure_started( ) return None + if not self.allow_restart: + return await self._prepare_without_restart(codex_bin, env, identity) + lifecycle = await self.version(codex_bin, env) if lifecycle is None and self.require_shared: log.info("bootstrapping required Codex profile daemon") @@ -1031,6 +1048,52 @@ async def ensure_started( self._standalone_socket_path = None return info + async def _prepare_without_restart( + self, codex_bin: str, env: Mapping[str, str], identity: tuple[object, ...], + ) -> Optional[CodexDaemonInfo]: + """Prepare local sharing without interrupting native clients. + + Official ``start`` reuses an existing listener. In contrast, bootstrap, + restart and even enable-remote-control can stop a running generation. + Local TUI/proxy sharing only needs the Unix listener; cloud remote + control is a separate native setting and is not changed here. + """ + lifecycle = await self.version(codex_bin, env) + if lifecycle is None: + await self.start(codex_bin, env) + lifecycle = await self.version(codex_bin, env) + if lifecycle is None and self.require_shared: + prepared = await asyncio.to_thread( + _prepare_profile_standalone, codex_bin, env) + if prepared is True: + await self.start(codex_bin, env) + lifecycle = await self.version(codex_bin, env) + info = _existing_proxy_candidate(lifecycle) if lifecycle else None + if info is None: + self.invalidate() + if self.require_shared: + raise CodexProfileDaemonUnavailable( + "Codex shared daemon unavailable; existing processes were preserved") + return None + codex_home = os.path.realpath(os.path.expanduser( + env.get("CODEX_HOME") or "~/.codex")) + if _managed_daemon_process_identity(codex_home) is not None: + self._managed_identity_required = True + nofile = await asyncio.to_thread( + _ensure_managed_daemon_nofile, codex_bin, env, lifecycle, + allow_local_listener=True) + if nofile is False: + self.invalidate() + raise CodexProfileDaemonUnavailable( + "Codex shared daemon file limit could not be verified") + info = CodexDaemonInfo( + socket_path=info.socket_path, nofile_verified=nofile is True) + self._ready_identity = identity + self._ready = info + self._ready_codex_home = codex_home + self._standalone_socket_path = info.socket_path + return info + async def proxy_args( self, codex_bin: str, env: Mapping[str, str], ) -> Optional[list[str]]: diff --git a/cc_remote/wrapper/codex_readiness.py b/cc_remote/wrapper/codex_readiness.py new file mode 100644 index 00000000..74e753da --- /dev/null +++ b/cc_remote/wrapper/codex_readiness.py @@ -0,0 +1,196 @@ +"""No-model Codex connection checks, published by the actual Wrapper user. + +The release installer cannot safely guess the service's PATH/account environment +or run an account's Codex as root. It instead waits for this startup receipt. +This verifies transport readiness, not an operator's shell aliases or an already +running TUI's automatic discovery. +""" +from __future__ import annotations + +import asyncio +from dataclasses import asdict +import json +import os +from pathlib import Path +import signal +import stat +import tempfile +import time +from typing import Any + +from websockets.client import ClientProtocol +from websockets.frames import Frame, Opcode +from websockets.http11 import Response +from websockets.uri import parse_uri + +from cc_remote import __version__ +from cc_remote.wrapper.codex_daemon import CodexDaemonManager +from cc_remote.wrapper.process_scan import process_identity + +REPORT_NAME = "codex-readiness.json" +SOURCE_ROOT = Path(__file__).resolve().parents[2] +_TIMEOUT = 8.0 + + +def socket_identity(path: str) -> tuple[int, int, int]: + info = os.lstat(path) + parent = Path(path).parent + parent_info = parent.stat() + if (not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid() + or info.st_mode & 0o077 or parent_info.st_uid != os.getuid() + or parent_info.st_mode & 0o022 or str(parent.resolve()) != str(parent)): + raise ValueError("Codex socket is not owned by the Wrapper user") + return info.st_dev, info.st_ino, info.st_ctime_ns + + +async def probe_proxy(binary: str, env: dict[str, str], socket_path: str) -> None: + """Initialize through the official raw WebSocket proxy, without a thread.""" + process = await asyncio.create_subprocess_exec( + binary, "app-server", "proxy", "--sock", socket_path, + env=env, cwd=env.get("HOME") or str(Path.home()), + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, limit=256 * 1024, + start_new_session=True, + ) + protocol = ClientProtocol(parse_uri("ws://localhost/"), max_size=256 * 1024) + + async def flush() -> None: + assert process.stdin is not None + for chunk in protocol.data_to_send(): + if chunk: + process.stdin.write(chunk) + await process.stdin.drain() + + try: + async with asyncio.timeout(_TIMEOUT): + assert process.stdin is not None and process.stdout is not None + initialize = json.dumps({ + "id": 1, "method": "initialize", "params": { + "clientInfo": {"name": "cc-remote-readiness", "version": __version__}, + }, + }).encode() + protocol.send_request(protocol.connect()) + await flush() + fragments = bytearray() + messages = 0 + while messages < 32: + chunk = await process.stdout.read(64 * 1024) + if not chunk: + raise RuntimeError("Codex proxy closed before initialization") + protocol.receive_data(chunk) + if protocol.handshake_exc is not None: + raise RuntimeError("Codex proxy rejected WebSocket handshake") + for event in protocol.events_received(): + if isinstance(event, Response): + protocol.send_text(initialize) + elif isinstance(event, Frame): + if event.opcode in {Opcode.BINARY, Opcode.CLOSE}: + raise RuntimeError("Codex proxy closed or returned binary data") + if event.opcode not in {Opcode.TEXT, Opcode.CONT}: + continue + fragments.extend(event.data) + if len(fragments) > 256 * 1024: + raise RuntimeError("Codex initialization exceeds limit") + if not event.fin: + continue + messages += 1 + response = json.loads(fragments) + fragments.clear() + if not isinstance(response, dict) or response.get("id") != 1: + continue + if "error" in response or not isinstance(response.get("result"), dict): + raise RuntimeError("Codex proxy rejected initialization") + protocol.send_text(b'{"method":"initialized"}') + protocol.send_close() + await flush() + return + await flush() + raise RuntimeError("Codex proxy did not return initialization") + finally: + if process.stdin is not None: + process.stdin.close() + try: + await asyncio.wait_for(process.wait(), timeout=1) + except TimeoutError: + # Only this short-lived, thread-free probe is terminated. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + await process.wait() + + +async def check_profile( + profile_id: str, home: str, binary: str, daily_cli: str | None, + env: dict[str, str], manager: CodexDaemonManager, +) -> dict[str, Any]: + row: dict[str, Any] = { + "profile": profile_id, "home": home, "status": "unavailable", + "wrapper_cli": binary, "daily_cli": daily_cli, + "terminal_connection": "unverified", + } + try: + # Setup is owned by Wrapper startup, serialized by the same manager + # that subsequent Code sessions use. Work never uses this manager. + info = await manager.ensure_started(binary, env) + if info is None or not info.socket_path: + row["reason"] = "daemon_unavailable" + return row + expected = os.path.join(os.path.realpath(home), + "app-server-control", "app-server-control.sock") + if os.path.realpath(info.socket_path) != expected: + row["reason"] = "account_socket_mismatch" + return row + before = socket_identity(expected) + wrapper = await manager.version(binary, env) + if not wrapper or wrapper.get("status") != "running": + row["reason"] = "daemon_unavailable" + return row + await probe_proxy(binary, env, expected) + if daily_cli is None: + row["reason"] = "daily_cli_missing" + return row + daily = await manager.version(daily_cli, env) + if ( + not daily or daily.get("status") != "running" + or os.path.realpath(str(daily.get("socketPath", ""))) != expected + or os.path.realpath(str(wrapper.get("socketPath", ""))) != expected + ): + row["reason"] = "daily_cli_mismatch" + return row + versions = [wrapper.get("cliVersion"), daily.get("cliVersion"), + wrapper.get("appServerVersion"), daily.get("appServerVersion")] + if not all(isinstance(value, str) and value for value in versions) or len(set(versions)) != 1: + row["reason"] = "version_mismatch" + return row + if os.path.realpath(daily_cli) != os.path.realpath(binary): + await probe_proxy(daily_cli, env, expected) + if socket_identity(expected) != before: + row["reason"] = "daemon_changed" + return row + row.update(status="ready", socket=expected, socket_identity=list(before), version=versions[0]) + except Exception as exc: + # A CLI may include credentials or prompts in stderr/errors. Publish + # only a closed reason and exception class, never its raw message. + row.update(reason="connection_failed", error_type=type(exc).__name__) + return row + + +def write_report(state_dir: Path, rows: list[dict[str, Any]]) -> None: + identity = process_identity(os.getpid()) + if identity is None: + raise RuntimeError("cannot identify Wrapper process") + payload = { + "schema": 1, "source": str(SOURCE_ROOT), "version": __version__, + "wrapper": asdict(identity), "created_at": time.time(), "profiles": rows, + } + state_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor, filename = tempfile.mkstemp(prefix=".codex-readiness-", dir=state_dir) + temporary = Path(filename) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(payload, stream, ensure_ascii=False) + stream.write("\n") + temporary.replace(state_dir / REPORT_NAME) + finally: + temporary.unlink(missing_ok=True) diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index d8e06f9f..0d9e4e9d 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -63,6 +63,8 @@ from uuid import uuid4 from typing import Literal, Optional +from cc_remote.wrapper import codex_readiness + from claude_agent_sdk import ( PermissionResultAllow, PermissionResultDeny, delete_session, fork_session, get_session_info, get_session_messages, list_sessions, @@ -2106,7 +2108,8 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): self._codex_daemons = { profile.id: CodexDaemonManager( getattr(cfg, "codex_daemon_mode", "auto"), - require_shared=self._codex_profiles.is_multi_profile, + require_shared=(getattr(cfg, "codex_daemon_mode", "auto") == "auto"), + allow_restart=False, ) for profile in self._codex_profiles } @@ -8861,7 +8864,11 @@ async def prepare_codex_daemons(self) -> None: Codex remains an optional engine. One unavailable binary/profile is logged independently and must not prevent Relay or Claude startup. """ + rows: list[dict] = [] if getattr(self.cfg, "codex_daemon_mode", "auto") != "auto": + rows = [{"profile": profile.id, "status": "disabled"} + for profile in self._codex_profiles] + self._publish_codex_readiness(rows) return try: codex_bin = await asyncio.to_thread(resolve_codex_bin) @@ -8870,37 +8877,44 @@ async def prepare_codex_daemons(self) -> None: "Codex shared daemon prewarm unavailable", error_type=type(exc).__name__, ) + self._publish_codex_readiness([ + {"profile": profile.id, "status": "unavailable", "reason": "daily_cli_missing"} + for profile in self._codex_profiles]) return - async def prepare(profile: CodexProfile) -> None: + daily_cli = shutil.which("codex") + + async def prepare(profile: CodexProfile) -> dict: try: - info = await self._codex_daemon_for_profile( - profile).ensure_started( - codex_bin, + async with asyncio.timeout(40): + row = await codex_readiness.check_profile( + profile.id, str(profile.home), codex_bin, daily_cli, codex_env(codex_bin, self._codex_home(profile)), + self._codex_daemon_for_profile(profile), ) except Exception as exc: + row = {"profile": profile.id, "status": "unavailable", + "reason": "connection_failed", "error_type": type(exc).__name__} + if row["status"] != "ready": log.warning( - "Codex profile shared daemon prewarm failed", - profile_id=profile.id, - error_type=type(exc).__name__, + "Codex profile shared connection needs attention", + profile_id=profile.id, reason=row.get("reason"), ) - return - if info is None: - log.warning( - "Codex profile shared daemon prewarm unavailable", - profile_id=profile.id, - ) - return - log.info( - "Codex profile shared daemon ready", - profile_id=profile.id, - remote_control=info.verified_remote_control, - ) + else: + log.info("Codex profile shared transport ready", profile_id=profile.id, + terminal_connection="unverified") + return row - await asyncio.gather(*( + rows = await asyncio.gather(*( prepare(profile) for profile in self._codex_profiles )) + self._publish_codex_readiness(rows) + + def _publish_codex_readiness(self, rows: list[dict]) -> None: + try: + codex_readiness.write_report(self.cfg.state_dir, rows) + except Exception as exc: + log.warning("Codex readiness receipt unavailable", error_type=type(exc).__name__) async def run(self) -> None: self._cleanup_tmp() diff --git a/deploy/README.md b/deploy/README.md index 8b77b15d..21147f2a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -84,6 +84,21 @@ deployment. verifies its `SHA256SUMS` entry before extraction, rejects unsafe archive paths, and then invokes the in-bundle installer. It never pipes a network 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 + 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. + 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. - `build_release.py` / `release_manifest.py` — reproducible role-bundle builder and fail-closed manifest validator. Relay artifacts contain `web/dist` and `requirements-relay.lock`; Wrapper artifacts contain no Web tree and use @@ -102,6 +117,10 @@ deployment. restores the previous release/service definition on failure. The installer requires and explicitly selects the service user's daily `~/.local/bin/claude`; it never silently falls back to the SDK-bundled CLI. + After activation, `check_codex_readiness.py` reads a fresh, release- and + process-bound result from Wrapper startup. Codex is optional: missing or + incompatible CLI/account connections produce a separate warning instead of + rolling back an otherwise healthy Claude/Wrapper installation. - `prepare_wrapper_stage.py` — unprivileged preflight for an existing manual immutable-Wrapper topology. It reuses an active venv only when the dependency lock and Python pin are identical; otherwise it builds a platform-local venv @@ -261,7 +280,7 @@ docker build -f deploy/Dockerfile \ Explicit takeover may gracefully terminate the exact same-user Claude process with SIGTERM and then resume through the SDK, but it never kills the terminal shell, escalates to SIGKILL, or silently adopts a process. -- **Codex Code:** `CC_REMOTE_CODEX_DAEMON=auto` prefers Codex's official shared +- **Codex Code:** `CC_REMOTE_CODEX_DAEMON=auto` requires Codex's official shared app-server daemon. Set it to `off` only to force the legacy private stdio path. Optional multi-account installs provide either inline `CC_REMOTE_CODEX_PROFILES_JSON` or a private @@ -290,12 +309,26 @@ This does not apply to Work's deliberately private app-server. home selection. Do not read or copy auth files. Both selected CLIs must support `app-server daemon` and `app-server proxy`; an npm installation alone neither proves nor disproves that capability. -2. Keep `CC_REMOTE_CODEX_DAEMON=auto` for sharing. Wrapper startup already - prepares each account's daemon and enables remote control before connecting - to Relay; do not add a second daemon or another startup service. Check the - current startup's `Codex profile shared daemon ready` log and - `remote_control=true`. A prewarm failure, `using stdio`, or an unverified - existing-server candidate is not proof of shared readiness. +2. Keep `CC_REMOTE_CODEX_DAEMON=auto` for sharing; an explicitly configured + `off` survives upgrades. Wrapper startup reuses each account's official + daemon or invokes the native idempotent `start` if it isn't reachable. It + does not bootstrap, restart, replace a lagging daemon, or toggle Codex's + separate cloud remote-control setting: those commands can interrupt native + clients. Local TUI/proxy sharing uses the Unix listener without cloud remote + control. Do not add a second daemon or another startup service. + If sharing is unavailable, Code reports a connection error instead of silently + starting a private stdio server. Explicit `off` retains the legacy private path. + Startup compares the CLI found on the service's PATH with Wrapper's selected + binary, checks account socket and CLI/server versions, and initializes their + official WebSocket proxy connections without creating a thread or model turn. + `Codex profile shared transport ready` and the private rebuildable + `codex-readiness.json` receipt describe this transport check. The Release + installer checks its source release, fresh timestamp and live process identity + before displaying the result. A failed or missing result never passes + sharing acceptance, even if the Wrapper itself was installed successfully. + This does not verify an operator's aliases, launch arguments or an already + open TUI. Complete step 4 separately; do not relabel transport readiness as a + verified ordinary terminal connection. 3. As the same OS user, compare the following **read-only** probes using the resolved account home and both executable paths (replace placeholders): @@ -332,6 +365,15 @@ build, home, endpoint and startup/connection errors. Do not assume all builds auto-attach simply because a daemon is running, or mask the difference by silently changing the user's shell alias. +The inspected official CLI 0.154.0 automatically probes its account's default +socket for ordinary launches. Additional launch configuration (for example +`-c`, a config profile, strict config or a custom exec-server) can select an +embedded server instead; a failed automatic connection can also fall back. +See the [versioned native startup implementation](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/tui/src/lib.rs). +Installers preserve these user choices rather than rewriting aliases or adding +`--remote` to every invocation. Version mismatches are reported so the operator +can finish active work before updating/restarting Codex. + An existing private CLI writer is not migrated into the daemon by starting it later. Let the operator finish and exit that CLI normally, then reconnect to the verified shared endpoint. Never kill an active CLI, delete locks/rollouts, diff --git a/deploy/build_release.py b/deploy/build_release.py index 01ec1f1c..d70c0f24 100755 --- a/deploy/build_release.py +++ b/deploy/build_release.py @@ -38,6 +38,8 @@ class BuildError(ValueError): "cc-remote-relay.service", "env.relay.example", "install-relay.sh", + "install_cli.py", + "install_lock.py", "python-version.txt", "release_manifest.py", "setup-vps.sh", @@ -46,10 +48,13 @@ class BuildError(ValueError): ) _WRAPPER_DEPLOY = ( "atomic_symlink.py", + "check_codex_readiness.py", "cc-remote-wrapper.service", "com.muggle.cc-remote.wrapper.plist.in", "env.wrapper.example", "install-wrapper.sh", + "install_cli.py", + "install_lock.py", "install_claude_service.py", "prepare_wrapper_stage.py", "python-version.txt", @@ -236,6 +241,8 @@ def build_bundle( _copy_file(root / lock_name, staging / lock_name) _copy_file(uv_bin, staging / "bin" / "uv") (staging / "bin" / "uv").chmod(0o755) + _copy_file(root / "scripts" / "cc-remote", staging / "bin" / "cc-remote") + (staging / "bin" / "cc-remote").chmod(0o755) _copy_file( root / "deploy" / "uv-LICENSE-MIT", staging / "licenses" / "uv-LICENSE-MIT", diff --git a/deploy/check_codex_readiness.py b/deploy/check_codex_readiness.py new file mode 100644 index 00000000..b2f8bca0 --- /dev/null +++ b/deploy/check_codex_readiness.py @@ -0,0 +1,129 @@ +"""Print this activation's Codex result without running an account CLI as root.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import socket +import stat +import sys +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from cc_remote.wrapper.codex_readiness import REPORT_NAME +from cc_remote.wrapper.process_scan import ProcessIdentity, process_identity, process_owner_uid +from deploy.work_registry_snapshot import resolve_wrapper_state_dir + +_REASONS = { + "daemon_unavailable": "共享服务尚不可用,请检查当前账号的 Codex 安装。", + "account_socket_mismatch": "账号与连接地址不一致,请检查 CODEX_HOME 和账号配置。", + "daily_cli_missing": "服务环境找不到 codex,请安装 CLI 或检查服务的 PATH。", + "daily_cli_mismatch": "终端 CLI 与 cc-remote 没有找到同一个服务,请检查 Codex 路径和账号。", + "version_mismatch": "Codex CLI 与运行中的服务版本不一致;当前任务保留,结束后再更新或重开 Codex。", + "daemon_changed": "检查期间 Codex 服务发生变化,本次未确认连接。", + "connection_failed": "连接检查未通过;现有任务保留,请检查 Wrapper 日志。", +} + + +def socket_still_ready(row: dict, owner_uid: int) -> bool: + try: + path = row["socket"] + if not isinstance(path, str) or not os.path.isabs(path): + return False + info = os.lstat(path) + if (not stat.S_ISSOCK(info.st_mode) or info.st_uid != owner_uid + or [info.st_dev, info.st_ino, info.st_ctime_ns] != row["socket_identity"]): + return False + # No account binary, authentication or model API is invoked as root. + # Check only that the exact recently verified listener still accepts. + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(0.2) + connection.connect(path) + return True + except (OSError, KeyError, TypeError, ValueError): + return False + + +def read_receipt(path: Path, release: Path, after: float) -> dict | None: + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + return None + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > 64 * 1024: + raise ValueError("invalid Codex readiness receipt") + raw = stream.read(64 * 1024 + 1) + if len(raw) > 64 * 1024: + raise ValueError("oversized Codex readiness receipt") + try: + report = json.loads(raw) + identity = ProcessIdentity(**report["wrapper"]) + rows = report["profiles"] + valid = ( + report["schema"] == 1 and report["source"] == str(release.resolve()) + and isinstance(report["created_at"], (int, float)) + and after <= report["created_at"] <= time.time() + 5 + and isinstance(rows, list) and 0 < len(rows) <= 32 + and all(isinstance(row, dict) and isinstance(row.get("profile"), str) + and row.get("status") in {"ready", "disabled", "unavailable"} for row in rows) + and process_identity(identity.pid) == identity + and process_owner_uid(identity.pid) == info.st_uid + ) + except (KeyError, TypeError, ValueError): + return None + if not valid: + return None + for row in rows: + if row["status"] == "ready" and not socket_still_ready(row, info.st_uid): + row.update(status="unavailable", reason="daemon_changed") + return report + + +def describe(report: dict) -> bool: + complete = True + for row in report["profiles"]: + # Values come from private configuration but must not inject terminal controls. + profile = json.dumps(row["profile"], ensure_ascii=False) + if row["status"] == "ready": + print(f"Codex {profile}: 共享连接已就绪(CLI 与 cc-remote 的连接检查通过)。") + elif row["status"] == "disabled": + print(f"Codex {profile}: 保留已有的关闭设置,未启用共享连接。") + else: + complete = False + print(f"Codex {profile}: {_REASONS.get(row.get('reason'), _REASONS['connection_failed'])}") + if any(row["status"] == "ready" for row in report["profiles"]): + print("新终端请使用对应账号的 codex / codex resume。安装前已打开的独立会话请等任务结束后重开。") + print("已验证本地连接,未发送模型消息;现有终端、shell 别名及额外启动参数的实际连接仍需确认。") + return complete + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--home", required=True, type=Path) + parser.add_argument("--release", required=True, type=Path) + parser.add_argument("--after", required=True, type=float) + parser.add_argument("--env-file", type=Path) + parser.add_argument("--plist", type=Path) + parser.add_argument("--wait", type=float, default=45) + args = parser.parse_args(argv) + try: + state = resolve_wrapper_state_dir(args.home, env_file=args.env_file, plist=args.plist) + deadline = time.monotonic() + max(0, min(args.wait, 45)) + while True: + report = read_receipt(state / REPORT_NAME, args.release, args.after) + if report is not None: + return 0 if describe(report) else 1 + if time.monotonic() >= deadline: + print("Codex: 未收到本次启动的连接检查结果,请检查 Wrapper 日志;不能据此确认已共享。") + return 1 + time.sleep(1) + except (OSError, ValueError, RuntimeError) as exc: + print(f"Codex: 无法读取连接检查结果({type(exc).__name__});请检查 Wrapper 日志。") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/install-relay.sh b/deploy/install-relay.sh index 4fd18d81..dd3ad6b8 100755 --- a/deploy/install-relay.sh +++ b/deploy/install-relay.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # First-install/update entrypoint for a role-scoped relay release bundle. set -euo pipefail +installer_args=("$@") die() { echo "ERROR: $*" >&2 @@ -75,7 +76,14 @@ bundle="$(cd "$bundle" && pwd -P)" ) appdir=/opt/cc-remote +if [ -z "${CC_REMOTE_INSTALL_LOCK_FD:-}" ]; then + exec python3 "$bundle/deploy/install_lock.py" \ + "$appdir" bash "$bundle/deploy/install-relay.sh" "${installer_args[@]}" +fi +python3 "$bundle/deploy/install_lock.py" --verify-fd "$CC_REMOTE_INSTALL_LOCK_FD" "$appdir" env_file="$appdir/.env" +cli_path=/usr/local/bin/cc-remote +python3 "$bundle/deploy/install_cli.py" --destination "$cli_path" --check new_env="" cleanup() { [ -z "$new_env" ] || rm -f -- "$new_env" @@ -186,3 +194,4 @@ echo echo "Relay installed. Open https://$domain/ and log in." echo "Then open Devices, create a one-time pairing code, and run the" echo "wrapper installer on the Mac or Linux machine that hosts Claude/Codex." +echo "Updates: cc-remote update (check only: cc-remote update --check)" diff --git a/deploy/install-wrapper.sh b/deploy/install-wrapper.sh index e08515d5..472e3e71 100755 --- a/deploy/install-wrapper.sh +++ b/deploy/install-wrapper.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # First-install/update entrypoint for a role-scoped wrapper release bundle. set -euo pipefail +installer_args=("$@") die() { echo "ERROR: $*" >&2 @@ -130,6 +131,7 @@ if [ "$system" = darwin ]; then service_file="$target_home/Library/LaunchAgents/com.muggle.cc-remote.wrapper.plist" service_label="com.muggle.cc-remote.wrapper" log_dir="$target_home/Library/Logs/cc-remote" + cli_path="$target_home/.local/bin/cc-remote" else [ "$(id -u)" -eq 0 ] || die "Linux wrapper installation must run as root" command -v systemctl >/dev/null 2>&1 || die "systemd is required" @@ -157,8 +159,24 @@ else service_file=/etc/systemd/system/cc-remote-wrapper.service service_label=cc-remote-wrapper log_dir="" + cli_path=/usr/local/bin/cc-remote fi +# Lock before reading rollback state, staging releases or changing credentials. +# Managed updates pass their open lock; direct installs acquire it and re-enter. +if [ -z "${CC_REMOTE_INSTALL_LOCK_FD:-}" ]; then + exec "$bundle/bin/uv" run --no-project --no-env-file --managed-python \ + --python "$python_runtime" python "$bundle/deploy/install_lock.py" \ + "$appdir" bash "$bundle/deploy/install-wrapper.sh" "${installer_args[@]}" +fi +"$bundle/bin/uv" run --no-project --no-env-file --managed-python \ + --python "$python_runtime" python "$bundle/deploy/install_lock.py" \ + --verify-fd "$CC_REMOTE_INSTALL_LOCK_FD" "$appdir" + +"$bundle/bin/uv" run --no-project --no-env-file --managed-python \ + --python "$python_runtime" python "$bundle/deploy/install_cli.py" \ + --destination "$cli_path" --check + claude_bin="$target_home/.local/bin/claude" [ -x "$claude_bin" ] || \ die "daily Claude Code executable is missing: $claude_bin" @@ -182,6 +200,7 @@ rollback_snapshot="" snapshot_created=0 service_stopped=0 service_was_running=0 +activation_committed=0 mkdir -p "$releases" "$runtimes" "$rollback_root" if [ "$system" = darwin ]; then @@ -251,7 +270,7 @@ wrapper_service_active() { cleanup() { status=$? trap - EXIT HUP INT TERM - if [ "$status" -ne 0 ]; then + if [ "$status" -ne 0 ] && [ "$activation_committed" -eq 0 ]; then rollback_ready=1 if [ "$snapshot_created" -eq 1 ]; then # Never start old code against data migrated by the failed new release. @@ -307,6 +326,8 @@ cleanup() { else echo "ERROR: wrapper activation failed; manual data recovery is required" >&2 fi + elif [ "$status" -ne 0 ]; then + echo "WARNING: Wrapper activation was committed; inspect it before retrying." >&2 fi [ -z "$stage" ] || rm -rf -- "$stage" [ -z "$service_backup" ] || rm -f -- "$service_backup" @@ -453,6 +474,7 @@ from xml.sax.saxutils import escape source, destination, current, home, log_dir = map(Path, sys.argv[1:6]) previous_plist = Path(sys.argv[6]) if sys.argv[6] else None text = source.read_text(encoding="utf-8") +previous_environment = {} work_roots = { "CLAUDE_WORK_ROOT": str(home / ".claude" / "cc-remote" / "work"), "CODEX_WORK_ROOT": str(home / ".codex" / "cc-remote" / "work"), @@ -462,6 +484,7 @@ if previous_plist is not None: previous = plistlib.load(stream) environment = previous.get("EnvironmentVariables", {}) if isinstance(environment, dict): + previous_environment = environment for key in work_roots: value = environment.get(key) if isinstance(value, str) and value: @@ -478,7 +501,11 @@ for marker, value in values.items(): if any(marker in text for marker in values): raise SystemExit("unresolved LaunchAgent template marker") staged = destination.with_name(f".{destination.name}.new") -staged.write_text(text, encoding="utf-8") +payload = plistlib.loads(text.encode("utf-8")) +# 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) +staged.write_bytes(plistlib.dumps(payload)) staged.replace(destination) PY service_changed=1 @@ -537,6 +564,7 @@ fi "$target/.venv/bin/python" "$target/deploy/atomic_symlink.py" "$target" "$current" switched=1 +activation_started="$(date +%s)" if [ "$system" = darwin ]; then domain="gui/$(id -u)" @@ -569,6 +597,26 @@ if [ "$migration_ready" -ne 1 ]; then die "Claude/Codex Work profile migrations did not become ready" fi +# The real Wrapper prepares and probes each account as the service user. Read +# its fresh result here; never run a user's Codex binary as the Linux installer. +codex_check_args=(--home "$target_home" --release "$target" --after "$activation_started") +if [ "$system" = darwin ]; then + codex_check_args+=(--plist "$service_file") +else + codex_check_args+=(--env-file "$config_dir/wrapper.env") +fi +echo "==> checking Codex shared connections (no model messages)" +if ! "$target/.venv/bin/python" "$target/deploy/check_codex_readiness.py" \ + "${codex_check_args[@]}"; then + echo "WARNING: Wrapper is installed; Codex sharing still needs attention." +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" +activation_committed=1 + echo echo "Wrapper v$version installed from $git_sha." echo "Active release: $target" @@ -581,3 +629,7 @@ if [ "$system" = darwin ]; then else echo "Logs: journalctl -u $service_label -f" fi +echo "Updates: $cli_path update (check only: $cli_path update --check)" +if [ "$system" = darwin ] && ! command -v cc-remote >/dev/null 2>&1; then + echo 'Add ~/.local/bin to PATH to use the cc-remote command.' +fi diff --git a/deploy/install.sh b/deploy/install.sh index aa726e04..b29e8ff3 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.0}" +VERSION="${CC_REMOTE_VERSION:-4.0.1}" 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 new file mode 100644 index 00000000..673d7fec --- /dev/null +++ b/deploy/install_cli.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Register the release-management command and non-secret install identity.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import stat +import tempfile + + +_HEADER = b"#!/usr/bin/env bash\n# cc-remote release management launcher." + + +def check_destination(destination: Path) -> None: + if destination.is_symlink(): + raise ValueError(f"refusing to replace an existing command symlink: {destination}") + if destination.exists(): + if not destination.is_file(): + raise ValueError(f"command destination is not a regular file: {destination}") + with destination.open("rb") as stream: + if not stream.read(len(_HEADER)).startswith(_HEADER): + raise ValueError(f"refusing to replace an unrelated command: {destination}") + + +def _atomic_file(destination: Path, content: bytes, mode: int) -> None: + descriptor, temporary = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(content) + os.fchmod(stream.fileno(), mode) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, destination) + finally: + Path(temporary).unlink(missing_ok=True) + + +def install_cli(root: Path, destination: Path, *, role: str, user: str | None = None, + domain: str | None = None) -> None: + check_destination(destination) + current = root / "current" + if not current.is_symlink() or current.resolve().parent != (root / "releases").resolve(): + raise ValueError("management command requires an active immutable Release installation") + source = current / "bin" / "cc-remote" + content = source.read_bytes() + 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 role == "wrapper": + if not user or user == "root": + raise ValueError("wrapper management requires the original service user") + metadata["user"] = user + elif role == "relay": + if not domain: + raise ValueError("relay management requires the configured domain") + metadata["domain"] = domain + else: + raise ValueError("unknown installation role") + destination.parent.mkdir(parents=True, exist_ok=True) + previous = destination.read_bytes() if destination.exists() else None + previous_mode = stat.S_IMODE(destination.stat().st_mode) if previous is not None else 0o755 + _atomic_file(destination, content, 0o755) + try: + _atomic_file(root / "installation.json", (json.dumps(metadata, sort_keys=True) + "\n").encode(), 0o644) + except OSError: + if previous is None: + destination.unlink() + else: + _atomic_file(destination, previous, previous_mode) + raise + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--destination", type=Path, required=True) + parser.add_argument("--check", action="store_true") + parser.add_argument("--root", type=Path) + parser.add_argument("--role", choices=("relay", "wrapper")) + parser.add_argument("--user") + parser.add_argument("--domain") + args = parser.parse_args() + try: + if args.check: + check_destination(args.destination) + 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) + except (OSError, ValueError) as exc: + parser.exit(1, f"ERROR: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/install_lock.py b/deploy/install_lock.py new file mode 100644 index 00000000..ba9edcb5 --- /dev/null +++ b/deploy/install_lock.py @@ -0,0 +1,82 @@ +"""Share one persistent lock between direct role installers and managed updates.""" +from __future__ import annotations + +import argparse +import fcntl +import os +from pathlib import Path +import stat + +LOCK_FD_ENV = "CC_REMOTE_INSTALL_LOCK_FD" + + +class InstallLockError(ValueError): + pass + + +def verify_install_lock(root: Path, descriptor: int) -> None: + """Validate the inherited open file and acquire its exclusive lock again. + + An environment marker alone never skips locking. flock is associated with + the inherited open file description, so a child can verify it without + deadlocking against its updater parent or releasing that parent's lock. + """ + if descriptor < 3: + raise InstallLockError("invalid inherited install lock descriptor") + opened = os.fstat(descriptor) + expected = (root / ".update.lock").lstat() + if ( + not stat.S_ISREG(opened.st_mode) or not stat.S_ISREG(expected.st_mode) + or (opened.st_dev, opened.st_ino) != (expected.st_dev, expected.st_ino) + or opened.st_uid != os.geteuid() or opened.st_mode & 0o022 + ): + raise InstallLockError("install lock does not match this installation") + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise InstallLockError( + "another install or update is already running; inspect it before retrying" + ) from exc + + +def acquire_install_lock(root: Path) -> int: + descriptor = os.open(root / ".update.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + verify_install_lock(root, descriptor) + except BaseException: + os.close(descriptor) + raise + return descriptor + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--verify-fd", type=int) + parser.add_argument("root", type=Path) + parser.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args() + try: + if args.verify_fd is not None: + if args.command: + parser.error("--verify-fd does not accept a command") + verify_install_lock(args.root, args.verify_fd) + return 0 + if not args.command: + parser.error("a command is required when acquiring the install lock") + args.root.mkdir(mode=0o755, parents=True, exist_ok=True) + descriptor = acquire_install_lock(args.root) + try: + os.set_inheritable(descriptor, True) + environment = {**os.environ, LOCK_FD_ENV: str(descriptor)} + # Replace the helper so the installer itself holds the lock through + # activation and its EXIT/INT rollback traps, even if a caller exits. + os.execvpe(args.command[0], args.command, environment) + finally: + os.close(descriptor) + except (OSError, InstallLockError) as exc: + parser.exit(1, f"ERROR: {exc}\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/setup-vps.sh b/deploy/setup-vps.sh index 829c5337..64fcbfaf 100755 --- a/deploy/setup-vps.sh +++ b/deploy/setup-vps.sh @@ -42,6 +42,8 @@ INSECURE_HTTP=0 PRIVATE_DIRECT=0 PUBLIC_SCHEME=https CADDY_TEMPLATE="" +MANAGED_RELEASE=0 +CLI_PATH=/usr/local/bin/cc-remote [ -r "$SOURCE_DIR/deploy/setup_transaction.sh" ] || { echo "ERROR: $SOURCE_DIR/deploy/setup_transaction.sh is missing" >&2 @@ -180,6 +182,15 @@ python3 "$SOURCE_DIR/deploy/validate_protocol_bundle.py" \ [ -f "$SOURCE_DIR/deploy/caddy_managed_block.py" ] || die "$SOURCE_DIR/deploy/caddy_managed_block.py missing" [ -f "$SOURCE_DIR/deploy/cc-remote-relay.service" ] || die "$SOURCE_DIR/deploy/cc-remote-relay.service missing" +if [ -f "$SOURCE_DIR/release-manifest.json" ]; then + ( + cd "$SOURCE_DIR" + python3 -m deploy.release_manifest "$SOURCE_DIR" --role relay --os linux + ) + python3 "$SOURCE_DIR/deploy/install_cli.py" --destination "$CLI_PATH" --check + MANAGED_RELEASE=1 +fi + require_secret SESSION_SECRET 32 if LOGIN_USERS_POLICY="$(read_env_value LOGIN_USERS_JSON 2>/dev/null)" && \ [[ -n "$LOGIN_USERS_POLICY" ]]; then @@ -411,6 +422,13 @@ if (( ! READY )); then die "relay did not become ready on http://127.0.0.1:8765/healthz" fi +# Registration is the final fallible activation step. Its own atomic writer +# restores the command on failure; our EXIT trap restores Relay/Caddy/current. +# Source deployments without a release manifest do not become managed installs. +if (( MANAGED_RELEASE )); then + "$NEW_RELEASE_DIR/.venv/bin/python" "$NEW_RELEASE_DIR/deploy/install_cli.py" \ + --root "$APPDIR" --destination "$CLI_PATH" --role relay --domain "$TARGET" +fi DEPLOY_READY=1 echo diff --git a/docs/installation.md b/docs/installation.md index b19e50db..7ea9226f 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.0`;请先确认对应版本已发布,或替换为已选定的已发布 tag +`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.1`;请先确认对应版本已发布,或替换为已选定的已发布 tag (变量中不带开头的 `v`)。该路径不会自动安装尚未发布的开发分支: ```bash -export CC_REMOTE_VERSION=4.0.0 +export CC_REMOTE_VERSION=4.0.1 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" @@ -91,8 +91,49 @@ macOS 必须以当前桌面用户运行,安装器创建用户 LaunchAgent;Li `0600` 私有配置:macOS 为 `~/.cc-remote/device.json`,Linux 为 `/etc/cc-remote/device.env`;不会进入 plist、systemd unit 或 release 目录。 -升级同一台机器时下载新版本 `install.sh` 后重新执行即可。Relay 仍传 `--domain`; -Wrapper 已有设备凭据时只需: +Codex 默认准备好本机共享连接:终端和 cc-remote 选择同一账号时,可以连接同一个 +会话服务。安装或升级激活后会逐个账号检查 CLI、服务版本和实际连接,并显示结果。 +已有服务直接复用,安装前已打开的独立终端会话不会强行迁移;等任务结束后重新打开。 +如果缺少 CLI、版本不一致或连接失败,会明确提示,不会为了修复连接而重启正在工作的 +Codex。已有的 `CC_REMOTE_CODEX_DAEMON=off` 设置也会保留。 +默认共享模式连接失败时会报错,不会悄悄另起一个独立会话服务。 + +这个检查不发送模型消息。它使用服务环境中的 `codex`;你的 shell 别名、额外参数和 +已打开终端的实际连接仍需按[共享验收](../deploy/README.md#codex-code-shared-control-plane-acceptance) +确认。多账号需选择相同的 `CODEX_HOME`;Claude 原生 CLI 与 Codex App 不会自动接入。 + +### 后续更新 + +包含管理命令的新安装器会注册 `cc-remote`:macOS 位于 `~/.local/bin/cc-remote`, +Linux 位于 `/usr/local/bin/cc-remote`。macOS 需把 `~/.local/bin` 加到 `PATH`。 +首次安装完成后,在独立终端或 SSH 中运行: + +```bash +cc-remote update --check # 只查版本,不下载安装包或重启服务 +cc-remote update # 更新本机组件到最新稳定 Release +``` + +`--version` 可选择一个已发布的准确版本,不执行降级。若同机安装了两个角色, +加 `--role relay` 或 `--role wrapper`。Linux 自动请求 `sudo`,并保留安装时的 +Wrapper 服务用户;macOS 以原桌面用户运行。只更新本机选择的角色,Relay 会带上 Web, +其他机器的 Wrapper 需分别执行更新。 + +命令校验 SHA-256、安装包路径、平台和版本,随后复用原安装器的不可变切换、状态快照 +与失败回滚;保留账号、配对和外部配置,不清理旧 release,也不会重新配对。同版本 +不重启,重复更新会被锁住,激活失败不会自动重试。独立 Claude 服务不在更新范围内。 +真正升级 Wrapper 后会显示上述 Codex 连接检查结果;`--check` 和同版本更新不触发配置或检查。 +升级前先等待进程内 Claude、BTW 和排队消息处理结束;SDK 或独立服务协议变化时, +命令会停止并要求按 [Claude 服务指南](claude-session-service.md) 完成迁移。 + +通信协议变化时命令默认停止。先安排所有机器的维护窗口,按部署文档顺序使用 +`--version` 固定同一版并附加 `--allow-protocol-change`;这只表示已安排协调升级, +不会自动管理远端机器。更新完成后重新加载 Web/PWA。 + +v4.0.0 及更早版本尚未安装这个命令,需先按下面的方式升级到包含它的版本一次。 +源码、自定义目录和 Docker 部署继续使用各自流程,命令不会自动接管它们。 + +旧版本升级同一台机器时,下载新版本 `install.sh` 后重新执行即可。Relay 仍传 +`--domain`;Wrapper 已有设备凭据时只需: ```bash ./install.sh wrapper diff --git a/docs/installation_en.md b/docs/installation_en.md index bab06086..21e86822 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.0`; +`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.1`; 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.0 +export CC_REMOTE_VERSION=4.0.1 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" @@ -103,8 +103,63 @@ credential is stored only in a mode-`0600` private config: `~/.cc-remote/device.json` on macOS or `/etc/cc-remote/device.env` on Linux. It is never embedded in a plist, systemd unit, or release directory. -For an upgrade, download the new version's `install.sh` and rerun it. Relay -still needs `--domain`; a previously paired Wrapper needs only: +Codex prepares a local shared connection by default. Terminal and cc-remote +sessions selecting the same account can use the same session service. After +installation or upgrade activation, Wrapper checks each account's CLI, running +server version and actual transport; the installer prints the result. Existing +servers are reused. A private terminal opened before installation must finish +its work and be reopened normally. Missing CLIs, version mismatches and failed +connections are reported without restarting active Codex work. An explicit +`CC_REMOTE_CODEX_DAEMON=off` setting is preserved. +In the default shared mode, connection failure is reported instead of silently +starting a private session server. + +These checks send no model messages and use `codex` from the service's PATH. +Shell aliases, extra launch arguments and existing terminal connections still +need [shared-control acceptance](../deploy/README.md#codex-code-shared-control-plane-acceptance). +Select the same `CODEX_HOME` for multiple accounts. Native Claude CLI and Codex +App attachment are separate; this installation does not automatically attach them. + +### Subsequent updates + +Installers containing the management command register `cc-remote` at +`~/.local/bin/cc-remote` on macOS and `/usr/local/bin/cc-remote` on Linux. +Add `~/.local/bin` to your macOS `PATH`. After the initial installation, use an +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 +``` + +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. + +The command verifies SHA-256, archive paths, platform and version before calling +the existing immutable installer with its state snapshot and failed-activation +rollback. Account, pairing and external configuration remain intact. Previous +releases are retained; no re-pairing occurs. The same version is a no-op, concurrent +updates are locked out, and failed activations are not retried automatically. +An activated Wrapper upgrade prints the Codex connection result described above; +`--check` and same-version updates do not configure or probe Codex. +The independent Claude service is never restarted. Finish in-process Claude, +BTW and queued work before updating. SDK/service-protocol changes stop the command +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. + +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. + +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: ```bash ./install.sh wrapper diff --git a/docs/releases/v4.0.1.md b/docs/releases/v4.0.1.md new file mode 100644 index 00000000..42f897f9 --- /dev/null +++ b/docs/releases/v4.0.1.md @@ -0,0 +1,87 @@ +# cc-remote v4.0.1 + +## 中文 + +v4.0.1 新增 `cc-remote update`,并补齐安装后的 Codex 共享连接检查。 +产品版本为 **4.0.1**,通信协议继续使用 **v72**,与 v4.0.0 相同。 + +### 功能与修复 + +- **本机更新命令**:`cc-remote update` 更新安装器管理的本机 Relay 或 Wrapper。 + 支持 `--check` 只查更新、`--version` 指定版本;同机两个角色用 `--role` 选择。 + 校验安装包、平台、版本和 SHA-256,复用不可变安装、状态快照与失败回滚。 +- **更新保护**:保留原服务用户、账号、设备配对和外部配置;阻止并发更新、降级 + 及未经协调的协议升级。独立 Claude 会话服务保留原有生命周期。 +- **Codex 共享连接**:默认按账号准备官方 daemon,复用已有服务,并检查 CLI 与 + Wrapper 的版本、账号地址和真实连接。检查不发送模型消息,也不重启正在工作的 + Codex。失败时明确提示;用户显式关闭共享的设置继续保留。 +- **macOS 配置保留**:升级 Wrapper 时保留用户原有的环境设置。 + +共享检查确认本地连接可用。已打开的独立终端不会被强行迁移,等任务结束后重开; +自定义 shell 别名或额外启动参数仍需单独确认。Codex App 接入是独立可选步骤。 + +### 从 v4.0.0 升级 + +**v4.0.0 尚未安装 `cc-remote update`。** 先按 +[安装与升级](https://github.com/muggle-stack/cc-remote/blob/v4.0.1/docs/installation.md) +下载并校验 v4.0.1 的 `install.sh`,在独立终端或 SSH 中重新运行安装器一次。 +已有 Wrapper 凭据时使用 `./install.sh wrapper`;Relay 仍传原来的 `--domain`。 +之后的版本即可使用: + +```bash +cc-remote update --check +cc-remote update +``` + +命令只更新本机选中的角色,其他机器分别更新。源码、自定义目录和 Docker 部署 +继续使用各自流程。升级前等待进程内 Claude、BTW 和排队任务结束;SDK 或独立 +Claude 服务协议变化时,按会话服务指南单独迁移。升级后重新加载 Web/PWA。 + +本次仍只发布 Claude/Codex;DSH、Electron 和 MCP computer use 实验功能保留在 +各自开发分支。已有 v4.0.0 的 tag 和安装包保留不变。 + +## English + +v4.0.1 adds `cc-remote update` and installation-time Codex shared-connection +checks. The product version is **4.0.1**; wire protocol **v72** is unchanged +from v4.0.0. + +### Highlights + +- **Local updates:** update installer-managed Relay or Wrapper releases with + `cc-remote update`. Use `--check` to check availability, `--version` to select + a release, and `--role` when both roles are installed. Validate bundles, + platform, version and SHA-256 before immutable activation and rollback. +- **Upgrade safeguards:** preserve service users, accounts, device pairing and + external configuration. Reject concurrent updates, downgrades and uncoordinated + protocol changes. Independent Claude services retain their own lifecycle. +- **Shared Codex connections:** prepare the official per-account daemon, reuse + existing servers, and check CLI/Wrapper versions, account endpoints and actual + transport. Checks send no model messages and never restart active Codex work. + Failures are reported; explicit sharing opt-outs are retained. +- **macOS configuration:** preserve existing Wrapper environment settings during + upgrades. + +The check establishes local transport readiness. Existing private terminals +must finish their tasks and be reopened normally; shell aliases and extra +launch arguments still need separate verification. Codex App attachment remains +an independent opt-in. + +### Upgrade from v4.0.0 + +**v4.0.0 does not install `cc-remote update`.** Follow the +[installation guide](https://github.com/muggle-stack/cc-remote/blob/v4.0.1/docs/installation_en.md) +to download and verify v4.0.1's `install.sh`, then run the installer once from +an independent terminal or SSH session. An already paired Wrapper uses +`./install.sh wrapper`; Relay keeps its original `--domain`. Subsequent releases +can use `cc-remote update --check` and `cc-remote update`. + +Only the selected local role is updated; update other machines separately. +Source, custom and Docker deployments retain their existing upgrade procedures. +Drain in-process Claude, BTW and queued work before upgrading. SDK or independent +Claude service protocol changes require the separate service migration guide. +Reload Web/PWA after upgrading. + +This release remains scoped to Claude/Codex. DSH, Electron and MCP computer use +experiments remain on their development branches. Existing v4.0.0 tags and +artifacts are retained. diff --git a/scripts/cc-remote b/scripts/cc-remote new file mode 100755 index 00000000..daaa3376 --- /dev/null +++ b/scripts/cc-remote @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# cc-remote release management launcher. Installed by the role installer. +set -euo pipefail + +if [ "$(uname -s)" = Linux ] && [ "$(id -u)" -ne 0 ]; then + # Relay releases and installation metadata may only be readable by root. + # Elevate this installed launcher, never a script downloaded from the network. + # Preserve explicit download settings without putting proxy credentials into + # command-line arguments. Never preserve Python import-path overrides. + kept_env=CC_REMOTE_GITHUB_REPOSITORY,CC_REMOTE_RELEASE_BASE_URL + kept_env+=,UV_DEFAULT_INDEX,UV_PYTHON_INSTALL_MIRROR + kept_env+=,HTTP_PROXY,HTTPS_PROXY,ALL_PROXY,NO_PROXY,http_proxy,https_proxy,all_proxy,no_proxy + exec sudo --preserve-env="$kept_env" -- "$0" "$@" +fi + +case "$(uname -s)" in + Darwin) roots=("$HOME/Library/Application Support/cc-remote/current") ;; + Linux) + selected_role="" + role_requested=0 + role_value_next=0 + if [ "${1:-}" = update ]; then + for argument in "${@:2}"; do + if [ "$role_value_next" -eq 1 ]; then + selected_role="$argument" + role_value_next=0 + continue + fi + case "$argument" in + --) break ;; + # Match argparse's accepted abbreviations and last-option-wins rule. + --r|--ro|--rol|--role) role_requested=1; role_value_next=1 ;; + --r=*|--ro=*|--rol=*|--role=*) + role_requested=1; selected_role="${argument#*=}" ;; + esac + done + fi + if [ "$role_requested" -eq 1 ]; then + [ "$role_value_next" -eq 0 ] || { + echo "--role requires relay or wrapper." >&2; exit 2; + } + case "$selected_role" in + relay) roots=("/opt/cc-remote/current") ;; + wrapper) roots=("/opt/cc-remote-wrapper/current") ;; + *) echo "--role requires relay or wrapper." >&2; exit 2 ;; + esac + else + roots=("/opt/cc-remote-wrapper/current" "/opt/cc-remote/current") + fi + ;; + *) echo "cc-remote supports macOS and Linux." >&2; exit 1 ;; +esac + +for release_root in "${roots[@]}"; do + if [ -x "$release_root/.venv/bin/python" ] && + [ -f "$release_root/cc_remote/__main__.py" ]; then + cd -- "$release_root" + exec env -u PYTHONHOME -u PYTHONPATH PYTHONDONTWRITEBYTECODE=1 \ + "$release_root/.venv/bin/python" -s -m cc_remote "$@" + fi +done +echo "No managed cc-remote release found. Run the release installer first." >&2 +exit 1 diff --git a/tests/test_codex_daemon.py b/tests/test_codex_daemon.py index 12f2c8b2..966cbde1 100644 --- a/tests/test_codex_daemon.py +++ b/tests/test_codex_daemon.py @@ -179,17 +179,27 @@ def run(argv, **_kwargs): ) +@pytest.mark.parametrize("listener,allow_local,accepted", [ + ("remote", False, True), ("local", True, True), ("default", True, True), + ("other", True, False), ("local", False, False), ("ambiguous", True, False), +]) def test_linux_managed_daemon_nofile_is_applied_to_exact_pid( - monkeypatch, tmp_path): + monkeypatch, tmp_path, listener, allow_local, accepted): proc_root = tmp_path / "proc" proc = proc_root / "4321" proc.mkdir(parents=True) binary = tmp_path / "codex" binary.write_bytes(b"codex") (proc / "exe").symlink_to(binary) - (proc / "cmdline").write_bytes( - f"{binary}\0app-server\0--remote-control\0".encode() - ) + socket_path = tmp_path / "codex-home/app-server-control/app-server-control.sock" + arguments = { + "remote": "--remote-control", + "local": f"--listen\0unix://{socket_path}", + "default": "--listen\0unix://", + "other": "--listen\0unix:///other/account.sock", + "ambiguous": f"--listen\0unix://{socket_path}\0--listen\0unix:///other.sock", + } + (proc / "cmdline").write_bytes(f"{binary}\0app-server\0{arguments[listener]}\0".encode()) (proc / "stat").write_bytes( b"4321 (codex) " + b" ".join( [b"S", *([b"0"] * 18), b"123"] @@ -218,7 +228,11 @@ def prlimit(pid, which, value=None): str(binary), {"CODEX_HOME": str(tmp_path / "codex-home")}, {"managedCodexPath": str(binary)}, - ) is True + allow_local_listener=allow_local, + ) is accepted + if not accepted: + assert calls == [] + return assert calls[1][2] == (daemon_module._DAEMON_NOFILE_SOFT_LIMIT, 524288) assert calls[-1][2] is None diff --git a/tests/test_codex_profiles.py b/tests/test_codex_profiles.py index 5b2295d5..ae1f4403 100644 --- a/tests/test_codex_profiles.py +++ b/tests/test_codex_profiles.py @@ -443,7 +443,7 @@ def test_one_explicit_profile_preserves_native_session_ids(tmp_path: Path) -> No assert (profile.id, native) == ("solo", "native-id") -def test_only_multi_profile_machines_require_shared_daemons( +def test_single_and_multi_profile_machines_require_shared_daemons( tmp_path: Path, ) -> None: multi, _transport = _machine(tmp_path) @@ -464,7 +464,7 @@ def test_only_multi_profile_machines_require_shared_daemons( }, }) single = WrapperMachine(cfg, _StubTransport()) - assert single._codex_daemons["solo"].require_shared is False + assert single._codex_daemons["solo"].require_shared is True @pytest.mark.parametrize("raw", ["", "explicit"]) diff --git a/tests/test_codex_readiness.py b/tests/test_codex_readiness.py new file mode 100644 index 00000000..cd52d4cd --- /dev/null +++ b/tests/test_codex_readiness.py @@ -0,0 +1,270 @@ +"""Install-time sharing checks use local fixtures, never a model or live daemon.""" +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import plistlib +import socket +import sys +import tempfile +import time +from types import SimpleNamespace + +import pytest +from websockets.asyncio.server import unix_serve + +from cc_remote.wrapper import codex_daemon as daemon +from cc_remote.wrapper import codex_readiness as readiness +from deploy import check_codex_readiness as installer + + +def lifecycle(home, **changes): + return { + "status": "running", "cliVersion": "0.154.0", "appServerVersion": "0.154.0", + "managedCodexPath": "/opt/codex", "managedCodexVersion": "0.154.0", + "socketPath": str(home / "app-server-control/app-server-control.sock"), **changes, + } + + +@pytest.mark.parametrize("available", [True, False]) +@pytest.mark.parametrize("require_shared", [True, False]) +def test_non_disruptive_setup_never_restarts_or_enables_cloud_control( + tmp_path, monkeypatch, available, require_shared, +): + calls = [] + manager = daemon.CodexDaemonManager(allow_restart=False, require_shared=require_shared) + async def run(binary, env, *args): + calls.append(args) + assert args[-1] in {"--help", "start", "version"} + data = lifecycle(tmp_path, appServerVersion="0.153.0") if available else None + return daemon._CommandResult(0 if available or args[-1] == "--help" else 1, + json.dumps(data).encode(), b"") + monkeypatch.setattr(manager, "_run", run) + monkeypatch.setattr(daemon, "_prepare_profile_standalone", lambda *_: None) + monkeypatch.setattr(daemon, "_managed_daemon_process_identity", lambda _: None) + async def check(): + if require_shared and not available: + with pytest.raises(daemon.CodexProfileDaemonUnavailable): + await manager.ensure_started("/opt/codex", {"CODEX_HOME": str(tmp_path)}) + return + result = await manager.ensure_started("/opt/codex", {"CODEX_HOME": str(tmp_path)}) + assert (result is not None) == available + if available: + assert manager.strict_shared_affinity + assert result.verified_remote_control is False + asyncio.run(check()) + assert sum(command[-1] == "start" for command in calls) == int(not available) + + +def test_non_disruptive_first_start_reuses_official_lifecycle(tmp_path, monkeypatch): + manager = daemon.CodexDaemonManager(allow_restart=False) + calls = [] + started = False + async def run(binary, env, *args): + nonlocal started + calls.append(args[-1]) + if args[-1] == "start": + started = True + data = lifecycle(tmp_path) if started else None + return daemon._CommandResult(0 if started or args[-1] == "--help" else 1, + json.dumps(data).encode(), b"") + monkeypatch.setattr(manager, "_run", run) + monkeypatch.setattr(daemon, "_managed_daemon_process_identity", lambda _: None) + result = asyncio.run(manager.ensure_started("/opt/codex", {"CODEX_HOME": str(tmp_path)})) + assert result is not None + assert calls == ["--help", "--help", "version", "start", "version"] + + +@pytest.fixture +def account_socket(): + # macOS Unix paths are limited to 104 bytes; pytest's path can exceed that. + with tempfile.TemporaryDirectory(prefix="cc-readiness-", dir="/tmp") as directory: + home = Path(directory).resolve() + path = home / "app-server-control/app-server-control.sock" + path.parent.mkdir(mode=0o700) + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(str(path)) + path.chmod(0o600) + listener.listen() + yield home, path + + +@pytest.mark.parametrize("case,reason", [ + ("ready", None), ("missing", "daily_cli_missing"), + ("other_account", "daily_cli_mismatch"), ("wrong_wrapper", "account_socket_mismatch"), + ("old_cli", "version_mismatch"), ("old_server", "version_mismatch"), + ("denied", "connection_failed"), ("swapped", "daemon_changed"), +]) +def test_profile_checks_endpoint_versions_and_real_handshake( + monkeypatch, account_socket, case, reason, +): + home, path = account_socket + probes = [] + class Manager: + async def ensure_started(self, binary, env): + assert env["CODEX_HOME"] == str(home) + return SimpleNamespace(socket_path=str(path) if case != "wrong_wrapper" else "/other/socket") + async def version(self, binary, env): + data = lifecycle(home) + if case == "other_account" and binary == "/daily/codex": + data["socketPath"] = "/other/socket" + if case == "old_cli" and binary == "/daily/codex": + data["cliVersion"] = "0.153.0" + if case == "old_server": + data["appServerVersion"] = "0.153.0" + return data + async def probe(binary, env, selected): + probes.append(binary) + assert selected == str(path) + if case == "denied": + raise RuntimeError("secret-do-not-publish") + monkeypatch.setattr(readiness, "probe_proxy", probe) + if case == "swapped": + identities = iter([(1, 2, 3), (1, 4, 5)]) + monkeypatch.setattr(readiness, "socket_identity", lambda _: next(identities)) + row = asyncio.run(readiness.check_profile( + "account", str(home), "/wrapper/codex", None if case == "missing" else "/daily/codex", + {"CODEX_HOME": str(home)}, Manager(), + )) + assert row.get("reason") == reason + assert row["terminal_connection"] == "unverified" + assert "secret-do-not-publish" not in json.dumps(row) + if case == "ready": + assert row["status"] == "ready" + assert probes == ["/wrapper/codex", "/daily/codex"] + + +@pytest.mark.parametrize("mode", ["success", "reject", "hang", "oversized", "eof"]) +def test_proxy_probe_only_initializes_and_reaps_child(tmp_path, monkeypatch, mode): + binary = tmp_path / "codex" + messages = [] + pid_file = tmp_path / "pid" + binary.write_text(f"""#!{sys.executable} +import os, pathlib, selectors, socket, sys +pathlib.Path({str(pid_file)!r}).write_text(str(os.getpid())) +assert sys.argv[1:4] == ['app-server', 'proxy', '--sock'] +peer = socket.socket(socket.AF_UNIX) +peer.connect(sys.argv[4]) +poll = selectors.DefaultSelector() +poll.register(0, selectors.EVENT_READ) +poll.register(peer, selectors.EVENT_READ) +while True: + for key, _ in poll.select(): + data = os.read(key.fd, 65536) + if not data: sys.exit(0) + if key.fd == 0: peer.sendall(data) + else: + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() +""") + binary.chmod(0o700) + monkeypatch.setattr(readiness, "_TIMEOUT", 0.2 if mode == "hang" else 3) + async def handler(ws): + request = json.loads(await ws.recv()) + messages.append(request["method"]) + assert request["method"] == "initialize" + if mode == "hang": + await ws.wait_closed() + return + if mode == "eof": + await ws.close() + return + if mode == "oversized": + await ws.send("x" * 300000) + elif mode == "reject": + await ws.send(json.dumps({"id": 1, "error": {"message": "private"}})) + else: + # Exercise ping and fragmentation, not just a synthetic JSON pipe. + await ws.ping() + response = json.dumps({"id": 1, "result": {"userAgent": "fixture"}}) + await ws.send([response[:12], response[12:]]) + messages.append(json.loads(await ws.recv())["method"]) + await ws.wait_closed() + async def check(): + with tempfile.TemporaryDirectory(prefix="cc-proxy-", dir="/tmp") as directory: + path = str(Path(directory) / "socket") + async with unix_serve(handler, path, close_timeout=0.2): + if mode == "success": + await readiness.probe_proxy(str(binary), dict(os.environ), path) + else: + with pytest.raises((RuntimeError, ValueError, TimeoutError)): + await readiness.probe_proxy(str(binary), dict(os.environ), path) + asyncio.run(check()) + assert messages == ( + ["initialize", "initialized"] if mode == "success" else ["initialize"]) + with pytest.raises(ProcessLookupError): + os.kill(int(pid_file.read_text()), 0) + + +def test_installer_rejects_previous_activation_dead_or_reused_pid(tmp_path, monkeypatch): + before = time.time() + readiness.write_report(tmp_path, [{"profile": "test", "status": "disabled"}]) + path = tmp_path / readiness.REPORT_NAME + assert path.stat().st_mode & 0o777 == 0o600 + assert installer.read_receipt(path, readiness.SOURCE_ROOT, before) + assert installer.read_receipt(path, tmp_path / "another-release", before) is None + assert installer.read_receipt(path, readiness.SOURCE_ROOT, time.time() + 1) is None + monkeypatch.setattr(installer, "process_identity", lambda _: None) + assert installer.read_receipt(path, readiness.SOURCE_ROOT, before) is None + + +def test_installer_rejects_symlink_and_bounds_receipt(tmp_path): + target = tmp_path / "target" + target.write_text("x" * (64 * 1024 + 1)) + link = tmp_path / "link" + link.symlink_to(target) + with pytest.raises(OSError): + installer.read_receipt(link, tmp_path, 0) + with pytest.raises(ValueError): + installer.read_receipt(target, tmp_path, 0) + + +def test_installer_checks_listener_again_before_printing_ready(tmp_path, account_socket): + home, path = account_socket + row = {"profile": "account", "status": "ready", "socket": str(path), + "socket_identity": list(readiness.socket_identity(str(path)))} + before = time.time() + readiness.write_report(tmp_path, [row]) + receipt = tmp_path / readiness.REPORT_NAME + assert installer.read_receipt(receipt, readiness.SOURCE_ROOT, before)["profiles"][0]["status"] == "ready" + path.unlink() + result = installer.read_receipt(receipt, readiness.SOURCE_ROOT, before) + assert result["profiles"][0]["status"] == "unavailable" + assert result["profiles"][0]["reason"] == "daemon_changed" + + +@pytest.mark.parametrize("kind", ["plist", "env-file"]) +def test_installer_uses_service_state_root_without_executing_config(tmp_path, kind, capsys): + state = tmp_path / "custom state" + before = time.time() + readiness.write_report(state, [{"profile": "user", "status": "disabled"}]) + config = tmp_path / "config" + if kind == "plist": + config.write_bytes(plistlib.dumps({"EnvironmentVariables": {"CC_REMOTE_STATE_DIR": str(state)}})) + else: + config.write_text(f'CC_REMOTE_STATE_DIR="{state}"\nIGNORED=$(touch /bad)\n') + assert installer.main([ + "--home", str(tmp_path), "--release", str(readiness.SOURCE_ROOT), + "--after", str(before), f"--{kind}", str(config), "--wait", "0", + ]) == 0 + assert "保留已有的关闭设置" in capsys.readouterr().out + + +def test_install_report_keeps_cli_discovery_unverified_and_errors_separate(capsys): + assert not installer.describe({"profiles": [ + {"profile": "good", "status": "ready"}, + {"profile": "other", "status": "unavailable", "reason": "version_mismatch"}, + ]}) + output = capsys.readouterr().out + assert "未发送模型消息" in output + assert "实际连接仍需确认" in output + assert "版本不一致" in output + + +def test_missing_receipt_does_not_pass_acceptance(tmp_path, capsys): + assert installer.main([ + "--home", str(tmp_path), "--release", str(tmp_path), "--after", "0", "--wait", "0", + ]) == 1 + assert "不能据此确认已共享" in capsys.readouterr().out diff --git a/tests/test_deploy.py b/tests/test_deploy.py index ef8e7f68..ca2d7793 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -582,8 +582,11 @@ def test_release_permissions_remove_inherited_group_write_and_other_access( assert executable.stat().st_mode & 0o777 == 0o750 -def test_injected_post_switch_failure_restores_full_release_caddy_and_unit( - tmp_path, +@pytest.mark.parametrize("activation", [ + "readiness-failure", "registration-failure", "registration-success", "source-success", +]) +def test_post_switch_activation_commits_or_restores_full_release_caddy_and_unit( + tmp_path, activation, ): appdir = tmp_path / "app" releases = appdir / "releases" @@ -607,6 +610,15 @@ def test_injected_post_switch_failure_restores_full_release_caddy_and_unit( unit.write_text("new unit") unit_backup.write_text("old unit") systemctl_log = tmp_path / "systemctl.log" + cli_log = tmp_path / "cli-registration.log" + if activation.startswith("registration-"): + runtime = new_release / ".venv/bin/python" + runtime.parent.mkdir() + runtime.write_text( + '#!/bin/sh\nprintf "%s\\n" "$@" > "$CLI_REGISTRATION_LOG"\n' + + ("exit 0\n" if activation.endswith("success") else "exit 23\n") + ) + runtime.chmod(0o755) harness = r''' set -euo pipefail @@ -645,6 +657,18 @@ def test_injected_post_switch_failure_restores_full_release_caddy_and_unit( atomic_release_link "$NEW_RELEASE_DIR" "$CURRENT_LINK" false # injected relay readiness failure after the complete release switch ''' + if activation != "readiness-failure": + # Execute the real setup tail so registration must finish successfully + # before the transaction commits; no system package/service setup runs. + setup_tail = (ROOT / "deploy/setup-vps.sh").read_text().split( + 'echo "==> waiting for relay readiness"', 1)[1] + harness = harness.replace( + "false # injected relay readiness failure after the complete release switch", + f'MANAGED_RELEASE={0 if activation == "source-success" else 1}\n' + 'CLI_PATH="$APPDIR/cc-remote"\n' + 'TARGET=remote.example.test\nPUBLIC_SCHEME=https\nINSECURE_HTTP=0\n' + + setup_tail, + ) result = subprocess.run( [ "bash", "-c", harness, "rollback-test", @@ -653,11 +677,28 @@ def test_injected_post_switch_failure_restores_full_release_caddy_and_unit( str(caddyfile), str(caddy_backup), str(unit), str(unit_backup), str(systemctl_log), ], - env={**os.environ, "CURL_LOG": str(tmp_path / "curl.log")}, + env={**os.environ, "CURL_LOG": str(tmp_path / "curl.log"), + "CLI_REGISTRATION_LOG": str(cli_log)}, text=True, capture_output=True, ) + if activation.startswith("registration-"): + arguments = cli_log.read_text().splitlines() + assert arguments == [ + str(new_release / "deploy/install_cli.py"), + "--root", str(appdir), "--destination", str(appdir / "cc-remote"), + "--role", "relay", "--domain", "remote.example.test", + ] + elif activation == "source-success": + assert not cli_log.exists() + if activation.endswith("success"): + assert result.returncode == 0, result.stderr + assert current.resolve() == new_release.resolve() + assert caddyfile.read_text() == "new caddy" + assert unit.read_text() == "new unit" + assert not systemctl_log.exists() + return assert result.returncode != 0 assert current.resolve() == old_release.resolve() for relative in ("cc_remote", "web/dist", ".venv"): diff --git a/tests/test_install_commit.py b/tests/test_install_commit.py new file mode 100644 index 00000000..28af447e --- /dev/null +++ b/tests/test_install_commit.py @@ -0,0 +1,103 @@ +"""Exercise real Wrapper finalization and cleanup without starting services.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.parametrize("previous_install", [False, True]) +@pytest.mark.parametrize("phase", ["interrupt-readiness", "success", "fail-output"]) +def test_wrapper_registration_matches_the_activation_commit(tmp_path, previous_install, phase): + root = tmp_path / "managed installation" + target = root / "releases/new" + previous = root / "releases/old" + (target / ".venv/bin").mkdir(parents=True) + (target / ".venv/bin/python").symlink_to(sys.executable) + (target / "deploy").mkdir() + for name in ("install_cli.py", "atomic_symlink.py"): + shutil.copyfile(ROOT / "deploy" / name, target / "deploy" / name) + (target / "deploy/check_codex_readiness.py").write_text( + "import os, signal\n" + "if os.environ['TEST_PHASE'] == 'interrupt-readiness':\n" + " assert os.getppid() == int(os.environ['TEST_INSTALLER_PID'])\n" + " os.kill(os.getppid(), signal.SIGTERM)\n" + ) + (target / "bin").mkdir() + launcher = (ROOT / "scripts/cc-remote").read_bytes() + (target / "bin/cc-remote").write_bytes(launcher) + current = root / "current" + current.symlink_to(target) + cli = tmp_path / "bin/cc-remote" + metadata = root / "installation.json" + old_metadata = b'{"schema":1,"role":"wrapper","user":"prior-user"}\n' + old_launcher = launcher + b"\n# previous launcher\n" + if previous_install: + previous.mkdir() + cli.parent.mkdir() + cli.write_bytes(old_launcher) + cli.chmod(0o755) + metadata.write_bytes(old_metadata) + + settings = { + "system": "darwin", "appdir": str(root), "target": str(target), + "previous": str(previous) if previous_install else "", "current": str(current), + "cli_path": str(cli), "target_user": "fixture-user", "target_home": str(tmp_path), + "service_file": str(tmp_path / "wrapper.plist"), "service_label": "fixture-wrapper", + "config_dir": str(tmp_path / "config"), "log_dir": str(tmp_path / "logs"), + "activation_started": "0", "version": "4.0.1", "git_sha": "a" * 40, + "stage": "", "service_backup": "", "device_backup": "", "unit_verify_dir": "", + "rollback_snapshot": "", "snapshot_created": "0", "service_changed": "0", + "device_changed": "0", "service_stopped": "0", "service_was_running": "0", + "switched": "1", "activation_committed": "0", + } + source = (ROOT / "deploy/install-wrapper.sh").read_text() + helpers = source[source.index("restart_after_rollback() {"):] + helpers = helpers.split('if [ -e "$service_file" ]; then', 1)[0] + finalization = source[source.index("# The real Wrapper prepares"):] + harness = "set -euo pipefail\n" + "\n".join( + f"{key}={shlex.quote(value)}" for key, value in settings.items() + ) + "\n" + helpers + r''' +export TEST_INSTALLER_PID=$$ +launchctl() { return 0; } +echo() { + if [ "$TEST_PHASE" = fail-output ]; then + case "$*" in "Wrapper v"*) return 1 ;; esac + fi + builtin echo "$@" +} +''' + finalization + result = subprocess.run( + ["bash", "-c", harness], + env={**os.environ, "TEST_PHASE": phase}, + capture_output=True, text=True, timeout=10, + ) + if phase == "interrupt-readiness": + assert result.returncode == 130, result.stderr + assert "activation failed" in result.stderr + if previous_install: + assert current.resolve() == previous + assert cli.read_bytes() == old_launcher + assert metadata.read_bytes() == old_metadata + else: + assert not current.is_symlink() + assert not cli.exists() + assert not metadata.exists() + else: + assert result.returncode == (1 if phase == "fail-output" else 0), result.stderr + assert current.resolve() == target + assert cli.read_bytes() == launcher + assert json.loads(metadata.read_text()) == { + "schema": 1, "role": "wrapper", "user": "fixture-user", + } + if phase == "fail-output": + assert "activation was committed" in result.stderr + assert "activation failed" not in result.stderr diff --git a/tests/test_install_lock.py b/tests/test_install_lock.py new file mode 100644 index 00000000..603c8e6a --- /dev/null +++ b/tests/test_install_lock.py @@ -0,0 +1,137 @@ +"""Exercise the real Wrapper entrypoint with inert platform/bootstrap fixtures.""" +from __future__ import annotations + +import os +from pathlib import Path +import select +import shutil +import subprocess +import sys + +import pytest + +from cc_remote import update as updater +from deploy.install_lock import LOCK_FD_ENV, InstallLockError, acquire_install_lock, verify_install_lock + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def wrapper_install(tmp_path): + bundle = tmp_path / "bundle" + (bundle / "bin").mkdir(parents=True) + (bundle / "deploy").mkdir() + (bundle / "requirements-wrapper.lock").touch() + (bundle / "release-manifest.json").write_text( + '{"python":"3.13.9","product_version":"4.0.1","git_sha":"' + "a" * 40 + '"}') + for filename in ("install-wrapper.sh", "install_lock.py"): + shutil.copyfile(ROOT / "deploy" / filename, bundle / "deploy" / filename) + uv = bundle / "bin/uv" + uv.write_text(f"""#!{sys.executable} +import os, pathlib, sys +args = sys.argv[sys.argv.index('python') + 1:] +if args[:2] == ['-m', 'deploy.release_manifest']: + raise SystemExit(0) +if pathlib.Path(args[0]).name == 'install_lock.py': + os.execv(sys.executable, [sys.executable, *args]) +if pathlib.Path(args[0]).name == 'install_cli.py': + pathlib.Path(os.environ['TEST_ENTERED']).write_text('entered') + print('entered', flush=True) + if os.environ.get('TEST_HOLD') == '1': + sys.stdin.read(1) + raise SystemExit(17) # Stop before touching any actual service or account. +raise AssertionError(args) +""") + uv.chmod(0o755) + stubs = tmp_path / "bin" + stubs.mkdir() + (stubs / "uname").write_text('#!/bin/sh\ncase "$1" in -s) echo Darwin;; -m) echo arm64;; esac\n') + (stubs / "id").write_text('#!/bin/sh\ncase "$1" in -u) echo 501;; -un) echo fixture-user;; esac\n') + for path in stubs.iterdir(): + path.chmod(0o755) + home = tmp_path / "user home" + home.mkdir() + install_root = home / "Library/Application Support/cc-remote" + install_root.mkdir(parents=True) + marker = tmp_path / "protected-phase" + env = {**os.environ, "HOME": str(home), "PATH": f'{stubs}:{os.environ["PATH"]}', + "TEST_ENTERED": str(marker)} + env.pop(LOCK_FD_ENV, None) + command = ["bash", str(bundle / "deploy/install-wrapper.sh"), str(bundle)] + return install_root, marker, env, command + + +def test_direct_wrapper_install_cannot_enter_during_managed_update(wrapper_install): + root, marker, env, command = wrapper_install + with updater.update_lock(root): + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) + assert result.returncode != 0 + assert "already running" in result.stderr + assert not marker.exists() + + +def test_managed_update_and_second_install_cannot_enter_direct_install(wrapper_install): + root, marker, env, command = wrapper_install + process = subprocess.Popen(command, env={**env, "TEST_HOLD": "1"}, text=True, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + assert select.select([process.stdout], [], [], 10)[0], "installer never reached protected phase" + assert process.stdout.readline().strip() == "entered" + assert marker.exists() + with pytest.raises(updater.UpdateError, match="already running"): + with updater.update_lock(root): + pytest.fail("managed update entered a direct installation") + second = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) + assert second.returncode != 0 and "already running" in second.stderr + process.communicate("x", timeout=10) + assert process.returncode == 17 + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + with updater.update_lock(root): + pass + + +def test_wrapper_accepts_updater_descriptor_without_releasing_parent_lock(wrapper_install, monkeypatch): + root, marker, env, command = wrapper_install + for key in ("HOME", "PATH", "TEST_ENTERED"): + monkeypatch.setenv(key, env[key]) + with updater.update_lock(root) as descriptor: + assert updater.run_installer(command, descriptor) == 17 + assert marker.exists() + with pytest.raises(updater.UpdateError, match="already running"): + with updater.update_lock(root): + pytest.fail("the child released the parent's lock") + + +def test_inherited_marker_for_another_file_cannot_bypass_wrapper_lock(wrapper_install, tmp_path): + _root, marker, env, command = wrapper_install + other = os.open(tmp_path / "other", os.O_CREAT | os.O_RDWR, 0o600) + try: + result = subprocess.run(command, env={**env, LOCK_FD_ENV: str(other)}, + pass_fds=(other,), capture_output=True, text=True, timeout=10) + finally: + os.close(other) + assert result.returncode != 0 + assert not marker.exists() + + +def test_lock_rejects_symlinks_and_wrong_inherited_file(tmp_path): + path = tmp_path / ".update.lock" + target = tmp_path / "outside" + target.write_text("preserve") + path.symlink_to(target) + with pytest.raises(OSError): + acquire_install_lock(tmp_path) + assert target.read_text() == "preserve" + path.unlink() + descriptor = acquire_install_lock(tmp_path) + try: + with target.open("rb") as other: + with pytest.raises(InstallLockError, match="does not match"): + verify_install_lock(tmp_path, other.fileno()) + with pytest.raises(InstallLockError, match="invalid inherited"): + verify_install_lock(tmp_path, 0) + finally: + os.close(descriptor) diff --git a/tests/test_product_version.py b/tests/test_product_version.py index b5808835..d0e9a505 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.0" + assert __version__ == "4.0.1" assert re.fullmatch(r"[1-9]\d*\.\d+\.\d+", __version__) package = json.loads((ROOT / "web/package.json").read_text()) diff --git a/tests/test_release_distribution.py b/tests/test_release_distribution.py index 5042c249..572a5c58 100644 --- a/tests/test_release_distribution.py +++ b/tests/test_release_distribution.py @@ -155,6 +155,9 @@ def test_release_bundles_are_deterministic_and_role_scoped( members = _members(first) assert f"{prefix}/release-manifest.json" in members assert f"{prefix}/bin/uv" in members + assert f"{prefix}/bin/cc-remote" in members + assert f"{prefix}/cc_remote/__main__.py" in members + assert f"{prefix}/deploy/install_cli.py" in members assert f"{prefix}/licenses/uv-LICENSE-MIT" in members assert f"{prefix}/cc_remote/protocol.py" in members assert not any("/tests/" in name for name in members) @@ -166,6 +169,7 @@ def test_release_bundles_are_deterministic_and_role_scoped( assert f"{prefix}/web/dist/cc-remote-viewer-runner.js" in members assert f"{prefix}/requirements-relay.lock" in members assert f"{prefix}/deploy/install-relay.sh" in members + assert f"{prefix}/deploy/install_lock.py" in members assert f"{prefix}/deploy/setup-vps.sh" in members assert f"{prefix}/deploy/Caddyfile.insecure" in members assert f"{prefix}/deploy/install-wrapper.sh" not in members @@ -174,7 +178,10 @@ def test_release_bundles_are_deterministic_and_role_scoped( assert not any("/web/" in name for name in members) assert f"{prefix}/requirements-wrapper.lock" in members assert f"{prefix}/deploy/install-wrapper.sh" in members + assert f"{prefix}/deploy/install_lock.py" in members assert f"{prefix}/deploy/install_claude_service.py" in members + assert f"{prefix}/deploy/check_codex_readiness.py" in members + assert f"{prefix}/cc_remote/wrapper/codex_readiness.py" in members assert f"{prefix}/cc_remote/claude_service/server.py" in members assert f"{prefix}/deploy/work_registry_snapshot.py" in members assert f"{prefix}/scripts/codex-auth-daemon-restart" in members diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 00000000..f6c9da3f --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,553 @@ +"""Offline management CLI checks: real downloads from fixtures, no live services.""" +from __future__ import annotations + +import hashlib +import io +import json +import os +from pathlib import Path +import plistlib +import shlex +import shutil +import subprocess +import sys +import tarfile +from types import SimpleNamespace +from urllib.error import HTTPError + +import pytest + +from cc_remote import update as updater +from cc_remote.__main__ import main +from deploy.install_cli import check_destination, install_cli +from deploy import install_cli as cli_installer +from deploy.install_lock import acquire_install_lock + + +ROOT = Path(__file__).resolve().parents[1] + + +def _manifest(role="wrapper", system="darwin", version="4.0.0"): + return { + "schema": 1, "product_version": version, "protocol_version": 72, + "git_sha": "a" * 40, "role": role, "os": system, "arch": "arm64", + "python": "3.13.9", "uv": "0.11.16", + } + + +def _installation(tmp_path, monkeypatch, *, role="wrapper", system="darwin"): + root = tmp_path / f"managed {role}" + release = root / "releases/old" + release.mkdir(parents=True) + (root / "current").symlink_to(release) + (release / "release-manifest.json").write_text(json.dumps(_manifest(role, system))) + (release / "requirements-wrapper.lock").write_text("claude-agent-sdk==0.2.151\n") + wire = release / "cc_remote/claude_service/wire.py" + wire.parent.mkdir(parents=True) + wire.write_text("VERSION = 1\n") + metadata = {"schema": 1, "role": role} + metadata.update({"user": "service-user"} if role == "wrapper" else {"domain": "remote.example.test"}) + (root / "installation.json").write_text(json.dumps(metadata)) + (root / "operator-config").write_text("preserve operator settings\n") + (root / "native-service-alive").write_text("still running\n") + monkeypatch.setattr(updater, "installation_roots", lambda _: {role: root}) + monkeypatch.setattr(updater, "host_platform", lambda: (system, "arm64")) + monkeypatch.setattr(updater.os, "geteuid", lambda: 0 if system == "linux" else 501) + # The fixture models activation privileges; its real files still belong to + # this test process. Exercise native locking with that actual OS identity. + def native_lock(path): + with monkeypatch.context() as native_identity: + native_identity.setattr(updater.os, "geteuid", os.getuid) + return acquire_install_lock(path) + monkeypatch.setattr(updater, "acquire_install_lock", native_lock) + monkeypatch.setattr(updater, "require_independent_terminal", lambda: None) + return updater.read_installation(root, system, "arm64") + + +def _bundle(tmp_path, installation, *, changes=None, unsafe=None, fail=False): + mirror = tmp_path / "mirror" + mirror.mkdir(exist_ok=True) + target = {**installation.manifest, "product_version": "4.0.1", "git_sha": "b" * 40, **(changes or {})} + prefix = f"cc-remote-{installation.role}-v4.0.1" + filename = f"{prefix}-{installation.manifest['os']}-arm64.tar.gz" + marker = tmp_path / "installer-calls.json" + body = f""" +import json, pathlib, shutil, sys +root = pathlib.Path({str(installation.root)!r}) +marker = pathlib.Path({str(marker)!r}) +calls = json.loads(marker.read_text()) if marker.exists() else [] +calls.append(sys.argv[1:]) +marker.write_text(json.dumps(calls)) +if {fail!r}: raise SystemExit(7) +bundle = pathlib.Path(sys.argv[1]) +target = root / 'releases' / 'new' +shutil.copytree(bundle, target) +link = root / 'next' +link.symlink_to(target) +link.replace(root / 'current') +""" + installer = f"#!/bin/sh\nexec {shlex.quote(sys.executable)} -c {shlex.quote(body)} \"$@\"\n" + files = { + "release-manifest.json": json.dumps(target).encode(), + "requirements-wrapper.lock": b"claude-agent-sdk==0.2.151\n", + "cc_remote/claude_service/wire.py": b"VERSION = 1\n", + f"deploy/install-{installation.role}.sh": installer.encode(), + } + with tarfile.open(mirror / filename, "w:gz") as archive: + for name, data in files.items(): + info = tarfile.TarInfo(f"{prefix}/{name}") + info.size = len(data) + info.mode = 0o755 if name.endswith(".sh") else 0o644 + archive.addfile(info, io.BytesIO(data)) + if unsafe: + info = tarfile.TarInfo(unsafe if unsafe.startswith("/") else f"{prefix}/{unsafe}") + if unsafe == "link": + info.type = tarfile.SYMTYPE + info.linkname = "/tmp" + archive.addfile(info) + digest = hashlib.sha256((mirror / filename).read_bytes()).hexdigest() + (mirror / "SHA256SUMS").write_text(f"{digest} {filename}\n") + return mirror, marker, filename + + +@pytest.mark.parametrize("role,system", [("wrapper", "darwin"), ("wrapper", "linux"), ("relay", "linux")]) +def test_update_downloads_verified_bundle_and_preserves_install_identity(tmp_path, monkeypatch, role, system): + installation = _installation(tmp_path, monkeypatch, role=role, system=system) + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + old_metadata = (installation.root / "installation.json").read_bytes() + assert main(["update", "--version", "4.0.1"]) == 0 + active = updater.read_installation(installation.root, system, "arm64") + assert active.manifest["product_version"] == "4.0.1" + assert installation.release.exists() + assert (installation.root / "installation.json").read_bytes() == old_metadata + assert (installation.root / "operator-config").read_text() == "preserve operator settings\n" + assert (installation.root / "native-service-alive").read_text() == "still running\n" + calls = json.loads(marker.read_text()) + assert len(calls) == 1 + expected = ["--domain", "remote.example.test"] if role == "relay" else ( + ["--user", "service-user"] if system == "linux" else [] + ) + assert calls[0][1:] == expected + + +def test_check_never_downloads_locks_or_activates(tmp_path, monkeypatch, capsys): + installation = _installation(tmp_path, monkeypatch) + before = sorted(installation.root.iterdir()) + monkeypatch.setattr(updater, "latest_version", lambda _: "4.0.1") + monkeypatch.setattr(updater, "download_bundle", lambda *a: pytest.fail("unexpected download")) + assert main(["update", "--check"]) == 0 + assert "Update available" in capsys.readouterr().out + assert main(["update", "--check", "--version", "4.0.0"]) == 0 + assert sorted(installation.root.iterdir()) == before + assert (installation.root / "current").resolve() == installation.release + + +@pytest.mark.parametrize("pinned", [False, True]) +def test_current_version_is_not_complete_while_activation_is_locked(tmp_path, monkeypatch, capsys, pinned): + installation = _installation(tmp_path, monkeypatch) + pending = installation.root / "releases/pending" + shutil.copytree(installation.release, pending) + (pending / "release-manifest.json").write_text(json.dumps(_manifest(version="4.0.1"))) + current = installation.root / "current" + current.unlink() + current.symlink_to(pending) + monkeypatch.setattr(updater, "latest_version", lambda _: "4.0.1") + monkeypatch.setattr(updater, "download_bundle", lambda *a: pytest.fail("unexpected download")) + arguments = ["update", "--version", "4.0.1"] if pinned else ["update"] + with updater.update_lock(installation.root): + assert main(arguments) == 1 + output = capsys.readouterr() + assert "already running" in output.err + assert "Already up to date" not in output.out + assert main(arguments) == 0 + assert "Already up to date" in capsys.readouterr().out + + +@pytest.mark.parametrize("rolled_back", [False, True]) +def test_version_is_refreshed_after_acquiring_the_activation_lock(tmp_path, monkeypatch, capsys, rolled_back): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + concurrent = installation.root / "releases/concurrent" + shutil.copytree(installation.release, concurrent) + (concurrent / "release-manifest.json").write_text(json.dumps(_manifest(version="4.0.1"))) + current = installation.root / "current" + if rolled_back: + current.unlink() + current.symlink_to(concurrent) + acquire = updater.acquire_install_lock + def prior_activation_finishes(root): + replacement = root / "next" + replacement.symlink_to(installation.release if rolled_back else concurrent) + replacement.replace(current) + return acquire(root) + monkeypatch.setattr(updater, "acquire_install_lock", prior_activation_finishes) + assert main(["update", "--version", "4.0.1"]) == 0 + assert updater.read_installation(installation.root, "darwin", "arm64").manifest["product_version"] == "4.0.1" + if rolled_back: + assert len(json.loads(marker.read_text())) == 1 + assert "Already up to date" not in capsys.readouterr().out + else: + assert not marker.exists() + assert "Already up to date" in capsys.readouterr().out + + +@pytest.mark.parametrize("version", ["../4.0.1", "4.0.1;id", "v4.0.1", "4.0.1-beta", "04.0.1", "3.0.0"]) +def test_invalid_or_older_version_does_not_download(tmp_path, monkeypatch, version): + _installation(tmp_path, monkeypatch) + monkeypatch.setattr(updater, "download_bundle", lambda *a: pytest.fail("unexpected download")) + assert main(["update", "--version", version]) == 1 + + +@pytest.mark.parametrize("changes", [{"os": "linux"}, {"arch": "x86_64"}, {"role": "relay"}, {"product_version": "4.0.2"}, {"protocol_version": True}]) +def test_mismatched_bundle_never_reaches_installer(tmp_path, monkeypatch, changes): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, _ = _bundle(tmp_path, installation, changes=changes) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + assert main(["update", "--version", "4.0.1"]) == 1 + assert not marker.exists() + assert (installation.root / "current").resolve() == installation.release + + +@pytest.mark.parametrize("problem", ["checksum", "duplicate-checksum", "checksum-encoding", "link", "../outside", "/absolute", "release-manifest.json"]) +def test_tampered_or_unsafe_archive_is_rejected(tmp_path, monkeypatch, problem): + installation = _installation(tmp_path, monkeypatch) + unsafe = None if "checksum" in problem else problem + mirror, marker, filename = _bundle(tmp_path, installation, unsafe=unsafe) + checksum = mirror / "SHA256SUMS" + if problem == "checksum": + checksum.write_text(f"{'0' * 64} {filename}\n") + elif problem == "duplicate-checksum": + checksum.write_text(checksum.read_text() * 2) + elif problem == "checksum-encoding": + checksum.write_bytes(b"\xff\xfe") + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + assert main(["update", "--version", "4.0.1"]) == 1 + assert not marker.exists() + + +def test_missing_download_never_reaches_activation(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, filename = _bundle(tmp_path, installation) + (mirror / filename).unlink() + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + assert main(["update", "--version", "4.0.1"]) == 1 + assert not marker.exists() + assert (installation.root / "current").resolve() == installation.release + + +def test_changed_installation_is_not_activated_after_download(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + download = updater.download_bundle + def changed(*args): + bundle = download(*args) + metadata = {**installation.metadata, "user": "another-user"} + (installation.root / "installation.json").write_text(json.dumps(metadata)) + return bundle + monkeypatch.setattr(updater, "download_bundle", changed) + assert main(["update", "--version", "4.0.1"]) == 1 + assert not marker.exists() + assert (installation.root / "current").resolve() == installation.release + + +def test_protocol_change_requires_explicit_coordinated_upgrade(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, _ = _bundle(tmp_path, installation, changes={"protocol_version": 73}) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + assert main(["update", "--version", "4.0.1"]) == 1 + assert not marker.exists() + assert main(["update", "--version", "4.0.1", "--allow-protocol-change"]) == 0 + assert len(json.loads(marker.read_text())) == 1 + + +def test_sdk_change_does_not_restart_an_independent_service(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + (installation.release / "requirements-wrapper.lock").write_text("claude-agent-sdk==0.2.150\n") + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + assert main(["update", "--version", "4.0.1", "--allow-protocol-change"]) == 1 + assert not marker.exists() + + +def test_installer_failure_is_not_retried_or_reported_as_success(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + mirror, marker, _ = _bundle(tmp_path, installation, fail=True) + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", mirror.as_uri()) + assert main(["update", "--version", "4.0.1"]) == 1 + assert len(json.loads(marker.read_text())) == 1 + assert (installation.root / "current").resolve() == installation.release + + +def test_interrupt_waits_for_installer_rollback_instead_of_killing_it(monkeypatch): + calls = [] + class Installer: + def wait(self): + calls.append("wait") + if len(calls) == 1: + raise KeyboardInterrupt + return 130 + monkeypatch.setattr(updater.subprocess, "Popen", lambda *args, **kwargs: Installer()) + assert updater.run_installer(["bash", "installer.sh"], 123) == 130 + assert calls == ["wait", "wait"] + + +def test_update_lock_is_exclusive_and_survives_holder_failure(tmp_path): + with updater.update_lock(tmp_path): + with pytest.raises(updater.UpdateError, match="already running"): + with updater.update_lock(tmp_path): + pytest.fail("second updater entered") + with updater.update_lock(tmp_path): + pass + + +def test_installer_keeps_lock_if_the_controller_disconnects(tmp_path): + with updater.update_lock(tmp_path) as descriptor: + child = subprocess.Popen( + [sys.executable, "-c", "import sys; sys.stdin.read(1)"], + stdin=subprocess.PIPE, pass_fds=(descriptor,), + ) + try: + with pytest.raises(updater.UpdateError, match="already running"): + with updater.update_lock(tmp_path): + pytest.fail("controller exit released the active installer lock") + finally: + child.communicate(b"x", timeout=5) + with updater.update_lock(tmp_path): + pass + + +def test_multiple_roles_need_an_explicit_selection(tmp_path, monkeypatch): + relay = _installation(tmp_path, monkeypatch, role="relay", system="linux") + wrapper = _installation(tmp_path, monkeypatch, system="linux") + monkeypatch.setattr(updater, "installation_roots", lambda _: {"relay": relay.root, "wrapper": wrapper.root}) + with pytest.raises(updater.UpdateError, match="both roles"): + updater.select_installation(None, "linux", "arm64") + assert updater.select_installation("relay", "linux", "arm64") == relay + assert updater.select_installation("wrapper", "linux", "arm64") == wrapper + + +@pytest.mark.parametrize("selected_role", ["relay", "wrapper"]) +@pytest.mark.parametrize("problem", ["metadata", "current", "manifest"]) +def test_explicit_role_ignores_an_unrelated_broken_installation(tmp_path, monkeypatch, selected_role, problem): + installations = { + role: _installation(tmp_path, monkeypatch, role=role, system="linux") + for role in ("relay", "wrapper") + } + monkeypatch.setattr(updater, "installation_roots", lambda _: { + role: installation.root for role, installation in installations.items() + }) + other_role = "wrapper" if selected_role == "relay" else "relay" + broken = installations[other_role] + if problem == "metadata": + (broken.root / "installation.json").write_text("{invalid metadata") + elif problem == "current": + (broken.root / "current").unlink() + (broken.root / "current").symlink_to(broken.root / "releases/missing") + else: + (broken.release / "release-manifest.json").unlink() + + assert updater.select_installation(selected_role, "linux", "arm64") == installations[selected_role] + assert main(["update", "--check", "--role", selected_role, "--version", "4.0.1"]) == 0 + for role in (other_role, None): + with pytest.raises(updater.UpdateError, match="cannot read managed installation"): + updater.select_installation(role, "linux", "arm64") + + +def test_selected_managed_root_cannot_impersonate_another_role(tmp_path, monkeypatch): + wrapper = _installation(tmp_path, monkeypatch, system="linux") + monkeypatch.setattr(updater, "installation_roots", lambda _: {"relay": wrapper.root}) + with pytest.raises(updater.UpdateError, match="role does not match"): + updater.select_installation("relay", "linux", "arm64") + + +@pytest.fixture +def linux_launcher(tmp_path): + managed = tmp_path / "managed roots" + stubs = tmp_path / "bin" + stubs.mkdir() + (stubs / "uname").write_text("#!/bin/sh\necho Linux\n") + (stubs / "id").write_text("#!/bin/sh\necho 0\n") + for stub in stubs.iterdir(): + stub.chmod(0o755) + releases = {} + for role, directory in (("relay", "cc-remote"), ("wrapper", "cc-remote-wrapper")): + release = managed / directory / "release" + (release / ".venv/bin").mkdir(parents=True) + runtime = release / ".venv/bin/python" + runtime.write_text(f"#!/bin/sh\nexec {shlex.quote(sys.executable)} \"$@\"\n") + runtime.chmod(0o755) + package = release / "cc_remote" + package.mkdir() + (package / "__init__.py").touch() + (package / "__main__.py").write_text( + f"import json, sys\nprint(json.dumps({{'role': {role!r}, 'argv': sys.argv[1:]}}))\n") + (release.parent / "current").symlink_to(release) + releases[role] = release + script = tmp_path / "cc-remote" + script.write_text((ROOT / "scripts/cc-remote").read_text().replace( + "/opt/cc-remote", str(managed / "cc-remote"))) + env = {**os.environ, "PATH": f'{stubs}:{os.environ["PATH"]}'} + return ["bash", str(script)], env, releases + + +@pytest.mark.parametrize("role", ["relay", "wrapper"]) +@pytest.mark.parametrize("option", ["--role", "--role=", "--ro", "--ro="]) +def test_launcher_selects_requested_runtime_before_loading_python(linux_launcher, role, option): + command, env, releases = linux_launcher + other = "wrapper" if role == "relay" else "relay" + (releases[other] / "cc_remote/__main__.py").write_text("raise ImportError('broken unrelated runtime')\n") + selection = [f"{option}{role}"] if option.endswith("=") else [option, role] + arguments = ["update", "--check", *selection] + result = subprocess.run(command + arguments, env=env, capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"role": role, "argv": arguments} + + +@pytest.mark.parametrize("role", ["relay", "wrapper"]) +def test_launcher_does_not_fall_back_when_requested_runtime_is_missing(linux_launcher, role): + command, env, releases = linux_launcher + (releases[role] / ".venv/bin/python").unlink() + result = subprocess.run(command + ["update", "--role", role], env=env, + capture_output=True, text=True, timeout=10) + assert result.returncode == 1 + assert "No managed cc-remote release found" in result.stderr + + +def test_launcher_uses_last_explicit_role_without_consuming_arguments(linux_launcher): + command, env, _releases = linux_launcher + arguments = ["update", "--role", "wrapper", "--role=relay", "--check"] + result = subprocess.run(command + arguments, env=env, capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"role": "relay", "argv": arguments} + + +@pytest.mark.parametrize("selection", [["--role"], ["--role="], ["--role", "invalid"]]) +def test_launcher_rejects_missing_or_invalid_role(linux_launcher, selection): + command, env, _releases = linux_launcher + result = subprocess.run(command + ["update", *selection], env=env, + capture_output=True, text=True, timeout=10) + assert result.returncode == 2 + assert "--role requires relay or wrapper" in result.stderr + + +def test_custom_installation_is_not_adopted(tmp_path, monkeypatch): + installation = _installation(tmp_path, monkeypatch) + (installation.root / "installation.json").unlink() + with pytest.raises(updater.UpdateError, match="no managed"): + updater.select_installation(None, "darwin", "arm64") + assert (installation.root / "current").resolve() == installation.release + + +@pytest.mark.parametrize("payload", [ + {"tag_name": "v4.0.2", "draft": False, "prerelease": False}, + {"tag_name": "v4.0.2-beta", "draft": False, "prerelease": True}, + {"tag_name": "v4.0.2", "draft": True, "prerelease": False}, + {"tag_name": "v../bad", "draft": False, "prerelease": False}, + [], +]) +def test_latest_version_only_accepts_a_stable_release(monkeypatch, payload): + monkeypatch.setattr(updater, "urlopen", lambda *a, **k: io.BytesIO(json.dumps(payload).encode())) + if isinstance(payload, dict) and payload.get("tag_name") == "v4.0.2" and not payload.get("draft"): + assert updater.latest_version("example/repository") == "4.0.2" + else: + with pytest.raises(updater.UpdateError): + updater.latest_version("example/repository") + + +def test_rate_limited_release_lookup_has_an_actionable_error(monkeypatch): + def limited(*args, **kwargs): + raise HTTPError("https://api.github.com", 403, "rate limited", {}, None) + monkeypatch.setattr(updater, "urlopen", limited) + with pytest.raises(updater.UpdateError, match="HTTP 403.*--version"): + updater.latest_version("example/repository") + + +@pytest.mark.parametrize("value", ["http://example.test/releases", "https://user:secret@example.test", "https://example.test?token=x", "file://other-host/tmp"]) +def test_untrusted_release_url_is_rejected(monkeypatch, value): + monkeypatch.setenv("CC_REMOTE_RELEASE_BASE_URL", value) + with pytest.raises(updater.UpdateError): + updater.release_base("example/repository", "4.0.1") + + +def test_wrapper_child_cannot_be_its_own_updater(monkeypatch): + monkeypatch.setattr(updater.os, "getppid", lambda: 456) + monkeypatch.setattr(updater.subprocess, "run", lambda *a, **k: SimpleNamespace( + returncode=0, stdout="1 /release/.venv/bin/python -m cc_remote.wrapper\n")) + with pytest.raises(updater.UpdateError, match="independent terminal"): + updater.require_independent_terminal() + + +def test_independent_terminal_is_accepted(monkeypatch): + monkeypatch.setattr(updater.os, "getppid", lambda: 456) + monkeypatch.setattr(updater.subprocess, "run", lambda *a, **k: SimpleNamespace( + returncode=0, stdout="1 /bin/zsh\n")) + updater.require_independent_terminal() + + +def test_cli_registration_is_atomic_and_preserves_unrelated_commands(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") + assert destination.read_bytes() == source.read_bytes() + assert os.access(destination, os.X_OK) + metadata = json.loads((installation.root / "installation.json").read_text()) + assert metadata == {"schema": 1, "role": "wrapper", "user": "service-user"} + install_cli(installation.root, destination, role="wrapper", user="service-user") + destination.write_text("#!/bin/sh\necho user-command\n") + with pytest.raises(ValueError, match="unrelated"): + install_cli(installation.root, destination, role="wrapper", user="service-user") + assert destination.read_text() == "#!/bin/sh\necho user-command\n" + destination.unlink() + destination.symlink_to(source) + with pytest.raises(ValueError, match="symlink"): + check_destination(destination) + + +def test_failed_registration_restores_the_previous_command(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 / "bin/cc-remote" + install_cli(installation.root, destination, role="wrapper", user="service-user") + previous = destination.read_bytes() + source.write_bytes(previous + b"\n# new release\n") + atomic = cli_installer._atomic_file + def fail_metadata(target, content, mode): + if target.name == "installation.json": + raise OSError("injected disk failure") + atomic(target, content, mode) + monkeypatch.setattr(cli_installer, "_atomic_file", fail_metadata) + with pytest.raises(OSError, match="disk failure"): + install_cli(installation.root, destination, role="wrapper", user="service-user") + assert destination.read_bytes() == previous + + +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' + program = script.split(marker, 1)[1].split("\nPY\n", 1)[0] + prior = tmp_path / "previous.plist" + environment = { + "CC_REMOTE_CLAUDE_SERVICE_SOCKET": "/private/example/service.sock", + "CC_REMOTE_CLAUDE_PROFILES_FILE": "/private/example/accounts.json", + "CLAUDE_WORK_ROOT": "/private/example/work", + "LOG_LEVEL": "DEBUG", + } + prior.write_bytes(plistlib.dumps({"EnvironmentVariables": environment})) + destination = tmp_path / "new.plist" + 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), + ], 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") diff --git a/tests/test_wrapper_startup.py b/tests/test_wrapper_startup.py index 4b4317e0..4c25af4b 100644 --- a/tests/test_wrapper_startup.py +++ b/tests/test_wrapper_startup.py @@ -40,6 +40,8 @@ async def run() -> None: }, }) machine = WrapperMachine(cfg, _Transport()) + assert all(not manager.allow_restart for manager in machine._codex_daemons.values()) + assert all(manager.require_shared for manager in machine._codex_daemons.values()) calls: list[tuple[str, str, str]] = [] resolved = 0 @@ -59,7 +61,7 @@ async def ensure_started(self, binary, env): calls.append((self.profile_id, binary, env["CODEX_HOME"])) if self.profile_id == "stack": raise RuntimeError("profile unavailable") - return SimpleNamespace(verified_remote_control=True) + return SimpleNamespace(socket_path=None, verified_remote_control=True) machine._codex_daemons = { profile.id: Manager(profile.id) @@ -75,10 +77,48 @@ async def ensure_started(self, binary, env): ("primary", "/opt/codex", str((tmp_path / "primary").resolve())), ("stack", "/opt/codex", str((tmp_path / "stack").resolve())), } + receipt = json.loads((cfg.state_dir / "codex-readiness.json").read_text()) + assert {row["profile"]: row["reason"] for row in receipt["profiles"]} == { + "primary": "daemon_unavailable", "stack": "connection_failed", + } asyncio.run(run()) +def test_default_account_check_resolves_home_even_with_legacy_none_env(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("CODEX_HOME", raising=False) + cfg = WrapperConfig( + codex_daemon_mode="auto", state_dir=tmp_path / "state", codex_profiles_json="", + claude_work_root=tmp_path / "claude-work", codex_work_root=tmp_path / "codex-work", + ) + monkeypatch.setattr(machine_module, "resolve_codex_bin", lambda: "/opt/codex") + async def check(profile, home, binary, daily, env, manager): + assert home == str((tmp_path / ".codex").resolve()) + assert "CODEX_HOME" not in env + return {"profile": profile, "status": "ready"} + monkeypatch.setattr(machine_module.codex_readiness, "check_profile", check) + machine = WrapperMachine(cfg, _Transport()) + assert machine._codex_home(machine._codex_profiles.default) is None + asyncio.run(machine.prepare_codex_daemons()) + receipt = json.loads((cfg.state_dir / "codex-readiness.json").read_text()) + assert receipt["profiles"] == [{"profile": "primary", "status": "ready"}] + + +def test_disabled_sharing_publishes_without_starting_cli(monkeypatch, tmp_path) -> None: + cfg = WrapperConfig( + codex_daemon_mode="off", state_dir=tmp_path / "state", codex_profiles_json="", + claude_work_root=tmp_path / "claude", codex_work_root=tmp_path / "codex", + ) + def unexpected(): + raise AssertionError("disabled sharing must not resolve or start Codex") + monkeypatch.setattr(machine_module, "resolve_codex_bin", unexpected) + machine = WrapperMachine(cfg, _Transport()) + asyncio.run(machine.prepare_codex_daemons()) + receipt = json.loads((cfg.state_dir / "codex-readiness.json").read_text()) + assert receipt["profiles"] == [{"profile": "primary", "status": "disabled"}] + + def test_wrapper_entrypoint_prepares_codex_before_run(monkeypatch) -> None: events: list[str] = [] cfg = WrapperConfig() diff --git a/web/package-lock.json b/web/package-lock.json index 97fd21ae..79baa437 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "4.0.0", + "version": "4.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "dependencies": { "@tanstack/react-virtual": "3.14.8", diff --git a/web/package.json b/web/package.json index e2fe85c8..d3f3364a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "4.0.0", + "version": "4.0.1", "author": "muggle", "license": "MIT", "type": "module", diff --git a/web/public/cc-remote-build.json b/web/public/cc-remote-build.json index 4ffce62e..841efd59 100644 --- a/web/public/cc-remote-build.json +++ b/web/public/cc-remote-build.json @@ -1,4 +1,4 @@ { - "version": "4.0.0", + "version": "4.0.1", "protocol": 72 }