From a0a835c6b7b02ce4661ae8ca6606b7af49d1f9c4 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 00:14:46 +0800 Subject: [PATCH 1/9] feat(deploy): support managed Linux upgrades and release retention - Register legacy systemd and Supervisor Wrappers with the shared update CLI. - Preserve service identity, external state, pairing and independent Claude processes through activation and rollback. - Record complete recovery generations and retire proven older artifacts with live dependency checks. - Document one-command updates, Linux adoption and deferred retention in both languages. --- .agents/skills/cc-remote-deploy/SKILL.md | 15 +- README.md | 19 +- README_en.md | 27 +- cc_remote/update.py | 28 +- cc_remote/update_relay.py | 24 +- deploy/README.md | 58 ++- deploy/adopt_wrapper.py | 90 +++++ deploy/build_release.py | 6 + deploy/check_codex_readiness.py | 5 +- deploy/cleanup.py | 10 +- deploy/install-wrapper.sh | 291 +++++++++++++-- deploy/install_cli.py | 38 +- deploy/linux_service.py | 431 ++++++++++++++++++++++ deploy/release_retention.py | 437 +++++++++++++++++++++++ deploy/setup-vps.sh | 20 ++ deploy/setup_transaction.sh | 10 +- deploy/work_registry_snapshot.py | 4 + deploy/wrapper_exec.py | 59 +++ docs/installation.md | 83 ++++- docs/installation_en.md | 107 +++++- scripts/cc-remote | 6 +- tests/test_adopt_wrapper.py | 71 ++++ tests/test_codex_readiness.py | 6 +- tests/test_deploy.py | 2 +- tests/test_install_commit.py | 8 +- tests/test_linux_service.py | 203 +++++++++++ tests/test_release_distribution.py | 2 + tests/test_release_retention.py | 281 +++++++++++++++ tests/test_update.py | 58 ++- tests/test_update_installer_relay.py | 33 +- 30 files changed, 2343 insertions(+), 89 deletions(-) create mode 100644 deploy/adopt_wrapper.py create mode 100644 deploy/linux_service.py create mode 100644 deploy/release_retention.py create mode 100644 deploy/wrapper_exec.py create mode 100644 tests/test_adopt_wrapper.py create mode 100644 tests/test_linux_service.py create mode 100644 tests/test_release_retention.py diff --git a/.agents/skills/cc-remote-deploy/SKILL.md b/.agents/skills/cc-remote-deploy/SKILL.md index a33e90b1..3c918b52 100644 --- a/.agents/skills/cc-remote-deploy/SKILL.md +++ b/.agents/skills/cc-remote-deploy/SKILL.md @@ -24,6 +24,13 @@ Prefer one tested source snapshot for current features. Use a published Release when the user selects that version and its contents meet the requested scope; do not assume the latest published tag includes the current branch's features. +For an existing Linux Supervisor installation (including a container), follow +[the explicit adoption procedure](../../../docs/installation_en.md#linux-supervisor-installation-and-adoption). +Reconcile the actual launcher environment first. Use the standard Wrapper installer +with its service binding, register the CLI, and verify `cc-remote update --check`; +copying a release and manually restarting Supervisor is not a complete installation. +Never reload the entire supervisor or restart the independent Claude service. + Use the operator's actual inventory and existing service ownership. Freeze one tested snapshot for all protocol tiers, preserve private configuration/state, and use the repository's immutable activation transactions. Lost connectivity @@ -55,8 +62,12 @@ After cleanup, repeat live config checks as each Codex service user and verify the Wrapper session route and public health. A pre-cleanup readiness receipt is not final acceptance. Report retained/deferred paths and removed allocated bytes; do not equate those bytes with actual free-space gain. No daemon or active task -may be stopped solely to make an artifact deletable. Installers do not prune -automatically. +may be stopped solely to make an artifact deletable. Managed Release installers +containing `release_retention.py` now generate an inventory from their own +activation ledger and call `cleanup.py` after acceptance; published v4.0.9 does +not contain that behavior. Inspect their retention result rather than duplicating +it with another script. Unknown/source/manual backups still need the explicit +reviewed inventory. A deferred cleanup does not authorize deleting its files. ## Codex CLI sharing is an acceptance check diff --git a/README.md b/README.md index de629651..9bdaf276 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,23 @@ CLAUDE_BIN= ## 安装与升级 -包含管理命令的 Release 安装支持 `cc-remote update --check` 检查更新、 -`cc-remote update` 升级。v4.0.0 首次过渡、角色选择和协议协调升级见 +Release 安装器会自动安装 `cc-remote` 管理命令。后续在独立终端或 SSH 中一键更新: + +```bash +cc-remote update --check # 只检查版本 +cc-remote update # 升级到最新稳定 Release,不需要加 latest +``` + +设备会先检查配对的 VPS:需要时先升级 Relay + Web,已是兼容的新版本则只更新本机。 +首次远程升级 VPS 可用 `cc-remote update --relay-ssh operator@relay-host` 绑定已有的 +SSH 管理入口。其他设备各自执行更新即可。安装使用预构建产物,不在用户机器跑 CI、 +pytest 或前端构建;Electron 应用本体单独更新。 + +当前源码新增自动备份保留:升级验收通过后,保留当前版本、上一版完整回滚资料及仍在使用的 +运行依赖,清理可确认的更早版本。Linux Wrapper 也可通过已有 Supervisor 安装并接入同一更新器, +保留实际目录、用户及 HOME;见 [Supervisor 安装与接管](docs/installation.md#linux-supervisor-安装与首次接管)。 +**v4.0.9 尚不含自动清理、Linux 旧布局迁移及 Supervisor 支持**; +需使用包含这些改动的新安装包。命令位置、旧安装过渡、角色选择和协议协调升级见 [后续更新](docs/installation.md#后续更新)。 | 场景 | 文档 | diff --git a/README_en.md b/README_en.md index 9cfcd703..3bd017be 100644 --- a/README_en.md +++ b/README_en.md @@ -243,10 +243,29 @@ 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. +Release installers register the `cc-remote` management command automatically. +Update from an independent terminal or SSH connection: + +```bash +cc-remote update --check # Check versions only +cc-remote update # Install the latest stable Release; no latest argument needed +``` + +Each device checks its paired VPS first, updating Relay + Web when necessary. +An already-current compatible Relay is skipped. Use +`cc-remote update --relay-ssh operator@relay-host` to bind an existing SSH admin +entry for the first remote server upgrade. Update other devices individually. +Installation uses prebuilt artifacts; it does not run CI, pytest or a frontend +build on users' machines. The Electron application is updated separately. + +Current source adds automatic retention: after acceptance, keep the active +version, one complete previous rollback set and all live runtime dependencies; +remove proven older generations. Linux Wrappers can also use an existing Supervisor +with the same updater, preserving their root, account and HOME; see +[Supervisor installation/adoption](docs/installation_en.md#linux-supervisor-installation-and-adoption). +**v4.0.9 does not include automatic cleanup, Linux legacy migration or Supervisor support**; these require a bundle containing the +new changes. See [subsequent updates](docs/installation_en.md#subsequent-updates) +for command locations, older installations, roles and coordinated protocol upgrades. | Scenario | Guide | |---|---| diff --git a/cc_remote/update.py b/cc_remote/update.py index bfa8688b..85227851 100644 --- a/cc_remote/update.py +++ b/cc_remote/update.py @@ -67,7 +67,7 @@ def installation_roots(system: str) -> dict[str, Path]: if system == "darwin": return {"wrapper": Path(os.environ.get("CC_REMOTE_MANAGED_ROOT") or Path.home() / "Library/Application Support/cc-remote")} - return {"relay": Path("/opt/cc-remote"), "wrapper": Path("/opt/cc-remote-wrapper")} + return {"relay": Path("/opt/cc-remote"), "wrapper": Path(os.environ.get("CC_REMOTE_MANAGED_ROOT") or "/opt/cc-remote-wrapper")} def read_installation(root: Path, system: str, machine: str) -> Installation: @@ -95,8 +95,19 @@ def read_installation(root: Path, system: str, machine: str) -> Installation: 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": + if not isinstance(user, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", user) or (user == "root" and not metadata.get("linux_service")): raise UpdateError("wrapper installation has no valid original service user") + profile = metadata.get('linux_service') + if profile is not None: + from deploy.linux_service import validate, trusted, absolute + try: + validate(profile) + if system != 'linux' or profile['user'] != user: + raise ValueError('service binding mismatch') + trusted(absolute(str(root))) + trusted(metadata_path) + except (ValueError, OSError) as exc: + raise UpdateError('invalid managed Linux service binding') from exc label = metadata.get("service_label") if label is not None and (system != "darwin" or not isinstance(label, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]{0,127}", label)): @@ -358,6 +369,14 @@ def update(*, role: str | None, target_version: str | None, check: bool, require_independent_terminal() relay.ensure(version, installation.manifest["protocol_version"], allow_protocol_change=allow_protocol_change) + if not check and selected == current: + retention = installation.release / "deploy/release_retention.py" + if retention.is_file() and (installation.root / ".release-generations.json").exists(): + require_independent_terminal() + if run_installer([str(installation.release / ".venv/bin/python"), "-I", "-B", + str(retention), "prune", "--root", str(installation.root)], + lock_descriptor): + print("WARNING: Update is current; older backups retained pending safe cleanup.") print("Already up to date." if selected == current else "Installed version is newer than the latest release.") return 0 if check: @@ -391,6 +410,11 @@ def update(*, role: str | None, target_version: str | None, check: bool, command += ["--domain", installation.metadata["domain"]] elif system == "linux": command += ["--user", installation.metadata["user"]] + if profile := installation.metadata.get('linux_service'): + command += ['--install-root', str(installation.root), '--service-manager', profile['manager'], + '--service-name', profile['name'], '--service-file', profile['service_file'], + '--home', profile['home'], '--env-file', profile['env_file'], + '--device-file', profile['device_file'], '--supervisor-config', profile['supervisor_config']] elif installation.metadata.get("service_label"): command += ["--install-root", str(installation.root), "--service-label", installation.metadata["service_label"]] diff --git a/cc_remote/update_relay.py b/cc_remote/update_relay.py index e3495346..dc594a75 100644 --- a/cc_remote/update_relay.py +++ b/cc_remote/update_relay.py @@ -42,6 +42,12 @@ def relay_origin(installation: Installation) -> str: value = json.loads(path.read_text()).get("relay_url") except (OSError, ValueError, AttributeError) as exc: raise UpdateError("cannot read this device's Relay URL; check its pairing configuration") from exc + elif installation.metadata.get('linux_service'): + from deploy.linux_service import environment, validate + try: + value = environment(validate(installation.metadata['linux_service'])).get('RELAY_URL') + except (OSError, ValueError) as exc: + raise UpdateError("cannot read the bound Linux service's Relay configuration") from exc else: config = {**dotenv_values("/etc/cc-remote/wrapper.env"), **dotenv_values("/etc/cc-remote/device.env")} @@ -137,7 +143,8 @@ def _ssh(self, command: list[str], *, timeout: int = 30) -> str: pwd.getpwnam(user) except KeyError as exc: raise UpdateError("the original Wrapper service user no longer exists") from exc - args = ["sudo", "-H", "-u", user, "--", *args] + if user != 'root': + args = ["runuser", "-u", user, "--", *args] if self.installation.metadata.get('linux_service') else ["sudo", "-H", "-u", user, "--", *args] try: response = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False) except (OSError, subprocess.TimeoutExpired) as exc: @@ -248,25 +255,36 @@ def installer_main(argv: list[str] | None = None) -> int: parser.add_argument("--root", required=True, type=Path) parser.add_argument("--bundle", required=True, type=Path) parser.add_argument("--user", required=True) + parser.add_argument("--previous-root", type=Path, + help="validated Linux legacy root during explicit migration") parser.add_argument("--service-label") + parser.add_argument("--linux-service", type=Path) parser.add_argument("--relay-ssh") parser.add_argument("--allow-protocol-change", action="store_true") args = parser.parse_args(argv) try: - current = args.root / "current" + previous_root = args.previous_root or args.root + current = previous_root / "current" previous = current.resolve(strict=True) - if not current.is_symlink() or previous.parent != (args.root / "releases").resolve(): + if not current.is_symlink() or previous.parent != (previous_root / "releases").resolve(): raise UpdateError("current must identify an existing immutable Wrapper release") old = load_manifest(previous / "release-manifest.json") target = load_manifest(args.bundle / "release-manifest.json") if (old["role"] != "wrapper" or target["role"] != "wrapper" or any(old[key] != target[key] for key in ("os", "arch"))): raise UpdateError("upstream preflight requires matching Wrapper installations") + if args.previous_root or args.linux_service: + from cc_remote.update import _claude_contract + if old["os"] != "linux" or _claude_contract(previous) != _claude_contract(args.bundle): + raise UpdateError("migration requires a compatible Claude SDK/service contract") if old["protocol_version"] != target["protocol_version"] and not args.allow_protocol_change: raise UpdateError("protocol changes; coordinate all devices and use --allow-protocol-change") metadata = {"schema": 1, "role": "wrapper", "user": args.user} if args.service_label: metadata["service_label"] = args.service_label + if args.linux_service: + from deploy.linux_service import validate + metadata['linux_service'] = validate(json.loads(args.linux_service.read_text())) installation = Installation(args.root, previous, old, metadata) RelayUpdate(installation, args.relay_ssh).ensure( target["product_version"], target["protocol_version"], diff --git a/deploy/README.md b/deploy/README.md index 1b213266..abc5a38d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -135,9 +135,32 @@ Report retained rollback paths, deleted generations, removed allocated bytes and any deferred paths. Allocated bytes are not a measurement of free-space gain (for example, shared filesystem blocks may remain in use). Moving old copies into another backup directory or Trash -does not reclaim disk space or satisfy this policy. This is an agent-operated -cleanup step: the existing installers and `cc-remote update` do not automatically -prune releases or backups. +does not reclaim disk space or satisfy this policy. + +Current-source **managed Release installers** apply this policy automatically +after activation and registration. `release_retention.py` records the exact +artifact identities in `.release-generations.json`, captures the previous service +and external configuration in `rollback-config/`, and binds the matching private +state snapshot in `rollback-data/`. It builds a bounded inventory and delegates +all deletion to `cleanup.py`, inheriting the installation lock. Fresh acceptance +checks public Relay/Web identity, stable service identity, Wrapper connectivity, +snapshot integrity and live Codex configuration as the service user. Process, +symlink and dormant service/configuration dependencies override retention limits. + +Failed/incomplete acceptance or insufficient process visibility retains files and +warns without rolling back an already committed installation. A same-version +`cc-remote update` retries deferred retention with fresh checks, without restarting +services or sending model messages; `--check` is read-only. An interrupted +installation's prepared record requires explicit inspection, not automatic retry. +Unknown backups/uploads and external legacy roots remain outside automatic +deletion and still require the reviewed agent inventory below. Configuration +backup `files.json` records each original destination, absence, owner and mode; +keep it with that generation for administrator-controlled recovery. + +**Published v4.0.9 does not include automatic retention or Linux legacy migration.** +These take effect with an installer bundle containing the new code, including +upgrades initiated by older management CLIs. Source/manual deployments continue +to use reviewed cleanup inventories and their existing activation transactions. #### Repository cleanup command @@ -261,11 +284,23 @@ Quarantine is temporary transaction state, not another retained backup or Trash. pairing is separate from this existing-installation upgrade path. Explicit Mac registration can bind an existing immutable root and LaunchAgent; future installs preserve that layout and service identity. The command does not - restart the independent Claude service, adopt source/Docker layouts, prune - releases, or automate downgrade rollback. + restart the independent Claude service, automatically adopt source/Docker + layouts, or automate downgrade rollback. Recorded managed generations use the + retention procedure above. 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. +- `linux_service.py` / `wrapper_exec.py` — Linux Supervisor adapter for the same + Wrapper installer/update transaction. Explicitly bind the root, Unix control + config, program/file, user, HOME and external environment/device files. Require + root-owned non-replaceable code/config paths. Never infer arbitrary launchers: + first adoption requires `--adopt-supervisor` and operator-reconciled environment + selectors. Only replace/reload that program; preserve other sections and the + independent Claude service. Snapshot selectors, Relay preflight, readiness and + retention use that same binding. See the complete + [Supervisor installation/adoption procedure](../docs/installation_en.md#linux-supervisor-installation-and-adoption). + Partial process visibility defers retention; it is not permission to delete + container releases. Container recreation/persistence remains runtime-owned. - `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 @@ -280,7 +315,8 @@ Quarantine is temporary transaction state, not another retained backup or Trash. - `install-wrapper.sh` — first-install/upgrade entry for published macOS and Linux Wrapper bundles. It builds the immutable release before pairing and activation, stores device authority outside the release, atomically switches - `current`, installs a per-user LaunchAgent or root-managed systemd unit, and + `current`, installs a per-user LaunchAgent, root-managed systemd unit or + explicitly selected Supervisor program, and 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. @@ -288,6 +324,16 @@ Quarantine is temporary transaction state, not another retained backup or Trash. 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. + An existing Linux immutable system-service installation can explicitly migrate + with `--adopt-root /absolute/legacy/root --user USER`. `adopt_wrapper.py` checks + its actual user, command, ownership, environment layout and absence of drop-ins + before activation. The standard `/opt/cc-remote-wrapper` destination must not + already be installed. The old root is preserved; external credentials and + service policy survive both migration and subsequent upgrades. A failure restores + the old service and state rather than registering an incomplete destination. + Unsupported layouts require explicit reconciliation, not a first-install + overwrite. Root privileges are required; a narrowly authorized custom activation + helper is not permission to bypass the system installer or its ownership checks. - `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 diff --git a/deploy/adopt_wrapper.py b/deploy/adopt_wrapper.py new file mode 100644 index 00000000..c9493421 --- /dev/null +++ b/deploy/adopt_wrapper.py @@ -0,0 +1,90 @@ +"""Validate an explicit Linux legacy-to-managed Wrapper migration. + +Only the existing system service is eligible. No discovery, new pairing, copying +native state or execution of code from the legacy installation occurs here. +""" +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import stat +import subprocess + + +def root_owned(path: Path) -> None: + info = path.lstat() + if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode): + raise ValueError("legacy install/service must be root-owned and not writable by other users") + + +def rebase_service(text: str, current: Path, target: Path, user: str) -> str: + """Preserve operator policy across migration and later managed upgrades.""" + values: dict[str, list[str]] = {} + section = "" + for line in text.splitlines(): + line = line.strip() + if line.startswith("["): + section = line + elif section == "[Service]" and line and not line.startswith(("#", ";")) and "=" in line: + key, value = line.split("=", 1) + values.setdefault(key, []).append(value) + if (values.get("User") != [user] or values.get("WorkingDirectory") != [str(current)] + or values.get("ExecStart") != [f"{current}/.venv/bin/python -m cc_remote.wrapper"]): + raise ValueError("service identity/command does not match the requested root and user") + env_files = values.get("EnvironmentFile", []) + if not env_files or any(p.lstrip("-") not in { + "/etc/cc-remote/wrapper.env", "/etc/cc-remote/device.env", + } for p in env_files): + raise ValueError("service uses a custom environment layout; migrate it explicitly first") + # Match path boundaries: do not rewrite a different path sharing a prefix. + return re.sub(re.escape(str(current)) + r"(?=/|\s|$|[\"'])", lambda _: str(target), text) + + +def validate(root: Path, user: str, destination: Path, + service_file: Path = Path("/etc/systemd/system/cc-remote-wrapper.service")) -> Path: + if (not root.is_absolute() or root.resolve(strict=True) != root + or re.search(r"[\s\"'\\\x00-\x1f]", str(root)) or root == destination + or root.is_relative_to(destination) or destination.is_relative_to(root)): + raise ValueError("legacy root must be a separate canonical absolute directory") + if (destination / "current").exists() or (destination / "installation.json").exists(): + raise ValueError("managed destination is already installed; migration would overwrite it") + for path in (root, root / "releases", service_file): + root_owned(path) + current = root / "current" + old = current.resolve(strict=True) + if not current.is_symlink() or old.parent != root / "releases": + raise ValueError("legacy current must point inside its immutable releases") + root_owned(old) + if not (old / "release-manifest.json").is_file(): + raise ValueError("legacy release manifest is required for compatibility checks") + rebase_service(service_file.read_text(), current, destination / "current", user) + output = subprocess.check_output([ + "systemctl", "show", "cc-remote-wrapper", "-p", "User", "-p", "WorkingDirectory", + "-p", "FragmentPath", "-p", "DropInPaths", + ], text=True, timeout=15) + live = dict(line.split("=", 1) for line in output.splitlines() if "=" in line) + if (live.get("User") != user or live.get("WorkingDirectory") != str(current) + or live.get("FragmentPath") != str(service_file) or live.get("DropInPaths")): + raise ValueError("effective service differs or has drop-ins; explicit reconciliation required") + return old + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", required=True, type=Path) + parser.add_argument("--destination", required=True, type=Path) + parser.add_argument("--user", required=True) + args = parser.parse_args() + try: + if os.geteuid() != 0: + raise ValueError("Linux migration requires the installation administrator") + print(validate(args.root, args.user, args.destination)) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + parser.exit(1, f"Migration refused: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/build_release.py b/deploy/build_release.py index 0f5cecce..1d6263dd 100755 --- a/deploy/build_release.py +++ b/deploy/build_release.py @@ -31,11 +31,13 @@ class BuildError(ValueError): _ARCHES = {"x86_64", "arm64"} _SHA_RE = re.compile(r"[0-9a-f]{40}") _RELAY_DEPLOY = ( + "linux_service.py", "Caddyfile", "Caddyfile.insecure", "Caddyfile.viewer.example", "caddy_managed_block.py", "cleanup.py", + "release_retention.py", "cc-remote-relay.service", "env.relay.example", "install-relay.sh", @@ -48,9 +50,13 @@ class BuildError(ValueError): "validate_protocol_bundle.py", ) _WRAPPER_DEPLOY = ( + "linux_service.py", + "wrapper_exec.py", + "adopt_wrapper.py", "atomic_symlink.py", "check_codex_readiness.py", "cleanup.py", + "release_retention.py", "cc-remote-wrapper.service", "com.muggle.cc-remote.wrapper.plist.in", "env.wrapper.example", diff --git a/deploy/check_codex_readiness.py b/deploy/check_codex_readiness.py index e5b78c40..4589c03e 100644 --- a/deploy/check_codex_readiness.py +++ b/deploy/check_codex_readiness.py @@ -131,12 +131,15 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--after", required=True, type=float) parser.add_argument("--env-file", type=Path) parser.add_argument("--plist", type=Path) + parser.add_argument("--state-dir", type=Path, + help="resolved private state root for a service-user live probe") parser.add_argument("--wait", type=float, default=45) parser.add_argument("--live-config", action="store_true", help="also read config from each live daemon as its service user; no model turn") args = parser.parse_args(argv) try: - state = resolve_wrapper_state_dir(args.home, env_file=args.env_file, plist=args.plist) + state = args.state_dir or 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) diff --git a/deploy/cleanup.py b/deploy/cleanup.py index b68f0d1a..7a08ebf6 100644 --- a/deploy/cleanup.py +++ b/deploy/cleanup.py @@ -19,7 +19,7 @@ import uuid sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from deploy.install_lock import acquire_install_lock +from deploy.install_lock import acquire_install_lock, verify_install_lock JOURNAL = ".cleanup-transaction.json" MAX_JSON = 1024 * 1024 @@ -342,12 +342,16 @@ def write_journal(path: Path, report: dict) -> None: temporary.unlink(missing_ok=True) -def cleanup(inventory: Path, *, apply: bool = False) -> dict: +def cleanup(inventory: Path, *, apply: bool = False, lock_descriptor: int | None = None) -> dict: plan = load_inventory(inventory) journal = plan["root"] / JOURNAL # Share the installer's lock. A dropped control connection cannot overlap a # second installer/cleanup; leftover journals are inspected, never replayed. - lock = acquire_install_lock(plan["root"]) + if lock_descriptor is None: + lock = acquire_install_lock(plan["root"]) + else: + verify_install_lock(plan["root"], lock_descriptor) + lock = os.dup(lock_descriptor) moved: list[tuple[Path, Path, dict]] = [] report = {"schema": 1, "inventory_sha256": plan["digest"], "started_at": time.time(), "mode": "apply" if apply else "preview", "status": "preview", diff --git a/deploy/install-wrapper.sh b/deploy/install-wrapper.sh index 93b5dcac..f8093252 100755 --- a/deploy/install-wrapper.sh +++ b/deploy/install-wrapper.sh @@ -13,6 +13,12 @@ usage() { Usage: install-wrapper.sh BUNDLE --relay https://remote.example.com --pair PAIR-CODE [--name LABEL] install-wrapper.sh BUNDLE [--user USER] [--relay-ssh USER@HOST] [--allow-protocol-change] + install-wrapper.sh BUNDLE --user USER --adopt-root /absolute/legacy/installation + install-wrapper.sh BUNDLE --user USER --service-manager supervisor \ + --supervisor-config /etc/supervisor/supervisord.conf --service-name wrapper \ + --service-file /etc/supervisor/conf.d/wrapper.conf --env-file /etc/cc-remote/wrapper.env \ + [--install-root /srv/cc-remote] [--home /root] [--device-file /root/.cc-remote/device.json] \ + [--adopt-supervisor] The relay and pair arguments are required for the first install. They may be omitted on upgrades when a device credential already exists. Linux installs @@ -30,10 +36,20 @@ pair_code="" device_name="" target_user="${CC_REMOTE_INSTALL_USER:-}" install_root="" +adopt_root="" installed_service_label="" relay_ssh="" allow_protocol_change=0 replace_pair=0 +service_manager=systemd +supervisor_config="" +supervisor_name=cc-remote-wrapper +supervisor_file="" +service_home="" +wrapper_env_file="" +paired_file="" +adopt_supervisor=0 +linux_profile="" while [ "$#" -gt 0 ]; do case "$1" in --relay) @@ -65,11 +81,30 @@ while [ "$#" -gt 0 ]; do install_root="$2" shift 2 ;; + --adopt-root) + [ "$#" -ge 2 ] || usage + adopt_root="$2" + shift 2 + ;; --service-label) [ "$#" -ge 2 ] || usage installed_service_label="$2" shift 2 ;; + --service-manager|--supervisor-config|--service-name|--service-file|--home|--env-file|--device-file) + [ "$#" -ge 2 ] || usage + case "$1" in + --service-manager) service_manager="$2" ;; + --supervisor-config) supervisor_config="$2" ;; + --service-name) supervisor_name="$2" ;; + --service-file) supervisor_file="$2" ;; + --home) service_home="$2" ;; + --env-file) wrapper_env_file="$2" ;; + --device-file) paired_file="$2" ;; + esac + shift 2 + ;; + --adopt-supervisor) adopt_supervisor=1; shift ;; --relay-ssh) [ "$#" -ge 2 ] || usage relay_ssh="$2" @@ -141,6 +176,8 @@ esac release_name="release-v${version}-${git_sha:0:12}" if [ "$system" = darwin ]; then + if [ "$service_manager" != systemd ] || [ -n "$supervisor_config$supervisor_file$service_home$wrapper_env_file$paired_file" ] || [ "$adopt_supervisor" -ne 0 ]; then die "Linux service options require Linux"; fi + [ -z "$adopt_root" ] || die "--adopt-root is only for an existing Linux system service" [ "$(id -u)" -ne 0 ] || \ die "macOS wrapper installation must run as the logged-in user, not root" target_user="$(id -un)" @@ -167,35 +204,60 @@ if [ "$system" = darwin ]; then log_dir="$target_home/Library/Logs/cc-remote" cli_path="$target_home/.local/bin/cc-remote" else - [ -z "$install_root$installed_service_label" ] || die "custom install roots are supported only on macOS" + [ -z "$installed_service_label" ] || die "--service-label is only supported on macOS" + case "$service_manager" in systemd|supervisor) ;; *) die "unsupported Linux service manager" ;; esac + if [ "$service_manager" = systemd ]; then + if [ -n "$install_root$supervisor_config$supervisor_file$service_home$wrapper_env_file$paired_file" ] || [ "$adopt_supervisor" -ne 0 ]; then die "custom Linux service options currently require Supervisor"; fi + else + if [ -z "$supervisor_config" ] || [ -z "$supervisor_file" ]; then die "Supervisor needs --supervisor-config and --service-file"; fi + [ -z "$adopt_root" ] || die "use --install-root and --adopt-supervisor for Supervisor" + fi [ "$(id -u)" -eq 0 ] || die "Linux wrapper installation must run as root" - command -v systemctl >/dev/null 2>&1 || die "systemd is required" + if [ "$service_manager" = systemd ]; then + command -v systemctl >/dev/null 2>&1 || die "systemd is unavailable; select an existing Supervisor with --service-manager supervisor" + fi command -v getent >/dev/null 2>&1 || die "getent is required" - if [ -z "$target_user" ] || [ "$target_user" = root ]; then + if [ -z "$target_user" ] || { [ "$target_user" = root ] && [ "$service_manager" != supervisor ]; }; then target_user="${SUDO_USER:-}" fi - if [ -z "$target_user" ] || [ "$target_user" = root ]; then + if [ -z "$target_user" ] || { [ "$target_user" = root ] && [ "$service_manager" != supervisor ]; }; then die "Linux wrapper installation needs --user USER" fi case "$target_user" in *[!A-Za-z0-9_.-]*|"") die "Linux service user has an invalid name" ;; esac id "$target_user" >/dev/null 2>&1 || die "Linux service user does not exist" - target_home="$(getent passwd "$target_user" | awk -F: '{print $6}')" + target_home="${service_home:-$(getent passwd "$target_user" | awk -F: '{print $6}')}" if [ -z "$target_home" ] || [ ! -d "$target_home" ]; then die "Linux service user has no usable home directory" fi case "$target_home" in *[" \"'\\"]*) die "Linux service user home contains unsupported characters" ;; esac - appdir=/opt/cc-remote-wrapper + appdir="${install_root:-/opt/cc-remote-wrapper}" config_dir=/etc/cc-remote device_file="$config_dir/device.env" service_file=/etc/systemd/system/cc-remote-wrapper.service service_label=cc-remote-wrapper log_dir="" cli_path=/usr/local/bin/cc-remote + if [ "$service_manager" = supervisor ]; then + service_file="$supervisor_file" + service_label="$supervisor_name" + device_file="${paired_file:-$config_dir/device.env}" + # Before even creating the lock directory, reject writable/symlink roots. + "$bundle/bin/uv" run --no-project --no-env-file --managed-python \ + --python "$python_runtime" python - "$bundle" "$appdir" <<'PY_CHECK' +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[1]) +from deploy.linux_service import absolute, trusted +trusted(absolute(sys.argv[2])) +PY_CHECK + fi fi +wrapper_env_file="${wrapper_env_file:-$config_dir/wrapper.env}" + # Lock before reading rollback state, staging releases or changing credentials. # Managed updates pass their open lock; direct installs acquire it and re-enter. @@ -232,8 +294,9 @@ fi --destination "$cli_path" --check claude_bin="$target_home/.local/bin/claude" -[ -x "$claude_bin" ] || \ - die "daily Claude Code executable is missing: $claude_bin" +if [ "$service_manager" != supervisor ]; then + [ -x "$claude_bin" ] || die "daily Claude Code executable is missing: $claude_bin" +fi releases="$appdir/releases" current="$appdir/current" @@ -255,6 +318,7 @@ snapshot_created=0 service_stopped=0 service_was_running=0 activation_committed=0 +retention_generation="" mkdir -p "$releases" "$runtimes" "$rollback_root" if [ "$system" = darwin ]; then @@ -275,6 +339,22 @@ if [ -L "$current" ]; then elif [ -e "$current" ]; then die "current must be a symlink" fi +if [ -n "$adopt_root" ]; then + [ -z "$pair_code" ] || die "migration preserves pairing; do not supply --pair" + previous="$( + "$bundle/bin/uv" run --no-project --no-env-file --managed-python \ + --python "$python_runtime" python "$bundle/deploy/adopt_wrapper.py" \ + --root "$adopt_root" --destination "$appdir" --user "$target_user" + )" +fi +if [ "$system" = linux ] && [ -f "$service_file" ] && [ -z "$previous" ]; then + die "existing Wrapper service is not registered here; use --adopt-root with its immutable root" +fi + +linux_service() { + "$target/.venv/bin/python" "$target/deploy/linux_service.py" "$1" \ + --profile "$linux_profile" --root "$appdir" "${@:2}" +} restart_after_rollback() { if [ "$system" = darwin ]; then @@ -285,6 +365,17 @@ restart_after_rollback() { launchctl bootstrap "$domain" "$service_file" >/dev/null 2>&1 && launchctl kickstart -k "$domain/$service_label" >/dev/null 2>&1 fi + elif [ "${service_manager:-systemd}" = supervisor ]; then + if [ "$service_was_running" -eq 1 ] && [ -n "$previous" ] && [ -f "$service_file" ]; then + linux_service start || return 1 + for _attempt in $(seq 1 20); do + if wrapper_service_active; then return 0; fi + sleep 1 + done + return 1 + else + linux_service stop + fi else systemctl daemon-reload >/dev/null 2>&1 || return 1 if [ "$service_was_running" -eq 1 ] && [ -n "$previous" ] && \ @@ -300,6 +391,8 @@ stop_wrapper_service() { if [ "$system" = darwin ]; then domain="gui/$(id -u)" launchctl bootout "$domain/$service_label" >/dev/null 2>&1 || true + elif [ "${service_manager:-systemd}" = supervisor ]; then + linux_service stop || return 1 else systemctl stop "$service_label" >/dev/null 2>&1 || true fi @@ -316,6 +409,8 @@ wrapper_service_active() { if [ "$system" = darwin ]; then domain="gui/$(id -u)" launchctl print "$domain/$service_label" >/dev/null 2>&1 + elif [ "${service_manager:-systemd}" = supervisor ]; then + linux_service state else systemctl is-active --quiet "$service_label" fi @@ -341,7 +436,11 @@ cleanup() { fi fi if [ "$switched" -eq 1 ]; then - if [ -n "$previous" ]; then + if [ -n "$adopt_root" ]; then + # The original root/current was never modified. Restore the absent + # managed link so a failed migration cannot masquerade as registration. + rm -f -- "$current" || rollback_ready=0 + elif [ -n "$previous" ]; then if ! "$target/.venv/bin/python" "$target/deploy/atomic_symlink.py" \ "$previous" "$current" >/dev/null 2>&1; then rollback_ready=0 @@ -352,8 +451,11 @@ cleanup() { fi if [ "$service_changed" -eq 1 ]; then if [ "$service_had_file" -eq 1 ] && [ -n "$service_backup" ]; then - cp -p "$service_backup" "$service_file" >/dev/null 2>&1 || \ - rollback_ready=0 + if [ "${service_manager:-systemd}" = supervisor ]; then + linux_service restore --template "$service_backup" || rollback_ready=0 + else + cp -p "$service_backup" "$service_file" >/dev/null 2>&1 || rollback_ready=0 + fi elif [ "$service_had_file" -eq 0 ] && [ -f "$service_file" ]; then rm -f -- "$service_file" || rollback_ready=0 fi @@ -376,6 +478,10 @@ cleanup() { fi fi if [ "$rollback_ready" -eq 1 ]; then + if [ -n "$retention_generation" ]; then + "$target/.venv/bin/python" "$target/deploy/release_retention.py" abort \ + --root "$appdir" --generation "$retention_generation" || true + fi echo "ERROR: wrapper activation failed; code and wrapper data were restored" >&2 else echo "ERROR: wrapper activation failed; manual data recovery is required" >&2 @@ -387,6 +493,7 @@ cleanup() { [ -z "$service_backup" ] || rm -f -- "$service_backup" [ -z "$device_backup" ] || rm -f -- "$device_backup" [ -z "$unit_verify_dir" ] || rm -rf -- "$unit_verify_dir" + [ -z "${linux_profile:-}" ] || rm -f -- "$linux_profile" exit "$status" } trap cleanup EXIT @@ -455,6 +562,36 @@ PY stage="" fi +retention_profile_args=() +if [ "$system" = linux ] && [ "$service_manager" = supervisor ]; then + linux_profile="$(mktemp "${TMPDIR:-/tmp}/cc-remote-linux-service.XXXXXX")" + "$target/.venv/bin/python" - "$target" "$linux_profile" "$service_label" "$target_user" "$target_home" "$service_file" "$wrapper_env_file" "$device_file" "$supervisor_config" <<'PY_BIND' +import json +from pathlib import Path +import sys +sys.path.insert(0, sys.argv[1]) +from deploy.linux_service import validate +keys = ['name', 'user', 'home', 'service_file', 'env_file', 'device_file', 'supervisor_config'] +profile = validate(dict(manager='supervisor', **dict(zip(keys, sys.argv[3:])))) +Path(sys.argv[2]).write_text(json.dumps(profile)) +PY_BIND + preflight_args=() + if [ "$adopt_supervisor" -eq 1 ]; then preflight_args+=(--adopt); fi + [ -f "$wrapper_env_file" ] || die "Supervisor requires a prepared --env-file (see installation guide)" + linux_service preflight "${preflight_args[@]}" + retention_profile_args=(--linux-service "$linux_profile") +fi + +if [ "$previous" != "$target" ]; then + retention_generation="$( + "$target/.venv/bin/python" "$target/deploy/release_retention.py" begin \ + --root "$appdir" --release "$target" --previous "$previous" --role wrapper \ + --home "$target_home" --service "$service_label" --service-file "$service_file" \ + --config "$device_file" --config "$wrapper_env_file" \ + --config "$appdir/installation.json" "${retention_profile_args[@]}" + )" +fi + if [ -n "$pair_code" ]; then pair_args=(pair "$relay" "$pair_code") if [ -n "$device_name" ]; then @@ -463,29 +600,61 @@ if [ -n "$pair_code" ]; then if [ "$replace_pair" -eq 1 ]; then pair_args+=(--replace) fi - if [ "$system" = linux ]; then + if [ "$system" = linux ] && [[ "$device_file" != *.json ]]; then pair_args+=(--env-file "$device_file") else pair_args+=(--config "$device_file") fi - ( - cd "$target" - PYTHONPATH="$target" "$target/.venv/bin/python" \ - -m cc_remote.device "${pair_args[@]}" - ) + if [ "$system" = linux ] && [ "$service_manager" = supervisor ] && [[ "$device_file" = *.json ]] && [ "$target_user" != root ]; then + # Native JSON credentials must be created by their actual owner, not root. + ( + cd "$target" + runuser -u "$target_user" -- env HOME="$target_home" PYTHONPATH="$target" \ + "$target/.venv/bin/python" -m cc_remote.device "${pair_args[@]}" + ) + else + ( + cd "$target" + PYTHONPATH="$target" "$target/.venv/bin/python" \ + -m cc_remote.device "${pair_args[@]}" + ) + fi device_changed=1 fi if [ ! -f "$device_file" ] || [ -L "$device_file" ]; then - die "device credential is missing; provide --relay and --pair" + # Older system services may keep their original token in wrapper.env. + # Preserve that authority in place; never copy it into a new pairing file. + if [ "$system" = linux ] && [ -n "$previous" ] && [ ! -e "$device_file" ] && [ ! -L "$device_file" ]; then + "$target/.venv/bin/python" - "$wrapper_env_file" <<'PY' +from pathlib import Path +import sys +from dotenv import dotenv_values + +path = Path(sys.argv[1]) +if not path.is_file() or path.is_symlink(): + raise SystemExit("existing wrapper credential configuration is missing") +env = dotenv_values(path) +token = env.get("WRAPPER_TOKEN") or "" +if not env.get("RELAY_URL") or len(token) < 32 or token.startswith("change-me"): + raise SystemExit("existing wrapper credentials are incomplete; inspect the installation") +PY + else + die "device credential is missing; provide --relay and --pair" + fi +else + chmod 0600 "$device_file" fi -chmod 0600 "$device_file" # Older update commands only invoke the downloaded installer. Check the Relay # here too, before stopping or switching the local Wrapper, so their first # upgrade has the same server-first behavior. Fresh pairing is a separate flow. if [ -n "$previous" ] && [ -z "$pair_code" ]; then upstream_args=(--root "$appdir" --bundle "$target" --user "$target_user") + if [ -n "$adopt_root" ]; then + upstream_args+=(--previous-root "$adopt_root") + fi + if [ -n "${linux_profile:-}" ]; then upstream_args+=(--linux-service "$linux_profile"); fi if [ "$system" = darwin ]; then upstream_args+=(--service-label "$service_label") fi @@ -502,6 +671,10 @@ if [ -n "$previous" ] && [ -z "$pair_code" ]; then ) fi +if [ -n "${linux_profile:-}" ] && [ -n "$service_backup" ]; then + cmp -s "$service_backup" "$service_file" || die "Supervisor configuration changed during staging; retry after reconciling it" +fi + # Mutable Work metadata and private wrapper control state live outside immutable # release directories. Capture them before activation so a code rollback also # restores the schemas understood by the previous wrapper. @@ -511,13 +684,15 @@ if [ "$service_had_file" -eq 1 ]; then if launchctl print "$domain/$service_label" >/dev/null 2>&1; then service_was_running=1 fi - elif systemctl is-active --quiet "$service_label"; then + elif wrapper_service_active; then service_was_running=1 fi fi if [ "$service_was_running" -eq 1 ]; then - stop_wrapper_service || die "existing wrapper could not be stopped safely" + # A lost stop response may still mean the original process stopped. + # Include it in rollback recovery even when the stop call reports failure. service_stopped=1 + stop_wrapper_service || die "existing wrapper could not be stopped safely" fi rollback_snapshot="$( mktemp -d "$rollback_root/activation-$release_name.XXXXXX" @@ -529,11 +704,15 @@ snapshot_args=( ) if [ "$system" = darwin ] && [ "$service_had_file" -eq 1 ]; then snapshot_args+=(--plist "$service_file") -elif [ "$system" = linux ] && [ -f "$config_dir/wrapper.env" ]; then - snapshot_args+=(--env-file "$config_dir/wrapper.env") +elif [ "$system" = linux ] && [ -f "$wrapper_env_file" ]; then + snapshot_args+=(--env-file "$wrapper_env_file") +fi +if [ -n "${linux_profile:-}" ]; then + linux_service snapshot --destination "$rollback_snapshot" +else + "$target/.venv/bin/python" "$target/deploy/work_registry_snapshot.py" \ + "${snapshot_args[@]}" >/dev/null fi -"$target/.venv/bin/python" "$target/deploy/work_registry_snapshot.py" \ - "${snapshot_args[@]}" >/dev/null snapshot_created=1 if [ "$system" = darwin ]; then @@ -591,8 +770,11 @@ PY service_changed=1 chmod 0644 "$service_file" plutil -lint "$service_file" >/dev/null +elif [ "$service_manager" = supervisor ]; then + linux_service render --template "$target/deploy/cc-remote-wrapper.service" + service_changed=1 else - if [ ! -e "$config_dir/wrapper.env" ]; then + if [ ! -e "$wrapper_env_file" ]; then umask 077 env_stage="$(mktemp "$config_dir/.wrapper.env.XXXXXX")" { @@ -606,27 +788,33 @@ else 'WRAPPER_SEND_QUEUE_CAP=8192' \ 'LOG_LEVEL=INFO' } > "$env_stage" - install -o root -g root -m 0600 "$env_stage" "$config_dir/wrapper.env" + install -o root -g root -m 0600 "$env_stage" "$wrapper_env_file" rm -f -- "$env_stage" else - if [ ! -f "$config_dir/wrapper.env" ] || \ - [ -L "$config_dir/wrapper.env" ]; then - die "$config_dir/wrapper.env must be a regular file" + if [ ! -f "$wrapper_env_file" ] || \ + [ -L "$wrapper_env_file" ]; then + die "$wrapper_env_file must be a regular file" fi - chmod 0600 "$config_dir/wrapper.env" + chmod 0600 "$wrapper_env_file" fi "$target/.venv/bin/python" - \ "$target/deploy/cc-remote-wrapper.service" "$service_file" \ - "$target_user" "$current" "$target_home" <<'PY' + "$target_user" "$current" "$target_home" "$adopt_root" "$service_backup" <<'PY' from pathlib import Path import sys source, destination = map(Path, sys.argv[1:3]) -user, current, home = sys.argv[3:] -text = source.read_text(encoding="utf-8") -text = text.replace("youruser", user) -text = text.replace("/path/to/cc-remote", current) -text = text.replace(f"/home/{user}", home) +user, current, home, legacy, backup = sys.argv[3:] +if backup: + sys.path.insert(0, str(source.parents[1])) + from deploy.adopt_wrapper import rebase_service + old_current = Path(legacy) / "current" if legacy else Path(current) + text = rebase_service(Path(backup).read_text(encoding="utf-8"), old_current, Path(current), user) +else: + text = source.read_text(encoding="utf-8") + text = text.replace("youruser", user) + text = text.replace("/path/to/cc-remote", current) + text = text.replace(f"/home/{user}", home) staged = destination.with_name(f".{destination.name}.new") staged.write_text(text, encoding="utf-8") staged.replace(destination) @@ -652,6 +840,13 @@ if [ "$system" = darwin ]; then launchctl bootstrap "$domain" "$service_file" launchctl kickstart -k "$domain/$service_label" launchctl print "$domain/$service_label" >/dev/null +elif [ "$service_manager" = supervisor ]; then + linux_service start + for _attempt in $(seq 1 20); do + if wrapper_service_active; then break; fi + sleep 1 + done + wrapper_service_active || die "Supervisor Wrapper did not start" else systemctl daemon-reload systemctl enable "$service_label" @@ -682,8 +877,10 @@ fi codex_check_args=(--home "$target_home" --release "$target" --after "$activation_started") if [ "$system" = darwin ]; then codex_check_args+=(--plist "$service_file") +elif [ -n "${linux_profile:-}" ]; then + codex_check_args+=(--state-dir "$(linux_service probe-state)") else - codex_check_args+=(--env-file "$config_dir/wrapper.env") + codex_check_args+=(--env-file "$wrapper_env_file") fi echo "==> checking Codex shared connections (no model messages)" if ! "$target/.venv/bin/python" "$target/deploy/check_codex_readiness.py" \ @@ -691,15 +888,31 @@ if ! "$target/.venv/bin/python" "$target/deploy/check_codex_readiness.py" \ echo "WARNING: Wrapper is installed; Codex sharing still needs attention." fi +if [ -n "${linux_profile:-}" ]; then linux_service health; fi + # Keep registration after the interruptible readiness check. Once it succeeds, # post-install output failures must not roll back a registered installation. cli_args=(--root "$appdir" --destination "$cli_path" --role wrapper --user "$target_user") if [ "$system" = darwin ]; then cli_args+=(--service-label "$service_label") +elif [ -n "${linux_profile:-}" ]; then + cli_args+=(--linux-service "$linux_profile") fi "$target/.venv/bin/python" "$target/deploy/install_cli.py" "${cli_args[@]}" activation_committed=1 +# The downloaded installer owns retention, so even an older update command gets +# the new policy. Never roll back a committed activation because cleanup defers. +if [ -n "$retention_generation" ]; then + if "$target/.venv/bin/python" "$target/deploy/release_retention.py" commit \ + --root "$appdir" --generation "$retention_generation" --snapshot "$rollback_snapshot"; then + "$target/.venv/bin/python" "$target/deploy/release_retention.py" prune --root "$appdir" || \ + echo "WARNING: Older backups retained; inspect the retention report before cleanup." + else + echo "WARNING: Installed, but retention acceptance is incomplete; all backups retained." + fi +fi + echo echo "Wrapper v$version installed from $git_sha." echo "Active release: $target" @@ -709,6 +922,8 @@ if [ -n "$previous" ] && [ "$previous" != "$target" ]; then fi if [ "$system" = darwin ]; then echo "Logs: $log_dir" +elif [ "${service_manager:-systemd}" = supervisor ]; then + echo "Logs: supervisorctl -c $supervisor_config tail $service_label" else echo "Logs: journalctl -u $service_label -f" fi diff --git a/deploy/install_cli.py b/deploy/install_cli.py index 263a6681..b2800c9b 100644 --- a/deploy/install_cli.py +++ b/deploy/install_cli.py @@ -10,6 +10,9 @@ import shlex import stat import tempfile +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) _HEADER = b"#!/usr/bin/env bash\n# cc-remote release management launcher." @@ -40,7 +43,8 @@ def _atomic_file(destination: Path, content: bytes, mode: int) -> None: def install_cli(root: Path, destination: Path, *, role: str, user: str | None = None, - domain: str | None = None, service_label: str | None = None) -> None: + domain: str | None = None, service_label: str | None = None, + linux_service: dict | None = None) -> None: check_destination(destination) current = root / "current" if not current.is_symlink() or current.resolve().parent != (root / "releases").resolve(): @@ -62,8 +66,34 @@ def install_cli(root: Path, destination: Path, *, role: str, user: str | None = raise ValueError("release management launcher cannot bind the installation root") content = content.replace(marker, marker + ( f"export CC_REMOTE_MANAGED_ROOT={shlex.quote(str(root))}\n").encode(), 1) + if linux_service is not None: + from deploy.linux_service import validate, trusted, absolute + validate(linux_service) + if role != 'wrapper' or linux_service['user'] != user or service_label is not None: + raise ValueError('Linux service binding does not match installation') + trusted(absolute(str(root))) + trusted(destination) + metadata['linux_service'] = linux_service + marker = b'managed_wrapper_root="/opt/cc-remote-wrapper"\n' + if content.count(marker) != 1: + raise ValueError('release launcher cannot bind Linux root') + content = content.replace(marker, f'managed_wrapper_root={shlex.quote(str(root))}\n'.encode(), 1) + elif role == 'relay' and destination.exists(): + # Co-installed Relay updates must preserve the trusted Wrapper binding. + for line in destination.read_text().splitlines(): + if line.startswith('managed_wrapper_root='): + value = shlex.split(line) + if len(value) != 1: + raise ValueError('invalid existing Wrapper launcher binding') + bound = value[0].partition('=')[2] + from deploy.linux_service import absolute, trusted + trusted(destination) + trusted(absolute(bound)) + content = content.replace(b'managed_wrapper_root="/opt/cc-remote-wrapper"\n', + f'managed_wrapper_root={shlex.quote(bound)}\n'.encode(), 1) + break if role == "wrapper": - if not user or user == "root": + if not user or (user == "root" and linux_service is None): raise ValueError("wrapper management requires the original service user") metadata["user"] = user elif role == "relay": @@ -95,6 +125,7 @@ def main() -> int: parser.add_argument("--user") parser.add_argument("--domain") parser.add_argument("--service-label") + parser.add_argument("--linux-service", type=Path) args = parser.parse_args() try: if args.check: @@ -103,7 +134,8 @@ def main() -> int: 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, - service_label=args.service_label) + service_label=args.service_label, + linux_service=json.loads(args.linux_service.read_text()) if args.linux_service else None) except (OSError, ValueError) as exc: parser.exit(1, f"ERROR: {exc}\n") return 0 diff --git a/deploy/linux_service.py b/deploy/linux_service.py new file mode 100644 index 00000000..c7b158de --- /dev/null +++ b/deploy/linux_service.py @@ -0,0 +1,431 @@ +"""Linux Wrapper service bindings shared by install, update and retention. + +Supervisor uses its local Unix XML-RPC endpoint. Only the bound program is +started/stopped/reloaded; the daemon and other programs are never restarted. +""" +from __future__ import annotations + +import argparse +import configparser +import glob +import http.client +import json +from pathlib import Path +import pwd +import re +import shlex +import socket +import stat +import subprocess +import sys +import xmlrpc.client + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}") +ENV_KEYS = frozenset({"HOME", "CLAUDE_BIN", "CLAUDE_WORK_ROOT", "CODEX_WORK_ROOT", + "CC_REMOTE_STATE_DIR", "RELAY_URL", "WRAPPER_TOKEN", "CC_REMOTE_MACHINE_ID", + "CC_REMOTE_DEVICE_CONFIG", "CC_REMOTE_CLAUDE_SERVICE_SOCKET", "CC_REMOTE_SUPERVISOR_UNSET_ENV"}) +UNSET_KEYS = frozenset({"CLAUDE_CONFIG_DIR", "CODEX_HOME", "PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy"}) +FIELDS = {"manager", "name", "user", "home", "service_file", "env_file", "device_file", "supervisor_config"} + + +def require(ok: bool, reason: str) -> None: + if not ok: + raise ValueError(reason) + + +def absolute(value: str) -> Path: + require(isinstance(value, str) and value.startswith('/') and not re.search(r'[\s%\"\'\\\x00-\x1f]', value), + 'Linux service paths must be absolute, without whitespace or expansion characters') + path = Path(value) + require(path.resolve() == path, 'Linux service paths must be canonical (no symlink ancestors)') + return path + + +def trusted(path: Path) -> None: + """No elevated launcher may execute a tree replaceable by its service user.""" + for item in [path, *path.parents]: + if not item.exists(): + continue + info = item.lstat() + require(info.st_uid == 0 and not stat.S_ISLNK(info.st_mode) + and (not info.st_mode & 0o022 or (item != path and item.is_dir() and info.st_mode & stat.S_ISVTX)), + 'Linux managed paths must be root-owned and protected from replacement') + + +def validate(profile: dict) -> dict: + require(isinstance(profile, dict) and set(profile) == FIELDS, 'invalid Linux service binding') + require(all(isinstance(value, str) for value in profile.values()), 'invalid Linux service field') + require(profile['manager'] == 'supervisor', 'unsupported Linux service binding') + require(bool(NAME.fullmatch(profile['name'])) and bool(NAME.fullmatch(profile['user'])), 'invalid service/user name') + for key in ('home', 'service_file', 'env_file', 'device_file'): + absolute(profile[key]) + absolute(profile['supervisor_config']) + return profile + + +def read_config(path: Path) -> configparser.ConfigParser: + trusted(path) + require(path.is_file() and path.stat().st_size <= 1024 * 1024, 'missing/oversized service configuration') + config = configparser.ConfigParser(interpolation=None, strict=True) + config.read_string(path.read_text()) + return config + + +def config_files(main: Path) -> list[Path]: + config = read_config(main) + result = [main] + patterns = config.get('include', 'files', fallback='').replace('%(here)s', str(main.parent)) + require('%' not in patterns, 'dynamic Supervisor includes require explicit reconciliation') + for pattern in shlex.split(patterns): + result.extend(Path(p).resolve() for p in sorted(glob.glob(str(main.parent / pattern)))) + require(len(result) <= 256, 'too many Supervisor include files') + return list(dict.fromkeys(result)) + + +def supervisor_program(profile: dict, *, optional: bool = False) -> configparser.SectionProxy | None: + main = Path(profile['supervisor_config']) + expected = Path(profile['service_file']) + files = config_files(main) + section = 'program:' + profile['name'] + matches = [(path, read_config(path)) for path in files] + matches = [(path, config) for path, config in matches if config.has_section(section)] + if not matches and optional and not expected.exists(): + config = read_config(main) + import fnmatch + patterns = config.get('include', 'files', fallback='').replace('%(here)s', str(main.parent)) + require(any(fnmatch.fnmatch(str(expected), str(main.parent / p)) for p in shlex.split(patterns)), + 'new Supervisor program file is not covered by the existing include pattern') + return None + require(len(matches) == 1 and matches[0][0] == expected, + 'Supervisor program is missing, duplicated or belongs to another file') + config = matches[0][1] + program = config[section] + require(program.get('numprocs', '1') == '1' + and program.get('process_name', '%(program_name)s') in {'%(program_name)s', profile['name']}, + 'managed Wrapper needs one named Supervisor process') + return program + + +class UnixConnection(http.client.HTTPConnection): + def __init__(self, path: str): + super().__init__('localhost', timeout=45) + self.path = path + + def connect(self): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(self.timeout) + self.sock.connect(self.path) + + +class UnixTransport(xmlrpc.client.Transport): + def __init__(self, path: str): + super().__init__() + self.path = path + + def make_connection(self, host): + _, self._extra_headers, _ = self.get_host_info(host) + return UnixConnection(self.path) + + +def rpc(profile: dict): + main = Path(profile['supervisor_config']) + config = read_config(main) + url = config.get('supervisorctl', 'serverurl', fallback='').replace('%(here)s', str(main.parent)) + require(url.startswith('unix:///'), 'Supervisor management requires an existing local Unix socket') + path = absolute(url[len('unix://'):]) + info = path.stat() + require(stat.S_ISSOCK(info.st_mode) and info.st_uid == 0 and not info.st_mode & 0o002, + 'Supervisor control socket must be root-owned and not world-writable') + # Credentials stay in the existing Supervisor config, never metadata/argv/logs. + username = config.get('supervisorctl', 'username', fallback='') + password = config.get('supervisorctl', 'password', fallback='') + from urllib.parse import quote + authority = f'{quote(username, safe="")}:{quote(password, safe="")}@' if username else '' + return xmlrpc.client.ServerProxy(f'http://{authority}localhost/RPC2', transport=UnixTransport(str(path))).supervisor + + +def state(profile: dict) -> dict: + try: + info = rpc(profile).getProcessInfo(profile['name']) + except xmlrpc.client.Fault as exc: + if exc.faultCode == 10: # BAD_NAME, e.g. before the first registration + return {'running': False, 'pid': 0, 'start': 0} + raise ValueError('Supervisor could not inspect the bound program') from None + return {'running': info['statename'] == 'RUNNING', 'pid': info['pid'], 'start': info['start']} + + +def stop(profile: dict) -> None: + server = rpc(profile) + try: + server.stopProcess(profile['name'], True) + except xmlrpc.client.Fault as exc: + require(exc.faultCode in {10, 70}, 'Supervisor could not stop the bound program') + + +def start(profile: dict) -> None: + server = rpc(profile) + added, changed, removed = server.reloadConfig()[0] + name = profile['name'] + if name in changed or name in removed: + require(not state(profile)['pid'], 'stop the Wrapper before reloading its configuration') + server.removeProcessGroup(name) + if name in added or name in changed: + server.addProcessGroup(name) + if name not in removed: + # addProcessGroup may autostart this program. Never touch others. + info = server.getProcessInfo(name) + if info['statename'] not in {'STARTING', 'RUNNING'}: + server.startProcess(name, True) + + +def parse_environment(value: str) -> dict[str, str]: + lexer = shlex.shlex(value, posix=True) + lexer.whitespace = ',' + lexer.whitespace_split = True + lexer.commenters = '' + result = {} + for item in lexer: + key, sep, value = item.strip().partition('=') + require(bool(sep), 'invalid Supervisor environment') + if key in ENV_KEYS: + require('%' not in value, 'dynamic deployment environment requires explicit reconciliation') + result[key] = value + return result + + +def unset_keys(value: str) -> set[str]: + keys = {key.strip() for key in value.split(',') if key.strip()} + require(keys <= UNSET_KEYS, 'only account, Python and proxy selectors may be unset') + return keys + + +def environment(profile: dict) -> dict[str, str]: + from dotenv import dotenv_values + result = {} + if profile['manager'] == 'supervisor': + main = read_config(Path(profile['supervisor_config'])) + program = supervisor_program(profile, optional=True) + result.update(parse_environment(main.get('supervisord', 'environment', fallback=''))) + if program is not None: + result.update(parse_environment(program.get('environment', ''))) + home = Path(profile['home']) + # The bootstrap defines these even if the daemon inherited other values. + result.update(HOME=str(home), CC_REMOTE_STATE_DIR=str(home / '.cc-remote'), + CLAUDE_WORK_ROOT=str(home / '.claude/cc-remote/work'), + CODEX_WORK_ROOT=str(home / '.codex/cc-remote/work')) + for key in ('env_file', 'device_file'): + path = Path(profile[key]) + if path.exists() and not (key == 'device_file' and path.suffix == '.json'): + trusted(path) + result.update({k: v for k, v in dotenv_values(path, interpolate=False).items() if k in ENV_KEYS and v is not None}) + unset_keys(result.pop('CC_REMOTE_SUPERVISOR_UNSET_ENV', '')) + device = Path(profile['device_file']) + if device.suffix == '.json': + result['CC_REMOTE_DEVICE_CONFIG'] = str(device) + if device.exists(): + require(device.is_file() and not device.is_symlink() and device.stat().st_size <= 65536, + 'invalid device credential file') + config = json.loads(device.read_text()) + for env_key, file_key in [('RELAY_URL', 'relay_url'), ('WRAPPER_TOKEN', 'wrapper_token'), + ('CC_REMOTE_MACHINE_ID', 'machine_id')]: + if not result.get(env_key) and config.get(file_key): + result[env_key] = config[file_key] + return result + + +def replace_program(original: str, name: str, replacement: str) -> str: + """Change one section byte-for-byte; keep other programs and daemon policy.""" + sections = list(re.finditer(r'^\[([^]\n]+)\][^\n]*(?:\n|$)', original, re.M)) + matches = [(m.start(), sections[i+1].start() if i+1 < len(sections) else len(original)) + for i, m in enumerate(sections) if m.group(1) == 'program:' + name] + require(len(matches) == 1, 'expected one Supervisor program section') + start, end = matches[0] + # Preserve operator logging/restart/environment settings. Only identity and + # command are replaced; Wrapper shutdown must not kill native descendants. + changed = {'command', 'directory', 'user', 'stopasgroup', 'killasgroup'} + config = configparser.ConfigParser(interpolation=None) + config.read_string(replacement) + values = config['program:' + name] + old = original[start:end].splitlines(keepends=True) + kept = [old[0]] + dropping = False + for line in old[1:]: + match = re.match(r'^([A-Za-z_]+)\s*=', line) + if match: + dropping = match.group(1) in changed + elif line and not line[0].isspace(): + dropping = False + if not dropping: + kept.append(line) + body = ''.join(kept).rstrip('\n') + '\n' + body += ''.join(f'{key}={values[key]}\n' for key in sorted(changed)) + '\n' + return original[:start] + body + original[end:] + + +def expected_command(root: Path, profile: dict) -> list[str]: + return [str(root / 'current/.venv/bin/python'), '-I', '-B', str(root / 'current/deploy/wrapper_exec.py'), + '--user', profile['user'], '--home', profile['home'], '--env-file', profile['env_file'], + '--device-file', profile['device_file']] + + +def preflight(root: Path, profile: dict, *, adopt: bool = False) -> None: + """Check live ownership/configuration before stopping or registering anything.""" + validate(profile) + trusted(root) + for key in ('service_file', 'env_file', 'device_file'): + if key != 'device_file' or Path(profile[key]).suffix != '.json': + trusted(Path(profile[key])) + account = pwd.getpwnam(profile['user']) + require(Path(profile['home']).is_dir(), 'service HOME is missing') + metadata = root / 'installation.json' + if metadata.exists(): + saved = json.loads(metadata.read_text()).get('linux_service') + require(saved == profile, 'registered service binding differs; do not retarget an installation') + program = supervisor_program(profile, optional=True) + server = rpc(profile) + env = environment(profile) + cli = Path(env.get('CLAUDE_BIN') or str(Path(profile['home']) / '.local/bin/claude')) + require(cli.is_file() and cli.stat().st_mode & 0o111, 'daily Claude CLI is missing') + require(env.get('HOME', account.pw_dir) == profile['home'], 'service HOME differs from the installation binding') + + if program is None: + require(not adopt, 'adoption requires an existing Supervisor program') + require(not state(profile)['pid'], 'Supervisor already owns an unbound Wrapper process') + return + command = shlex.split(program.get('command', '')) + managed = command == expected_command(root, profile) + require(managed or adopt, 'custom Supervisor command requires explicit --adopt-supervisor and reconciled environment') + if adopt: + require((root / 'current').is_symlink() and (root / 'current').resolve().parent == root / 'releases', + 'adoption requires the existing immutable root') + require(Path(profile['env_file']).is_file(), 'adoption requires an explicit external Wrapper environment file') + if managed: + require(program.get('directory') == str(root / 'current'), 'Supervisor Wrapper directory mismatch') + expected_user = program.get('user', read_config(Path(profile['supervisor_config'])).get('supervisord', 'user', fallback='root')) + require(expected_user == ('root' if managed else profile['user']), 'Supervisor Wrapper user mismatch') + configs = [x for x in server.getAllConfigInfo() if x['name'] == profile['name']] + require(len(configs) == 1 and configs[0]['group'] == profile['name'] + and shlex.split(configs[0]['command']) == command + and configs[0]['directory'] == program.get('directory', 'none') + and configs[0]['uid'] == (pwd.getpwnam(expected_user).pw_uid if 'user' in program else 'none'), + 'loaded Supervisor config differs from disk; reconcile it before updating') + changes = server.reloadConfig()[0] + require(not any(profile['name'] in group for group in changes), + 'Supervisor program has unapplied configuration changes; reconcile it before updating') + + +def render(root: Path, profile: dict, template: Path) -> None: + service = Path(profile['service_file']) + service.parent.mkdir(parents=True, exist_ok=True) + text = f'''[program:{profile['name']}] +command={shlex.join(expected_command(root, profile))} +directory={root}/current +user=root +autostart=true +autorestart=unexpected +startsecs=1 +stopsignal=TERM +stopwaitsecs=30 +stopasgroup=false +killasgroup=false +umask=0077 +stdout_logfile=AUTO +stderr_logfile=AUTO +''' + if service.exists(): + text = replace_program(service.read_text(), profile['name'], text) + from deploy.install_cli import _atomic_file + _atomic_file(service, text.encode(), stat.S_IMODE(service.stat().st_mode) if service.exists() else 0o600) + + +def restore_program(profile: dict, backup: Path) -> None: + """Rollback only the named program, retaining concurrent unrelated edits.""" + original = backup.read_text() + section = re.search(r'^\[program:' + re.escape(profile['name']) + r'\][^\n]*\n.*?(?=^\[|\Z)', + original, re.M | re.S) + require(section is not None, 'rollback program definition missing') + path = Path(profile['service_file']) + current = path.read_text() + matches = list(re.finditer(r'^\[program:' + re.escape(profile['name']) + r'\][^\n]*\n.*?(?=^\[|\Z)', + current, re.M | re.S)) + require(len(matches) == 1, 'rollback program definition changed') + match = matches[0] + from deploy.install_cli import _atomic_file + _atomic_file(path, (current[:match.start()] + section.group() + current[match.end():]).encode(), + stat.S_IMODE(path.stat().st_mode)) + + +def verify_health(root: Path, profile: dict) -> None: + import time + from cc_remote.update import Installation + from cc_remote.update_relay import relay_origin, relay_release, _get_json + from deploy.release_manifest import load_manifest + before = state(profile) + require(before['running'] and before['pid'] > 0, 'Wrapper is not running') + manifest = load_manifest(root / 'current/release-manifest.json') + installation = Installation(root, (root / 'current').resolve(), manifest, {'linux_service': profile}) + origin = relay_origin(installation) + public = relay_release(origin) + require(public['protocol'] == manifest['protocol_version'], 'Relay protocol does not match') + machine = environment(profile).get('CC_REMOTE_MACHINE_ID') or 'default' + connected = False + for _ in range(15): + require(state(profile) == before, 'Wrapper restarted during acceptance') + if machine in _get_json(origin + '/healthz').get('machines', []): + connected = True + break + time.sleep(1) + require(connected, 'Wrapper did not reconnect to Relay') + time.sleep(2) + require(state(profile) == before, 'Wrapper restarted during acceptance') + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['preflight', 'state', 'stop', 'start', 'render', 'snapshot', 'probe-state', 'health', 'restore']) + parser.add_argument('--profile', type=Path, required=True) + parser.add_argument('--root', type=Path, required=True) + parser.add_argument('--destination', type=Path) + parser.add_argument('--adopt', action='store_true') + parser.add_argument('--template', type=Path) + args = parser.parse_args(argv) + try: + profile = validate(json.loads(args.profile.read_text())) + if args.action == 'preflight': + preflight(args.root, profile, adopt=args.adopt) + elif args.action == 'state': + return 0 if state(profile)['running'] else 1 + elif args.action == 'health': + verify_health(args.root, profile) + elif args.action == 'stop': + stop(profile) + elif args.action == 'start': + start(profile) + elif args.action == 'restore': + restore_program(profile, args.template) + elif args.action == 'render': + render(args.root, profile, args.template) + else: + from deploy.work_registry_snapshot import create_snapshot, resolve_work_roots, resolve_wrapper_state_dir + env = environment(profile) + # Only three path selectors reach snapshot resolution, no credentials. + home = Path(profile['home']) + roots = resolve_work_roots(home, environment=env) + state_dir = resolve_wrapper_state_dir(home, environment=env) + if args.action == 'snapshot': + create_snapshot(args.destination, roots, state_dir=state_dir) + else: + print(state_dir) + return 0 + except (OSError, ValueError, KeyError, configparser.Error, xmlrpc.client.Error, subprocess.SubprocessError) as exc: + reason = str(exc) if type(exc) is ValueError else type(exc).__name__ + print(f'Linux service operation failed: {reason}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/deploy/release_retention.py b/deploy/release_retention.py new file mode 100644 index 00000000..a307dc16 --- /dev/null +++ b/deploy/release_retention.py @@ -0,0 +1,437 @@ +"""Record installer recovery sets and retire only proven superseded generations. + +Called by the *new bundle's* installer, including upgrades started by old CLIs. +Deletion belongs to cleanup.py; unknown legacy trees are never inferred by age. +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import plistlib +import pwd +import re +import shlex +import shutil +import stat +import subprocess +import sys +import time +import uuid + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from deploy import cleanup as retirement +from deploy.install_lock import LOCK_FD_ENV, acquire_install_lock, verify_install_lock +from deploy.release_manifest import load_manifest + +LEDGER = ".release-generations.json" +BACKUPS = "rollback-config" +INVENTORY = ".release-cleanup.json" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def read_ledger(root: Path) -> dict: + if not (root / LEDGER).exists(): + return {"schema": 1, "phase": "committed", "generations": []} + data, _ = retirement.read_json(root / LEDGER) + require(data.get("schema") == 1 and isinstance(data.get("generations"), list), + "invalid release generation ledger") + require(len(data["generations"]) <= 100, "generation ledger exceeds limit; inspect retained files") + ids = set() + for row in data["generations"]: + require(isinstance(row, dict) and re.fullmatch(r"[a-f0-9]{32}", row.get("id", "")) + and row["id"] not in ids, "invalid or duplicate generation identity") + ids.add(row["id"]) + release = retirement.absolute_path(row["release"]) + require(release.parent == root / "releases", "generation escapes releases") + require(row["backup"] == str(root / BACKUPS / row["id"]), "invalid recovery directory") + retirement.absolute_path(row["backup"]) + if row.get("snapshot"): + require(retirement.absolute_path(row["snapshot"]).parent == root / "rollback-data", + "snapshot escapes rollback data") + require(row.get("phase") in {"prepared", "committed", "aborted"}, + "unknown generation outcome; inspect before updating") + return data + + +def begin(root: Path, release: Path, previous: str, *, role: str, home: Path, + service: str, service_file: Path, configs: list[Path], linux_service: dict | None = None) -> str: + root = retirement.absolute_path(str(root)) + release = retirement.absolute_path(str(release)) + require(release.parent == root / "releases" and release.is_dir(), "invalid new release") + manifest = load_manifest(release / "release-manifest.json") + require(manifest["role"] == role, "release role mismatch") + data = read_ledger(root) + require(all(r["phase"] != "prepared" for r in data["generations"]), + "unfinished installation record; inspect the original activation before retrying") + require(len(data["generations"]) < 100, "too many retained generations; inspect retention") + if previous: + old = retirement.absolute_path(previous) + require(old.is_dir() and old != release, "invalid previous release") + require(((root / "current").is_symlink() and (root / "current").resolve(strict=True) == old) + or (not (root / "current").exists() and old.parent != root / "releases"), + "previous release is not the active installation") + identity = uuid.uuid4().hex + backup = root / BACKUPS / identity + row = dict(id=identity, release=str(release), previous=previous, backup=str(backup), + snapshot=None, role=role, home=str(home), service=service, + service_file=str(service_file), phase="prepared", started_at=time.time(), artifacts={}) + if linux_service is not None: + from deploy.linux_service import validate + row['linux_service'] = validate(linux_service) + data["generations"].append(row) + data["phase"] = "prepared" + retirement.write_journal(root / LEDGER, data) + backup.mkdir(parents=True, mode=0o700) + files = [] + # Include absence, mode and destination: rollback needs the matching service + # definition/configuration as well as code and the private-state snapshot. + for i, path in enumerate(dict.fromkeys([service_file, *configs])): + entry = {"destination": str(path), "file": str(i), "present": path.exists()} + if path.is_symlink(): + raise ValueError("configuration backup must not follow a symlink") + if path.exists(): + info = path.stat() + require(stat.S_ISREG(info.st_mode) and info.st_size <= 4 * 1024 * 1024, + "unsafe or oversized configuration backup") + entry.update(mode=stat.S_IMODE(info.st_mode), uid=info.st_uid, gid=info.st_gid) + with path.open("rb") as source, (backup / str(i)).open("xb") as target: + os.fchmod(target.fileno(), 0o600) + shutil.copyfileobj(source, target) + target.flush() + os.fsync(target.fileno()) + files.append(entry) + retirement.write_journal(backup / "files.json", {"schema": 1, "files": files}) + for path in [release, backup, *([Path(previous)] if previous else [])]: + row["artifacts"][str(path)] = list(retirement.identity(path)) + retirement.write_journal(root / LEDGER, data) + return identity + + +def generation(root: Path, identity: str) -> tuple[dict, dict]: + data = read_ledger(root) + rows = [r for r in data["generations"] if r["id"] == identity] + require(len(rows) == 1, "unknown activation record") + return data, rows[0] + + +def command(argv: list[str]) -> str: + result = subprocess.run(argv, capture_output=True, text=True, timeout=45, check=False) + require(result.returncode == 0, f"read-only acceptance failed: {Path(argv[0]).name}") + return result.stdout + + +def service_identity(row: dict) -> dict: + if row.get('linux_service'): + from deploy.linux_service import validate, state + live = state(validate(row['linux_service'])) + require(live['running'] and live['pid'] > 0, 'Supervisor Wrapper is not running') + return live + if sys.platform == "darwin": + raw = command(["launchctl", "print", f"gui/{os.getuid()}/{row['service']}"]) + fields = dict(re.findall(r"^\s*(state|pid|runs) = ([^\n]+)$", raw, re.M)) + require(fields.get("state") in {"running", "active"} and int(fields.get("pid", 0)) > 0, + "Wrapper is not running") + return {k: fields[k] for k in ("pid", "runs")} + raw = command(["systemctl", "show", row["service"], "-p", "ActiveState", "-p", "MainPID", + "-p", "NRestarts", "-p", "ExecMainStartTimestampMonotonic"]) + fields = dict(line.split("=", 1) for line in raw.splitlines() if "=" in line) + require(fields.get("ActiveState") == "active" and int(fields.get("MainPID", 0)) > 0, + "service is not running") + return fields + + +def acceptance(root: Path, row: dict) -> None: + from dotenv import dotenv_values + from cc_remote.update import Installation + from cc_remote.update_relay import _get_json, relay_origin, relay_release + + release = Path(row["release"]) + require((root / "current").resolve(strict=True) == release, "active release changed") + manifest = load_manifest(release / "release-manifest.json") + live = service_identity(row) + if row.get("service_identity"): + require(live == row["service_identity"], "service restarted; retain recovery files") + metadata, _ = retirement.read_json(root / "installation.json") + python = str(release / ".venv/bin/python") + if row["role"] == "relay": + origin = dotenv_values(root / ".env").get("PUBLIC_ORIGIN") + require(isinstance(origin, str) and bool(origin), "Relay public origin missing") + expected_version = manifest["product_version"] + else: + installation = Installation(root, release, manifest, metadata) + origin = relay_origin(installation) + expected_version = None # A newer compatible Relay is allowed. + home = Path(row["home"]) + if sys.platform == "darwin": + environment = plistlib.loads(Path(row["service_file"]).read_bytes()).get("EnvironmentVariables", {}) + device = Path(environment.get("CC_REMOTE_DEVICE_CONFIG", str(home / ".cc-remote/device.json"))) + config = json.loads(device.read_text()) if device.exists() else {} + machine = environment.get("CC_REMOTE_MACHINE_ID") or config.get("machine_id") or "default" + selector = ["--plist", row["service_file"]] + prefix = [] + else: + from deploy.work_registry_snapshot import resolve_wrapper_state_dir + if row.get('linux_service'): + from deploy.linux_service import environment as service_environment + environment = service_environment(row['linux_service']) + state = resolve_wrapper_state_dir(home, environment=environment) + else: + environment = {**dotenv_values("/etc/cc-remote/wrapper.env"), + **dotenv_values("/etc/cc-remote/device.env")} + state = resolve_wrapper_state_dir(home, env_file=Path("/etc/cc-remote/wrapper.env")) + machine = environment.get("CC_REMOTE_MACHINE_ID") or "default" + selector = ["--state-dir", str(state)] + owner = pwd.getpwnam(metadata["user"]) + prefix = (["runuser", "-u", owner.pw_name, "--"] if os.geteuid() == 0 and owner.pw_uid != 0 else []) + command([*prefix, python, "-I", "-B", str(release / "deploy/check_codex_readiness.py"), + "--home", str(home), "--release", str(release), "--after", str(row["started_at"]), + "--wait", "0", "--live-config", *selector]) + health = _get_json(origin + "/healthz") + require(machine in health.get("machines", []), "updated Wrapper has not reconnected") + require(bool(row.get("snapshot")), "matching private-state snapshot missing") + command([python, "-B", str(release / "deploy/work_registry_snapshot.py"), "verify", + "--snapshot", row["snapshot"]]) + public = relay_release(origin) + require(public["protocol"] == manifest["protocol_version"] + and (expected_version is None or public["version"] == expected_version), + "public Relay/Web build does not match") + time.sleep(1) + require(service_identity(row) == live, "service is unstable; retain recovery files") + + +def commit(root: Path, identity: str, snapshot: str | None = None) -> None: + data, row = generation(root, identity) + require(row["phase"] == "prepared", "activation already finalized; inspect instead of repeating") + require((root / "current").resolve(strict=True) == Path(row["release"]), "activation not current") + if snapshot: + path = retirement.absolute_path(snapshot) + require(path.parent == root / "rollback-data" and path.is_dir(), "invalid state snapshot") + row["snapshot"] = str(path) + row["artifacts"][str(path)] = list(retirement.identity(path)) + row.update(phase="committed", committed_at=time.time(), service_identity=service_identity(row)) + data["phase"] = "committed" + retirement.write_journal(root / LEDGER, data) + # A failed check leaves the committed installation and all recovery files + # intact. It is not a reason to roll back a healthy, already registered app. + acceptance(root, row) + + +def abort(root: Path, identity: str) -> None: + data, row = generation(root, identity) + require(row["phase"] == "prepared", "cannot abort a committed generation") + row["phase"] = "aborted" + data["phase"] = "committed" + retirement.write_journal(root / LEDGER, data) + + +def service_roots(row: dict) -> list[Path]: + home = Path(row["home"]) + roots = ([home / "Library/LaunchAgents", Path("/Library/LaunchAgents"), + Path("/Library/LaunchDaemons")] if sys.platform == "darwin" else + [Path("/etc/systemd/system"), Path("/usr/lib/systemd/system"), + Path("/usr/local/lib/systemd/system"), home / ".config/systemd/user"]) + if sys.platform != "darwin" and os.geteuid() == 0: + roots.extend(Path(account.pw_dir) / ".config/systemd/user" for account in pwd.getpwall() + if account.pw_dir.startswith("/") and Path(account.pw_dir).is_dir()) + return roots + + +def service_dependencies(row: dict, candidates: list[Path]) -> list[str]: + """Read dormant service definitions and their literal environment files. + + Do not source shell or run arbitrary services. Incomplete enumeration, + unreadable files or unresolved environment paths defer cleanup. + """ + found = set() + files = set() + + def walk_error(error: OSError) -> None: + raise error + + for directory in set(service_roots(row)): + if not directory.exists(): + continue + for parent, directories, names in os.walk(directory, onerror=walk_error): + require(not any((Path(parent) / name).is_symlink() for name in directories), + "symlinked service directory requires explicit retention inventory") + files.update(Path(parent) / name for name in names) + require(len(files) <= 8192, "service dependency scan exceeds bound") + if profile := row.get('linux_service'): + from deploy.linux_service import config_files, validate + validate(profile) + files.update(config_files(Path(profile['supervisor_config']))) + files.update(Path(profile[key]) for key in ('env_file', 'device_file') if Path(profile[key]).exists()) + total = 0 + + def inspect(path: Path) -> bytes: + nonlocal total + target = path.resolve(strict=True) + if str(target) == "/dev/null": # masked systemd unit + return b"" + info = path.stat() + total += info.st_size + require(stat.S_ISREG(info.st_mode) and info.st_size <= 4 * 1024 * 1024 + and total <= 32 * 1024 * 1024, "service dependency scan incomplete or oversized") + raw = path.read_bytes() + if path.suffix == ".plist": + raw = json.dumps(plistlib.loads(raw), default=str).encode() + for candidate in candidates: + if str(candidate).encode() in raw or target.is_relative_to(candidate): + found.add(str(candidate)) + return raw + + for path in files: + raw = inspect(path) + for value in re.findall(rb"^\s*EnvironmentFile=(.+)$", raw, re.M): + for name in shlex.split(value.decode()): + optional = name.startswith("-") + name = name.lstrip("-") + require(name.startswith("/") and not re.search(r"[%$*?\[\\]", name), + "dynamic service environment requires explicit retention inventory") + env = Path(name) + if optional and not env.exists() and not env.is_symlink(): + continue + inspect(env) + return sorted(found) + + +def inventory(root: Path, data: dict, row: dict) -> dict | None: + require(all(r["phase"] != "prepared" for r in data["generations"]), + "unfinished activation; cleanup deferred") + if not row["previous"]: + return None + previous = retirement.absolute_path(row["previous"]) + require(previous.parent == root / "releases", + "legacy rollback is outside the managed root; retain it for manual migration acceptance") + rollback = [str(previous), row["backup"]] + if row.get("snapshot"): + rollback.append(row["snapshot"]) + candidates, protected = set(), set() + for record in data["generations"]: + paths = [record["release"], record["backup"]] + if record["previous"] and Path(record["previous"]).parent == root / "releases": + paths.append(record["previous"]) + if record.get("snapshot"): + paths.append(record["snapshot"]) + if record["phase"] == "aborted": + protected.update(p for p in paths if Path(p).exists()) + elif record["id"] != row["id"]: + candidates.update(paths) + candidates -= {row["release"], *rollback} + candidates = {p for p in candidates if Path(p).exists()} + for candidate in candidates: + identities = [r["artifacts"][candidate] for r in data["generations"] + if candidate in r.get("artifacts", {})] + require(bool(identities) and all(i == list(retirement.identity(Path(candidate))) for i in identities), + "recorded artifact was replaced; cleanup requires inspection") + if not candidates: + return None + protected.update(service_dependencies(row, [Path(p) for p in candidates])) + # Independent services may intentionally keep their own current pointer, + # even while stopped. Preserve those targets and their runtime closure. + for path in root.iterdir(): + if path.is_symlink(): + protected.add(str(path.resolve(strict=True))) + python = str(Path(row["release"]) / ".venv/bin/python") + check = [python, "-B", str(Path(row["release"]) / "deploy/release_retention.py"), + "verify", "--root", str(root), "--generation", row["id"]] + return {"schema": 1, "installation_root": str(root), "current_release": row["release"], + "rollback_paths": rollback, "protected_paths": sorted(protected), + "cleanup_roots": [str(root / name) for name in ("releases", BACKUPS, "rollback-data") + if (root / name).is_dir()], "candidates": sorted(candidates), + "transactions": [{"path": str(root / LEDGER), "field": "phase", "equals": "committed"}], + "checks": [{"name": "live release, service, public health and native config", + "argv": check, "cwd": row["home"]}]} + + +def prune(root: Path, descriptor: int) -> dict | None: + data = read_ledger(root) + current = str((root / "current").resolve(strict=True)) + rows = [r for r in data["generations"] if r["release"] == current and r["phase"] == "committed"] + require(len(rows) == 1, "no unique committed generation; legacy artifacts remain untouched") + row = rows[0] + # Reboots and normal service restarts may legitimately replace the accepted + # process. Rebind only after fresh native/config/public checks prove this + # exact release again; the retirement checks below freeze that new identity. + fresh_row = {k: v for k, v in row.items() if k != "service_identity"} + before = service_identity(row) + acceptance(root, fresh_row) + require(service_identity(row) == before, "service changed during retention preflight") + row["service_identity"] = before + retirement.write_journal(root / LEDGER, data) + plan = inventory(root, data, row) + if plan is None: + print("Retention: current release and previous rollback retained; no proven older artifacts to remove.") + return None + path = root / INVENTORY + retirement.write_journal(path, plan) + preview = retirement.cleanup(path, lock_descriptor=descriptor) + print(f"Retention: checked {len(preview['candidates'])} recorded artifacts; live dependencies stay protected.") + result = retirement.cleanup(path, apply=True, lock_descriptor=descriptor) + # Keep provenance for every surviving artifact. Completed records whose + # payloads have all gone no longer need to accumulate indefinitely. + data["generations"] = [r for r in data["generations"] if r["id"] == row["id"] or any( + p and Path(p).exists() for p in (r["release"], r["previous"], r["backup"], r.get("snapshot")))] + retirement.write_journal(root / LEDGER, data) + print(f"Retention: removed {result['removed_allocated_bytes']} allocated bytes; " + f"{sum(r['state'] == 'deferred' for r in result['candidates'])} artifacts retained for dependencies.") + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("begin", "commit", "abort", "prune", "verify")) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--generation") + parser.add_argument("--release", type=Path) + parser.add_argument("--previous", default="") + parser.add_argument("--role", choices=("wrapper", "relay")) + parser.add_argument("--home", type=Path) + parser.add_argument("--service") + parser.add_argument("--service-file", type=Path) + parser.add_argument("--config", type=Path, action="append", default=[]) + parser.add_argument("--snapshot") + parser.add_argument("--linux-service", type=Path) + args = parser.parse_args(argv) + descriptor = None + try: + root = retirement.absolute_path(str(args.root)) + if args.action == "verify": + _, row = generation(root, args.generation) + acceptance(root, row) + return 0 + inherited = os.environ.get(LOCK_FD_ENV) + if inherited: + verify_install_lock(root, int(inherited)) + descriptor = os.dup(int(inherited)) + else: + descriptor = acquire_install_lock(root) + if args.action == "begin": + require(all((args.release, args.role, args.home, args.service, args.service_file)), + "begin requires release, role, home and service binding") + print(begin(root, args.release, args.previous, role=args.role, home=args.home, + service=args.service, service_file=args.service_file, configs=args.config, + linux_service=json.loads(args.linux_service.read_text()) if args.linux_service else None)) + elif args.action == "commit": + commit(root, args.generation, args.snapshot) + elif args.action == "abort": + abort(root, args.generation) + else: + prune(root, descriptor) + return 0 + except (OSError, ValueError, subprocess.SubprocessError) as exc: + print(f"Retention deferred: {exc}", file=sys.stderr) + return 1 + finally: + if descriptor is not None: + os.close(descriptor) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/setup-vps.sh b/deploy/setup-vps.sh index 64fcbfaf..0e770e68 100755 --- a/deploy/setup-vps.sh +++ b/deploy/setup-vps.sh @@ -44,6 +44,7 @@ PUBLIC_SCHEME=https CADDY_TEMPLATE="" MANAGED_RELEASE=0 CLI_PATH=/usr/local/bin/cc-remote +RETENTION_GENERATION="" [ -r "$SOURCE_DIR/deploy/setup_transaction.sh" ] || { echo "ERROR: $SOURCE_DIR/deploy/setup_transaction.sh is missing" >&2 @@ -337,6 +338,15 @@ harden_release_permissions "$NEW_RELEASE_DIR" chown -R root:ccremote "$RUNTIMES_DIR" harden_release_permissions "$RUNTIMES_DIR" +if (( MANAGED_RELEASE )); then + RETENTION_GENERATION="$( + "$NEW_RELEASE_DIR/.venv/bin/python" "$NEW_RELEASE_DIR/deploy/release_retention.py" begin \ + --root "$APPDIR" --release "$NEW_RELEASE_DIR" --previous "$PREVIOUS_RELEASE" \ + --role relay --home /root --service cc-remote-relay --service-file "$RELAY_UNIT_FILE" \ + --config "$CADDYFILE" --config "$ENV_FILE" --config "$APPDIR/installation.json" + )" +fi + echo "==> Caddy config ($PUBLIC_SCHEME://$TARGET)" CADDY_SITE="$(mktemp /etc/caddy/cc-remote-site.XXXXXX)" CADDY_CANDIDATE="$(mktemp /etc/caddy/Caddyfile.cc-remote.XXXXXX)" @@ -431,6 +441,16 @@ if (( MANAGED_RELEASE )); then fi DEPLOY_READY=1 +if [ -n "$RETENTION_GENERATION" ]; then + if "$NEW_RELEASE_DIR/.venv/bin/python" "$NEW_RELEASE_DIR/deploy/release_retention.py" commit \ + --root "$APPDIR" --generation "$RETENTION_GENERATION"; then + "$NEW_RELEASE_DIR/.venv/bin/python" "$NEW_RELEASE_DIR/deploy/release_retention.py" prune \ + --root "$APPDIR" || echo "WARNING: Older backups retained; inspect the retention report." + else + echo "WARNING: Installed, but retention acceptance is incomplete; all backups retained." + fi +fi + echo echo "Active release: $NEW_RELEASE_DIR" if [ -n "$PREVIOUS_RELEASE" ]; then diff --git a/deploy/setup_transaction.sh b/deploy/setup_transaction.sh index 9a2f8645..a50ae55c 100644 --- a/deploy/setup_transaction.sh +++ b/deploy/setup_transaction.sh @@ -191,12 +191,18 @@ cleanup() { local exit_status=$? if (( ! DEPLOY_READY )); then rollback_deployment + # A restored current link alone does not prove Caddy, service and data + # recovery. Leave a failed managed activation prepared for inspection. if [ -n "$NEW_RELEASE_DIR" ]; then - if (( RELEASE_SWITCHED )); then + if (( RELEASE_SWITCHED )) || [ -n "${RETENTION_GENERATION:-}" ]; then # A failed rollback may have left current pointing at the new release. # Keep that tree intact rather than turning the active symlink into a # dangling link; the operator can repair current and remove it later. - echo "ERROR: retaining staged release because release rollback failed" >&2 + if (( RELEASE_SWITCHED )); then + echo "ERROR: retaining staged release because release rollback failed" >&2 + else + echo "ERROR: retaining staged release for transaction inspection" >&2 + fi else case "$NEW_RELEASE_DIR" in "$RELEASES_DIR"/release-*) rm -rf -- "$NEW_RELEASE_DIR" ;; diff --git a/deploy/work_registry_snapshot.py b/deploy/work_registry_snapshot.py index df405424..ddd4283c 100644 --- a/deploy/work_registry_snapshot.py +++ b/deploy/work_registry_snapshot.py @@ -166,6 +166,7 @@ def resolve_work_roots( *, env_file: Path | None = None, plist: Path | None = None, + environment: dict[str, str] | None = None, ) -> dict[str, Path]: """Resolve the service's provider roots without executing its config.""" home_input = Path(os.path.expanduser(str(home))) @@ -178,6 +179,7 @@ def resolve_work_roots( } values.update(_env_file_values(env_file)) values.update(_plist_values(plist)) + values.update({k: v for k, v in (environment or {}).items() if k in values and v}) roots = { engine: _absolute_path(values[key], home, label=key) for engine, key in _ROOT_KEYS.items() @@ -199,6 +201,7 @@ def resolve_wrapper_state_dir( *, env_file: Path | None = None, plist: Path | None = None, + environment: dict[str, str] | None = None, ) -> Path: """Resolve the old service's private state directory without executing it.""" home_input = Path(os.path.expanduser(str(home))) @@ -208,6 +211,7 @@ def resolve_wrapper_state_dir( values = {_STATE_DIR_KEY: str(home / ".cc-remote")} values.update(_env_file_values(env_file)) values.update(_plist_values(plist)) + values.update({k: v for k, v in (environment or {}).items() if k in values and v}) state_dir = _absolute_path( values[_STATE_DIR_KEY], home, label=_STATE_DIR_KEY) if state_dir == Path(state_dir.anchor): diff --git a/deploy/wrapper_exec.py b/deploy/wrapper_exec.py new file mode 100644 index 00000000..378359db --- /dev/null +++ b/deploy/wrapper_exec.py @@ -0,0 +1,59 @@ +"""Supervisor bootstrap: load external service env, set HOME, then drop identity. + +The Supervisor program starts this trusted shim as root, like systemd loading +EnvironmentFile before User=. Native CLIs are only executed by the final user. +""" +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import pwd +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def main(): + from dotenv import dotenv_values + parser = argparse.ArgumentParser() + parser.add_argument('--user', required=True) + parser.add_argument('--home', required=True) + parser.add_argument('--env-file', type=Path, required=True) + parser.add_argument('--device-file', type=Path, required=True) + args = parser.parse_args() + from deploy.linux_service import trusted, absolute, unset_keys + home = absolute(args.home) + owner = pwd.getpwnam(args.user) + env = dict(os.environ) + env.update(CC_REMOTE_STATE_DIR=str(home / '.cc-remote'), + CLAUDE_WORK_ROOT=str(home / '.claude/cc-remote/work'), + CODEX_WORK_ROOT=str(home / '.codex/cc-remote/work')) + for path in (args.env_file, args.device_file): + if path == args.device_file and path.suffix == '.json': + env['CC_REMOTE_DEVICE_CONFIG'] = str(path) + continue + if path.exists(): + trusted(path) + env.update({k: v for k, v in dotenv_values(path, interpolate=False).items() if v is not None}) + for key in unset_keys(env.pop('CC_REMOTE_SUPERVISOR_UNSET_ENV', '')): + env.pop(key, None) + env.setdefault('PATH', '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin') + env['PATH'] = str(home / '.local/bin') + ':' + env['PATH'] + env.update(HOME=str(home), USER=owner.pw_name, LOGNAME=owner.pw_name, PYTHON_DOTENV_DISABLED='1') + for key in ('PYTHONHOME', 'PYTHONPATH'): + env.pop(key, None) + if os.geteuid() == 0: + os.initgroups(owner.pw_name, owner.pw_gid) + os.setgid(owner.pw_gid) + os.setuid(owner.pw_uid) + elif os.geteuid() != owner.pw_uid: + raise SystemExit('Wrapper bootstrap is running under the wrong user') + root = Path(__file__).resolve().parents[1] + os.chdir(root) + # Keep the release venv interpreter (sys.executable), not its resolved runtime. + os.execve(sys.executable, [sys.executable, '-s', '-m', 'cc_remote.wrapper'], env) + + +if __name__ == '__main__': + main() diff --git a/docs/installation.md b/docs/installation.md index 55f94c05..55fc8d70 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -23,7 +23,7 @@ Web,Wrapper 包只含本机控制端;两者都自带 `uv`,安装时创建 |---|---|---|---| | Relay | Ubuntu 22.04+ / Debian 12+ | x86_64、arm64 | systemd + Caddy | | Wrapper | macOS | Intel、Apple Silicon | 当前用户 LaunchAgent | -| Wrapper | glibc Linux + systemd(推荐 Ubuntu 22.04+ / Debian 12+) | x86_64、arm64 | 指定普通用户的 systemd 服务 | +| Wrapper | glibc Linux + systemd 或已有 Supervisor(推荐 Ubuntu 22.04+ / Debian 12+) | x86_64、arm64 | 指定用户的 systemd / Supervisor 服务 | ### 1)下载并校验引导脚本 @@ -126,7 +126,7 @@ cc-remote update --relay-ssh operator@relay-host ``` 也可以使用 `~/.ssh/config` 中的别名。该 SSH 账号需要能通过 `sudo -n` 运行服务器的 -托管更新器和 systemd;Linux 设备使用安装时的普通服务用户连接 SSH。不会把设备配对 +托管更新器和 systemd;Linux 设备使用安装登记的服务用户连接 SSH。不会把设备配对 凭据当作服务器管理权限,也不会复制 SSH 私钥。若不能验证服务器版本或没有管理权限, 本机不会先行切换。VPS 更新由独立 systemd 任务运行;连接断开后再次执行命令只核查 `upstream-update.json` 记录的同一事务,不重复启动安装。失败时按记录中的 unit 查看 @@ -140,23 +140,94 @@ cc-remote update --relay-ssh operator@relay-host `--relay-ssh` 和 `--allow-protocol-change`。 命令校验 SHA-256、安装包路径、平台和版本,随后复用原安装器的不可变切换、状态快照 -与失败回滚;保留账号、配对和外部配置,不清理旧 release,也不会重新配对。同版本 -不重启,重复更新会被锁住,激活失败不会自动重试。独立 Claude 服务不在更新范围内。 -真正升级 Wrapper 后会显示上述 Codex 连接检查结果;`--check` 和同版本更新不触发配置或检查。 +与失败回滚;保留账号、配对和外部配置,不会重新配对。同版本不重启服务,重复更新会被 +锁住,激活失败不会自动重试。独立 Claude 服务不在更新范围内。更新使用预构建 Web 与 +锁定依赖,不在用户机器上执行 CI、pytest 或 Node 构建;Electron 本体另行更新。 +真正升级 Wrapper 后会显示上述 Codex 连接检查结果;`--check` 不触发配置或本地验收。 升级前先等待进程内 Claude、BTW 和排队消息处理结束;SDK 或独立服务协议变化时, 命令会停止并要求按 [Claude 服务指南](claude-session-service.md) 完成迁移。 +当前源码的安装器会在切换前记录旧版代码、服务配置和私有状态快照;新版本通过验收后, +保留当前版本及上一版完整回滚资料,通过 `deploy/cleanup.py` 清理有记录的更早版本。 +独立 Claude 服务、Codex daemon、进程工作目录、打开文件或保留版本的符号链接仍引用的 +旧文件会继续保留,不为清理重启任务。来源不明、权限不足、验收失败或事务未完成时保留 +文件并告警;清理失败不会撤销已完成的升级。再次执行同版本 `cc-remote update` 会在 +重新验活、读取原生 Codex 配置后重试安全清理,`--check` 始终不清理。 +记录位于安装根目录的 `.release-generations.json`、`rollback-config/` 和 `rollback-data/`。 +原有未知备份不会按文件名猜测后删除。**v4.0.9 仍保留旧版本、不自动清理**;新规则随包含 +此改动的安装包生效,即使升级是由旧版更新命令发起的。 + 通信协议变化时命令默认停止。先安排所有机器的维护窗口,按部署文档顺序使用 `--version` 固定同一版并附加 `--allow-protocol-change`;这只表示已安排协调升级, 会先更新 VPS、再更新当前设备;其余设备仍需在维护窗口内逐一更新。 页面与设备中心会显示具体设备与服务端的版本不兼容提示。更新完成后重新加载 Web/PWA。 v4.0.0 及更早版本尚未安装这个命令,需先按下面的方式升级到包含它的版本一次。 -源码和 Docker 部署继续使用各自流程,命令不会自动接管它们。已有 Mac 不可变 Release +未登记的源码部署和镜像重建不会自动接管;已有 Supervisor 管理的 Linux Wrapper +可按下方流程接入相同更新器。已有 Mac 不可变 Release 布局可在确认当前目录与 LaunchAgent 对应后,用 `deploy/install_cli.py --root ... --destination ~/.local/bin/cc-remote --role wrapper --user ... --service-label ...` 显式注册; 后续更新保留该目录、服务标签与原有环境。未注册的自定义部署保持原样。 +#### Linux 旧安装迁入更新器 + +仅复制源码或手工切换 `current` 不会自动安装更新命令。当前源码提供显式迁移入口; +需要下载包含此功能的 Release 安装器(v4.0.9 不支持),由管理员在独立终端执行: + +```bash +./install.sh wrapper --user youruser --adopt-root /absolute/legacy/installation +``` + +它校验原 `cc-remote-wrapper` systemd 服务的用户、命令、不可变目录与环境文件, +将 Wrapper 迁到 `/opt/cc-remote-wrapper/` 并注册 `/usr/local/bin/cc-remote`。 +现有 `/etc/cc-remote/` 凭据、代理、账号和服务策略保留;不传新配对码,不重启独立 +Claude 服务,不复制原生会话。自定义命令、额外 systemd drop-in 或不明布局会拒绝迁移, +需先按部署文档核对。原安装树保留用于回滚和运行依赖,迁移首轮不自动清理;后续标准 +更新只清理新目录内有记录的旧资料。不要在迁移期间并发执行旧部署任务。 + +#### Linux Supervisor 安装与首次接管 + +当前源码支持已有 Supervisor 管理的 Wrapper,包括没有 systemd 的 Linux 容器。 +**v4.0.9 不包含此功能**;必须先使用包含该改动的安装包完成一次安装/接管,之后直接 +执行 `cc-remote update`。无需安装 systemd,也不负责启动 Supervisor 守护进程或重建镜像。 + +先确认 Supervisor 已以 root 运行并配置本地 Unix 管理 socket;显式指定实际配置文件, +不依赖 `supervisorctl` 的默认搜索路径。准备 root 所有、0600 的外部 Wrapper 环境文件, +参照 `deploy/env.wrapper.example`。已有自定义启动脚本需要先核对并保留其中的状态目录、 +工作目录、账号选择、代理、`CLAUDE_BIN` 和 `CC_REMOTE_CLAUDE_SERVICE_SOCKET`;不要复制 +Claude/Codex 登录凭据。文件值按字面读取,不运行 shell,不展开变量。 + +```bash +# 在独立终端中由管理员执行;占位路径按现有安装填写。 +./install.sh wrapper --user root --home /root \ + --service-manager supervisor --install-root /srv/cc-remote \ + --supervisor-config /etc/supervisor/supervisord.conf \ + --service-file /etc/supervisor/conf.d/cc-remote.conf --service-name wrapper \ + --env-file /etc/cc-remote/wrapper.env --device-file /root/.cc-remote/device.json \ + --adopt-supervisor +``` + +`--adopt-supervisor` 仅用于首次显式替换旧 Wrapper 启动命令;必须已有该命名程序和 +`current → releases/<旧版>` 的不可变目录。若程序写在 Supervisor 主配置里, +`--service-file` 可以与 `--supervisor-config` 相同;只改该程序段,其他服务段保持原样。 +接管前人工确认外部环境文件与原脚本等效,尤其是 Work/私有状态路径;安装器不会执行 +或猜测任意脚本的环境。需要清除旧脚本原本清除的继承变量时,可在环境文件使用 +`CC_REMOTE_SUPERVISOR_UNSET_ENV=ALL_PROXY,all_proxy,CLAUDE_CONFIG_DIR,CODEX_HOME`, +仅限账号选择、Python 环境和代理变量。不要为了接管改变既有账号选择。 + +全新安装不传 `--adopt-supervisor`,改用未占用的专用程序文件,并加 `--relay ... --pair ...`; +该文件须被已有 Supervisor 的 `[include] files=` 覆盖。`--user` 可选择已存在的普通用户; +只有显式指定 root 才以 root 运行。程序的可信启动器先读取外部配置,然后切换到指定用户 +和 HOME。JSON 配对文件保留在原位置;也支持 `--device-file /etc/cc-remote/device.env`。 +安装根目录和启动配置须由 root 管理且不能被其他用户替换;不自动接管可写源码树。 + +安装器复用同一套下载校验、安装锁、版本切换、私有状态快照及失败回滚,只启停命名的 +Wrapper,不执行 Supervisor `reload`/`restart all`,不重启独立 Claude 服务。验收后注册 +`/usr/local/bin/cc-remote` 和 `installation.json`;后续更新自动沿用目录、用户、HOME、 +配置文件及管理方式,无需重复这些参数。旧版及匹配配置/数据仍按上述保留规则处理; +容器缺少完整进程可见性时会推迟清理。镜像重建后的数据持久化、Supervisor 自启动由运行 +环境管理;这与容器内安装和更新 cc-remote 是两回事。 + 默认安装根目录:Mac 为 `~/Library/Application Support/cc-remote/`,Linux 设备为 `/opt/cc-remote-wrapper/`,VPS 为 `/opt/cc-remote/`。每个根目录的 `releases/` 保存版本, `current` 指向活动版本,私有配置和状态不放进 release。 diff --git a/docs/installation_en.md b/docs/installation_en.md index b465c79e..52f72bf5 100644 --- a/docs/installation_en.md +++ b/docs/installation_en.md @@ -25,7 +25,7 @@ repository, install Node, or paste tokens into service definitions. |---|---|---|---| | Relay | Ubuntu 22.04+ / Debian 12+ | x86_64, arm64 | systemd + Caddy | | Wrapper | macOS | Intel, Apple Silicon | per-user LaunchAgent | -| Wrapper | glibc Linux with systemd (Ubuntu 22.04+ / Debian 12+ recommended) | x86_64, arm64 | systemd under a chosen ordinary user | +| Wrapper | glibc Linux with systemd or an existing Supervisor (Ubuntu 22.04+ / Debian 12+ recommended) | x86_64, arm64 | systemd / Supervisor under the chosen user | ### 1) Download and verify the bootstrap @@ -161,15 +161,32 @@ the new Wrapper installer perform the same check and accept `--relay-ssh` and 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. +rollback. Account, pairing and external configuration remain intact; no re-pairing +occurs. The same version does not restart services, concurrent updates are locked +out, and failed activations are not retried automatically. Prebuilt Web assets +and locked dependencies are used; users do not run CI, pytest or Node builds. +Update the Electron application separately. An activated Wrapper upgrade prints the Codex connection result described above; -`--check` and same-version updates do not configure or probe Codex. +`--check` does not configure or run local acceptance checks. 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). +Current-source installers record the previous code, service configuration and +private-state snapshot before switching. After acceptance, retain the active +release and one complete previous rollback set. `deploy/cleanup.py` retires only +recorded, proven older generations. References from the independent Claude +service, Codex daemons, process working directories, open files and retained +symlinks keep older artifacts protected; cleanup never restarts tasks. Unknown +provenance, insufficient visibility, failed checks or unresolved transactions +defer deletion with a warning, without undoing a committed upgrade. Running +the same-version `cc-remote update` retries safe cleanup after fresh health and +native Codex configuration checks; `--check` never cleans up. +Records live in `.release-generations.json`, `rollback-config/` and `rollback-data/` +under the installation root. Unknown historical backups are never inferred by name. +**v4.0.9 retains old releases without automatic cleanup**. The new policy requires +a bundle containing this change, even when an older updater starts the upgrade. + 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 updates Relay before the current device; @@ -178,13 +195,89 @@ device list and conversation show incompatible device/server versions. Reload Web/PWA clients afterwards. v4.0.0 and earlier do not install this command. Upgrade once using the procedure -below to a release that includes it. Source and Docker installs keep their own -upgrade procedure. Existing immutable Mac layouts can be explicitly registered +below to a release that includes it. Unregistered source trees and image rebuilds +are not automatically adopted. Existing Supervisor-managed Linux Wrappers can +join the same updater using the procedure below. Immutable Mac layouts can be explicitly registered with `deploy/install_cli.py --root ... --destination ~/.local/bin/cc-remote --role wrapper --user ... --service-label ...` after verifying the root and LaunchAgent match. Future updates preserve that root, label and environment. Unregistered custom layouts are never automatically adopted. +#### Migrate an older Linux installation + +Copying source or manually switching `current` does not register the updater. +Current source provides an explicit migration entry point. Obtain a Release +installer containing it (v4.0.9 does not), then run as the installation +administrator from an independent terminal: + +```bash +./install.sh wrapper --user youruser --adopt-root /absolute/legacy/installation +``` + +The installer validates the existing `cc-remote-wrapper` systemd service's +user, command, immutable root and environment layout, migrates the Wrapper to +`/opt/cc-remote-wrapper/`, and registers `/usr/local/bin/cc-remote`. It preserves +existing `/etc/cc-remote/` credentials, proxy/account settings and service policy. +Do not supply a new pairing code. The independent Claude service is not restarted +and native sessions are not copied. Custom commands, systemd drop-ins or ambiguous +layouts are refused and need reconciliation under the deployment guide first. +The original tree stays available for rollback/live dependencies; the migration +does not prune it. Later standard updates clean only recorded artifacts inside +the new root. Do not run the old deployment workflow concurrently with migration. + +#### Linux Supervisor installation and adoption + +Current source supports an existing Supervisor, including Linux containers without +systemd. **v4.0.9 does not include this feature**: first install/adopt using a bundle +containing it, then use the same `cc-remote update`. The installer does not install +systemd, start supervisord, or rebuild a container image. + +Use an existing root-owned Supervisor with a local Unix control socket. Specify +its actual configuration explicitly; do not rely on supervisorctl search paths. +Prepare a root-owned 0600 external Wrapper environment file using +`deploy/env.wrapper.example`. When replacing a custom launcher, reconcile its +state/Work directories, cwd, account selection, proxies, `CLAUDE_BIN` and +`CC_REMOTE_CLAUDE_SERVICE_SOCKET` first. Do not copy native login credentials. +Values are literal: no shell evaluation or variable interpolation. + +```bash +# Administrator, independent terminal; replace paths with the actual installation. +./install.sh wrapper --user root --home /root \ + --service-manager supervisor --install-root /srv/cc-remote \ + --supervisor-config /etc/supervisor/supervisord.conf \ + --service-file /etc/supervisor/conf.d/cc-remote.conf --service-name wrapper \ + --env-file /etc/cc-remote/wrapper.env --device-file /root/.cc-remote/device.json \ + --adopt-supervisor +``` + +`--adopt-supervisor` explicitly replaces an existing Wrapper command on the first +adoption. It requires that named program and an immutable `current → releases/` +layout. If the program is in the main config, `--service-file` may equal +`--supervisor-config`: only the selected program section changes; other sections +remain intact. Verify the prepared environment matches the old launcher, especially +private-state and Work paths. Arbitrary scripts are not executed or guessed at. +For inherited variables previously cleared by the launcher, use, for example, +`CC_REMOTE_SUPERVISOR_UNSET_ENV=ALL_PROXY,all_proxy,CLAUDE_CONFIG_DIR,CODEX_HOME`. +Only account selectors, Python environment and proxy variables can be cleared; +preserve existing account selection rather than changing it during adoption. + +For a fresh install, omit `--adopt-supervisor`, select an unused dedicated program +file covered by the existing `[include] files=` pattern, and add `--relay ... --pair ...`. +`--user` can name an existing non-root account; root must be requested explicitly. +The trusted bootstrap reads external configuration, then switches to that account +and HOME. Existing JSON pairing stays in place; an external `device.env` is also +supported. Managed roots and startup configuration must be root-owned and protected +from replacement by other users. Writable source trees are not adopted implicitly. + +This reuses checksum verification, installation locking, immutable activation, +private-state snapshots and failure rollback. Only the selected Wrapper is +stopped/started; no Supervisor daemon reload, restart-all or independent Claude +service restart occurs. Acceptance registers `/usr/local/bin/cc-remote` and +`installation.json`. Later updates reuse the bound directory, account, HOME, +configuration and manager without extra flags. The retention policy above applies; +incomplete process visibility in a container defers cleanup. Persistence across +image rebuilds and starting Supervisor remain responsibilities of the runtime. + Default roots are `~/Library/Application Support/cc-remote/` on Mac, `/opt/cc-remote-wrapper/` for Linux devices and `/opt/cc-remote/` on the VPS. Each keeps immutable versions under `releases/` and selects one with `current`; diff --git a/scripts/cc-remote b/scripts/cc-remote index 893534e7..3173be33 100755 --- a/scripts/cc-remote +++ b/scripts/cc-remote @@ -1,6 +1,7 @@ #!/usr/bin/env bash # cc-remote release management launcher. Installed by the role installer. set -euo pipefail +managed_wrapper_root="/opt/cc-remote-wrapper" if [ "$(uname -s)" = Linux ] && [ "$(id -u)" -ne 0 ]; then # Relay releases and installation metadata may only be readable by root. @@ -16,6 +17,7 @@ fi case "$(uname -s)" in Darwin) roots=("${CC_REMOTE_MANAGED_ROOT:-$HOME/Library/Application Support/cc-remote}/current") ;; Linux) + export CC_REMOTE_MANAGED_ROOT="$managed_wrapper_root" selected_role="" role_requested=0 role_value_next=0 @@ -41,11 +43,11 @@ case "$(uname -s)" in } case "$selected_role" in relay) roots=("/opt/cc-remote/current") ;; - wrapper) roots=("/opt/cc-remote-wrapper/current") ;; + wrapper) roots=("$managed_wrapper_root/current") ;; *) echo "--role requires relay or wrapper." >&2; exit 2 ;; esac else - roots=("/opt/cc-remote-wrapper/current" "/opt/cc-remote/current") + roots=("$managed_wrapper_root/current" "/opt/cc-remote/current") fi ;; *) echo "cc-remote supports macOS and Linux." >&2; exit 1 ;; diff --git a/tests/test_adopt_wrapper.py b/tests/test_adopt_wrapper.py new file mode 100644 index 00000000..c06d5edd --- /dev/null +++ b/tests/test_adopt_wrapper.py @@ -0,0 +1,71 @@ +"""Legacy migration must bind to the actual service; never silently adopt.""" +from pathlib import Path + +import pytest + +from deploy import adopt_wrapper + + +@pytest.fixture +def legacy(tmp_path, monkeypatch): + root = tmp_path.resolve() / "legacy" + old = root / "releases/old" + old.mkdir(parents=True) + (old / "release-manifest.json").write_text("{}") + (root / "current").symlink_to(old) + service = tmp_path.resolve() / "wrapper.service" + service.write_text(f"""[Service] +User=operator +WorkingDirectory={root}/current +ExecStart={root}/current/.venv/bin/python -m cc_remote.wrapper +EnvironmentFile=/etc/cc-remote/wrapper.env +EnvironmentFile=-/etc/cc-remote/device.env +Restart=on-failure +""") + monkeypatch.setattr(adopt_wrapper, "root_owned", lambda p: None) + monkeypatch.setattr(adopt_wrapper.subprocess, "check_output", lambda *a, **k: + f"User=operator\nWorkingDirectory={root}/current\nFragmentPath={service}\nDropInPaths=\n") + return root, old, service, tmp_path.resolve() / "managed" + + +def test_explicit_migration_validates_without_modifying_legacy(legacy): + root, old, service, destination = legacy + before = service.read_bytes() + assert adopt_wrapper.validate(root, "operator", destination, service) == old + assert service.read_bytes() == before and not destination.exists() + assert (root / "current").resolve() == old + + +def test_migrated_service_keeps_custom_policy_on_subsequent_upgrade(legacy): + root, _, service, destination = legacy + original = service.read_text() + 'Environment="HTTPS_PROXY=http://127.0.0.1:8888"\nMemoryHigh=5G\n' + moved = adopt_wrapper.rebase_service(original, root / "current", destination / "current", "operator") + assert moved == original.replace(str(root / "current"), str(destination / "current")) + assert adopt_wrapper.rebase_service(moved, destination / "current", destination / "current", "operator") == moved + + +@pytest.mark.parametrize("mismatch", ["user", "command", "env", "dropin", "destination", "symlink", "ownership"]) +def test_migration_refuses_ambiguous_or_unrelated_installations(legacy, monkeypatch, mismatch): + root, _, service, destination = legacy + if mismatch == "user": + service.write_text(service.read_text().replace("User=operator", "User=other")) + elif mismatch == "command": + service.write_text(service.read_text().replace("-m cc_remote.wrapper", "-m another_app")) + elif mismatch == "env": + service.write_text(service.read_text().replace("/etc/cc-remote/wrapper.env", "/private/custom.env")) + elif mismatch == "dropin": + monkeypatch.setattr(adopt_wrapper.subprocess, "check_output", lambda *a, **k: + f"User=operator\nWorkingDirectory={root}/current\nFragmentPath={service}\nDropInPaths=/etc/override.conf\n") + elif mismatch == "destination": + destination.mkdir() + (destination / "installation.json").write_text("{}") + elif mismatch == "symlink": + link = root.with_name("alias") + link.symlink_to(root) + root = link + else: + def unsafe(path: Path): + raise ValueError("unsafe ownership") + monkeypatch.setattr(adopt_wrapper, "root_owned", unsafe) + with pytest.raises(ValueError): + adopt_wrapper.validate(root, "operator", destination, service) diff --git a/tests/test_codex_readiness.py b/tests/test_codex_readiness.py index f07a2cf6..3227b0fa 100644 --- a/tests/test_codex_readiness.py +++ b/tests/test_codex_readiness.py @@ -281,7 +281,7 @@ def test_receipt_fences_physical_replacement_behind_native_alias(tmp_path, accou assert receipt["profiles"][0]["reason"] == "daemon_changed" -@pytest.mark.parametrize("kind", ["plist", "env-file"]) +@pytest.mark.parametrize("kind", ["plist", "env-file", "state-dir"]) def test_installer_uses_service_state_root_without_executing_config(tmp_path, kind, capsys): state = tmp_path / "custom state" before = time.time() @@ -289,8 +289,10 @@ def test_installer_uses_service_state_root_without_executing_config(tmp_path, ki config = tmp_path / "config" if kind == "plist": config.write_bytes(plistlib.dumps({"EnvironmentVariables": {"CC_REMOTE_STATE_DIR": str(state)}})) - else: + elif kind == "env-file": config.write_text(f'CC_REMOTE_STATE_DIR="{state}"\nIGNORED=$(touch /bad)\n') + else: + config = state assert installer.main([ "--home", str(tmp_path), "--release", str(readiness.SOURCE_ROOT), "--after", str(before), f"--{kind}", str(config), "--wait", "0", diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 1107cf9f..d70c839f 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -665,7 +665,7 @@ def test_post_switch_activation_commits_or_restores_full_release_caddy_and_unit( 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' + 'CLI_PATH="$APPDIR/cc-remote"\nRETENTION_GENERATION=""\n' 'TARGET=remote.example.test\nPUBLIC_SCHEME=https\nINSECURE_HTTP=0\n' + setup_tail, ) diff --git a/tests/test_install_commit.py b/tests/test_install_commit.py index 823de123..a6b13898 100644 --- a/tests/test_install_commit.py +++ b/tests/test_install_commit.py @@ -15,7 +15,7 @@ @pytest.mark.parametrize("previous_install", [False, True]) -@pytest.mark.parametrize("phase", ["interrupt-readiness", "success", "fail-output"]) +@pytest.mark.parametrize("phase", ["interrupt-readiness", "success", "fail-output", "retention-failure"]) def test_wrapper_registration_matches_the_activation_commit(tmp_path, previous_install, phase): root = tmp_path / "managed installation" target = root / "releases/new" @@ -31,6 +31,7 @@ def test_wrapper_registration_matches_the_activation_commit(tmp_path, previous_i " assert os.getppid() == int(os.environ['TEST_INSTALLER_PID'])\n" " os.kill(os.getppid(), signal.SIGTERM)\n" ) + (target / "deploy/release_retention.py").write_text("raise SystemExit(1)\n") (target / "bin").mkdir() launcher = (ROOT / "scripts/cc-remote").read_bytes() (target / "bin/cc-remote").write_bytes(launcher) @@ -48,7 +49,7 @@ def test_wrapper_registration_matches_the_activation_commit(tmp_path, previous_i metadata.write_bytes(old_metadata) settings = { - "system": "darwin", "appdir": str(root), "target": str(target), + "system": "darwin", "appdir": str(root), "target": str(target), "adopt_root": "", "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", @@ -58,6 +59,7 @@ def test_wrapper_registration_matches_the_activation_commit(tmp_path, previous_i "rollback_snapshot": "", "snapshot_created": "0", "service_changed": "0", "device_changed": "0", "service_stopped": "0", "service_was_running": "0", "switched": "1", "activation_committed": "0", + "retention_generation": "fixture-generation" if phase == "retention-failure" else "", } source = (ROOT / "deploy/install-wrapper.sh").read_text() helpers = source[source.index("restart_after_rollback() {"):] @@ -102,3 +104,5 @@ def test_wrapper_registration_matches_the_activation_commit(tmp_path, previous_i if phase == "fail-output": assert "activation was committed" in result.stderr assert "activation failed" not in result.stderr + if phase == "retention-failure": + assert "all backups retained" in result.stdout diff --git a/tests/test_linux_service.py b/tests/test_linux_service.py new file mode 100644 index 00000000..db6d567d --- /dev/null +++ b/tests/test_linux_service.py @@ -0,0 +1,203 @@ +"""Supervisor identity, scoped configuration changes and updater bindings.""" +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +import xmlrpc.client + +import pytest + +from deploy import linux_service as service +from deploy import install_cli +from cc_remote import update, update_relay + + +@pytest.fixture +def binding(tmp_path, monkeypatch): + root = tmp_path.resolve() + home = root / 'home' + home.mkdir() + profile = dict(manager='supervisor', name='wrapper', user='root', home=str(home), + service_file=str(root / 'supervisor.conf'), env_file=str(root / 'wrapper.env'), + device_file=str(home / 'device.json'), supervisor_config=str(root / 'supervisor.conf')) + Path(profile['service_file']).write_text('''[supervisord] +user=root +[supervisorctl] +serverurl=unix:///run/fixture.sock +[program:claude-service] +command=/usr/bin/python3 /opt/native-service.py +[program:wrapper] +command=/usr/bin/python3 /opt/legacy-wrapper.py +autostart=true +stdout_logfile=/var/log/wrapper.log +stopasgroup=true +''') + Path(profile['env_file']).write_text(f'HOME={home}\nCC_REMOTE_STATE_DIR={home}/private\n') + Path(profile['device_file']).write_text(json.dumps({'relay_url': 'wss://relay.test/ws', + 'wrapper_token': 'private-fixture-token', 'machine_id': 'device'})) + monkeypatch.setattr(service, 'trusted', lambda _: None) + return root, profile + + +def test_named_program_rewrite_preserves_other_sections_and_policy(binding): + root, profile = binding + path = Path(profile['service_file']) + before = path.read_text() + service.render(root, profile, root / 'unused') + after = path.read_text() + assert after.split('[program:wrapper]')[0] == before.split('[program:wrapper]')[0] + assert 'stdout_logfile=/var/log/wrapper.log' in after + assert 'stopasgroup=false' in after + assert 'wrapper_exec.py' in after + assert service.supervisor_program(profile)['directory'] == str(root / 'current') + # A second normal upgrade is idempotent, not a growing duplicate section. + service.render(root, profile, root / 'unused') + assert path.read_text() == after + backup = root / 'backup' + backup.write_text(before) + path.write_text(after.replace('/opt/native-service.py', '/opt/new-native-service.py')) + service.restore_program(profile, backup) + restored = path.read_text() + assert restored.split('[program:wrapper]')[1] == before.split('[program:wrapper]')[1] + assert '/opt/new-native-service.py' in restored + + +def test_selectors_pairing_and_snapshot_share_profile(binding): + from deploy.work_registry_snapshot import resolve_work_roots, resolve_wrapper_state_dir + root, profile = binding + env = service.environment(profile) + home = Path(profile['home']) + assert env['RELAY_URL'] == 'wss://relay.test/ws' + assert env['CC_REMOTE_DEVICE_CONFIG'] == profile['device_file'] + assert resolve_wrapper_state_dir(home, environment=env) == home / 'private' + assert resolve_work_roots(home, environment=env)['claude'] == home / '.claude/cc-remote/work' + installation = update.Installation(root, root, {'os': 'linux'}, {'linux_service': profile}) + assert update_relay.relay_origin(installation) == 'https://relay.test' + Path(profile['env_file']).write_text('RELAY_URL=wss://override.test/ws\n') + assert update_relay.relay_origin(installation) == 'https://override.test' + + +def test_start_reload_changes_only_the_selected_program(binding, monkeypatch): + _, profile = binding + calls = [] + server = SimpleNamespace( + reloadConfig=lambda: [[[], ['wrapper', 'claude-service'], ['unrelated']]], + getProcessInfo=lambda name: {'statename': 'STOPPED', 'pid': 0, 'start': 0}, + removeProcessGroup=lambda name: calls.append(('remove', name)), + addProcessGroup=lambda name: calls.append(('add', name)), + startProcess=lambda name, wait: calls.append(('start', name)), + ) + monkeypatch.setattr(service, 'rpc', lambda _: server) + service.start(profile) + assert calls == [('remove', 'wrapper'), ('add', 'wrapper'), ('start', 'wrapper')] + + +def test_start_refuses_reconfiguring_a_live_process(binding, monkeypatch): + _, profile = binding + server = SimpleNamespace(reloadConfig=lambda: [[[], ['wrapper'], []]], + getProcessInfo=lambda _: {'statename': 'STARTING', 'pid': 123, 'start': 1}) + monkeypatch.setattr(service, 'rpc', lambda _: server) + with pytest.raises(ValueError, match='stop the Wrapper'): + service.start(profile) + + +@pytest.mark.parametrize('code,success', [(10, True), (70, True), (1, False)]) +def test_stop_does_not_hide_arbitrary_supervisor_failures(binding, monkeypatch, code, success): + _, profile = binding + def stop(name, wait): + assert name == 'wrapper' and wait + raise xmlrpc.client.Fault(code, 'fixture') + monkeypatch.setattr(service, 'rpc', lambda _: SimpleNamespace(stopProcess=stop)) + if success: + service.stop(profile) + else: + with pytest.raises(ValueError): + service.stop(profile) + + +def test_unix_transport_preserves_authentication(): + transport = service.UnixTransport('/run/fixture.sock') + connection = transport.make_connection('fixture:password@localhost') + assert connection.path == '/run/fixture.sock' + assert any(key == 'Authorization' and value.startswith('Basic ') for key, value in transport._extra_headers) + + +def test_root_custom_registration_round_trip_and_relay_launcher_preservation(binding, monkeypatch): + root, profile = binding + release = root / 'releases/v1' + (release / 'bin').mkdir(parents=True) + (root / 'current').symlink_to(release) + repo = Path(__file__).resolve().parents[1] + (release / 'bin/cc-remote').write_bytes((repo / 'scripts/cc-remote').read_bytes()) + (release / 'release-manifest.json').write_text(json.dumps(dict( + schema=1, product_version='4.0.9', protocol_version=74, git_sha='a'*40, + role='wrapper', os='linux', arch='arm64', python='3.13.9', uv='0.11.16'))) + cli = root / 'bin/cc-remote' + install_cli.install_cli(root, cli, role='wrapper', user='root', linux_service=profile) + assert 'managed_wrapper_root=' + str(root) in cli.read_text() + assert update.read_installation(root, 'linux', 'arm64').metadata['linux_service'] == profile + monkeypatch.setenv('CC_REMOTE_MANAGED_ROOT', str(root)) + assert update.installation_roots('linux')['wrapper'] == root + # Root remains rejected without an explicit bound service profile. + with pytest.raises(ValueError, match='original service user'): + install_cli.install_cli(root, cli, role='wrapper', user='root') + relay = root / 'relay' + relay_release = relay / 'releases/v1' + (relay_release / 'bin').mkdir(parents=True) + (relay / 'current').symlink_to(relay_release) + (relay_release / 'bin/cc-remote').write_bytes((repo / 'scripts/cc-remote').read_bytes()) + install_cli.install_cli(relay, cli, role='relay', domain='relay.test') + assert 'managed_wrapper_root=' + str(root) in cli.read_text() + + +def test_root_upstream_ssh_does_not_require_sudo(binding, monkeypatch): + root, profile = binding + installation = update.Installation(root, root, {'os': 'linux'}, {'user': 'root', 'linux_service': profile}) + monkeypatch.setattr(update_relay.os, 'geteuid', lambda: 0) + monkeypatch.setattr(update_relay.pwd, 'getpwnam', lambda _: SimpleNamespace(pw_name='root')) + calls = [] + def run(args, **kwargs): + calls.append(args) + return SimpleNamespace(returncode=0, stdout='ok') + monkeypatch.setattr(update_relay.subprocess, 'run', run) + assert update_relay.RelayUpdate(installation, 'relay-host')._ssh(['true']) == 'ok' + assert calls[0][0] == '/usr/bin/ssh' + + +def test_trusted_rejects_user_writable_managed_root(tmp_path): + # Root check is meaningful on both the normal developer account and root CI. + path = tmp_path / 'untrusted' + path.mkdir(mode=0o777) + path.chmod(0o777) + with pytest.raises(ValueError, match='root-owned'): + service.trusted(path) + + +@pytest.mark.parametrize('value', ['HOME', 'CC_REMOTE_STATE_DIR', 'WRAPPER_TOKEN']) +def test_bootstrap_unset_cannot_change_snapshot_or_pairing_selectors(value): + with pytest.raises(ValueError, match='only account'): + service.unset_keys(value) + + +def test_supervisor_paths_and_dynamic_config_fail_closed(binding): + _, profile = binding + with pytest.raises(ValueError): + service.validate({**profile, 'home': None}) + with pytest.raises(ValueError): + service.validate({**profile, 'home': '/home/../root'}) + main = Path(profile['supervisor_config']) + main.write_text('[include]\nfiles=%(ENV_CONFIG_DIR)s/*.conf\n') + with pytest.raises(ValueError, match='dynamic Supervisor'): + service.config_files(main) + + +def test_snapshot_uses_bound_private_state_directory(binding): + root, profile = binding + profile_file = root / 'binding.json' + profile_file.write_text(json.dumps(profile)) + destination = root / 'snapshot' + assert service.main(['snapshot', '--root', str(root), '--profile', str(profile_file), + '--destination', str(destination)]) == 0 + manifest = json.loads((destination / 'manifest.json').read_text()) + assert manifest['wrapper_state']['directory'] == str(Path(profile['home']) / 'private') diff --git a/tests/test_release_distribution.py b/tests/test_release_distribution.py index 693499af..b548d692 100644 --- a/tests/test_release_distribution.py +++ b/tests/test_release_distribution.py @@ -159,6 +159,7 @@ def test_release_bundles_are_deterministic_and_role_scoped( assert f"{prefix}/cc_remote/__main__.py" in members assert f"{prefix}/deploy/install_cli.py" in members assert f"{prefix}/deploy/cleanup.py" in members + assert f"{prefix}/deploy/release_retention.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) @@ -182,6 +183,7 @@ def test_release_bundles_are_deterministic_and_role_scoped( 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}/deploy/adopt_wrapper.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 diff --git a/tests/test_release_retention.py b/tests/test_release_retention.py new file mode 100644 index 00000000..c96b2461 --- /dev/null +++ b/tests/test_release_retention.py @@ -0,0 +1,281 @@ +"""Real temporary-tree retirement with stubbed OS/health observations; no model.""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from deploy import cleanup, release_retention as retention +from deploy.install_lock import acquire_install_lock + + +@pytest.fixture +def fleet(tmp_path, monkeypatch): + root = tmp_path.resolve() / "install" + (root / "releases").mkdir(parents=True) + service = tmp_path.resolve() / "wrapper.service" + service.write_text("operator-owned service settings\n") + monkeypatch.setattr(retention, "acceptance", lambda *a: None) + monkeypatch.setattr(retention, "service_identity", lambda row: {"pid": "123", "runs": "1"}) + monkeypatch.setattr(retention, "service_dependencies", lambda *a: []) + monkeypatch.setattr(cleanup, "process_references", lambda paths: {p: [] for p in paths}) + monkeypatch.setattr(cleanup, "run_checks", lambda plan: None) + + def activate(name): + release = root / "releases" / name + release.mkdir() + (release / "payload").write_text("installed bytes") + (release / "release-manifest.json").write_text(json.dumps({ + "schema": 1, "product_version": "4.0.9", "protocol_version": 74, + "git_sha": "a" * 40, "role": "wrapper", "os": "linux", "arch": "x86_64", + "python": "3.13.9", "uv": "0.11.16", + })) + link = root / "current" + previous = str(link.resolve()) if link.exists() else "" + identity = retention.begin(root, release, previous, role="wrapper", home=root, + service="wrapper", service_file=service, configs=[]) + snapshot = root / "rollback-data" / identity + snapshot.mkdir(parents=True) + (snapshot / "private-state").write_text("recover the previous data") + if link.is_symlink(): + link.unlink() + link.symlink_to(release) + retention.commit(root, identity, str(snapshot)) + return release, retention.generation(root, identity)[1] + + return root, service, activate + + +def prune(root): + fd = acquire_install_lock(root) + try: + return retention.prune(root, fd) + finally: + os.close(fd) + + +def test_three_upgrades_keep_current_and_complete_previous_recovery(fleet): + root, service, activate = fleet + first, old = activate("first") + second, middle = activate("second") + third, latest = activate("third") + unknown = root / "releases/unknown-legacy" + unknown.mkdir() + report = prune(root) + assert report["status"] == "complete" + assert third.is_dir() and second.is_dir() and not first.exists() + assert unknown.is_dir() # no filename/mtime guessing + assert Path(latest["snapshot"]).is_dir() and Path(latest["backup"]).is_dir() + assert (Path(latest["backup"]) / "0").read_bytes() == service.read_bytes() + assert Path(latest["backup"]).stat().st_mode & 0o777 == 0o700 + for row in (old, middle): + assert not Path(row["backup"]).exists() and not Path(row["snapshot"]).exists() + + +def test_first_preledger_release_becomes_eligible_only_after_next_success(fleet): + root, _, activate = fleet + first = root / "releases/pre-ledger" + first.mkdir() + (root / "current").symlink_to(first) + second, _ = activate("second") + assert prune(root) is None + assert first.is_dir() + third, _ = activate("third") + prune(root) + assert not first.exists() and second.exists() and third.exists() + + +def test_live_old_release_survives_then_same_version_prune_retries(fleet, monkeypatch): + root, _, activate = fleet + first, _ = activate("first") + activate("second") + activate("third") + monkeypatch.setattr(cleanup, "process_references", lambda paths: { + p: ["pid 55: cwd"] if p == first else [] for p in paths}) + assert prune(root)["status"] == "deferred" + assert first.exists() + monkeypatch.setattr(cleanup, "process_references", lambda paths: {p: [] for p in paths}) + assert prune(root)["status"] == "complete" + assert not first.exists() + + +def test_transitive_runtime_of_previous_release_is_not_removed(fleet): + root, _, activate = fleet + first, _ = activate("first") + second, _ = activate("second") + (second / "runtime").symlink_to(first, target_is_directory=True) + activate("third") + report = prune(root) + assert first.exists() + assert any("symlink dependency" in reason for row in report["candidates"] + if row["path"] == str(first) for reason in row["reasons"]) + + +@pytest.mark.parametrize("failure", ["health", "visibility", "pending", "replaced", "unknown_cleanup"]) +def test_uncertain_acceptance_never_deletes(fleet, monkeypatch, failure): + root, _, activate = fleet + first, _ = activate("first") + activate("second") + _, row = activate("third") + if failure == "health": + def fail(*args): + raise ValueError("health failed") + monkeypatch.setattr(retention, "acceptance", fail) + elif failure == "visibility": + def invisible(*args): + raise cleanup.CleanupError("process visibility incomplete") + monkeypatch.setattr(cleanup, "process_references", invisible) + elif failure == "pending": + data = retention.read_ledger(root) + data["generations"][0]["phase"] = "prepared" + cleanup.write_journal(root / retention.LEDGER, data) + elif failure == "replaced": + first.rename(first.with_name("original")) + first.mkdir() + else: + cleanup.write_journal(root / cleanup.JOURNAL, {"status": "requires_inspection"}) + with pytest.raises(ValueError): + prune(root) + assert first.exists() and Path(row["snapshot"]).exists() + + +def test_post_quarantine_failure_restores_original_paths(fleet, monkeypatch): + root, _, activate = fleet + first, _ = activate("first") + activate("second") + activate("third") + calls = [] + + def checks(plan): + calls.append(first.exists()) + if len(calls) == 2: + raise cleanup.CleanupError("fresh config read failed") + + monkeypatch.setattr(cleanup, "run_checks", checks) + with pytest.raises(ValueError, match="fresh config"): + prune(root) + assert first.is_dir() and (first / "payload").read_text() == "installed bytes" + assert json.loads((root / cleanup.JOURNAL).read_text())["status"] == "aborted" + + +def test_failed_activation_does_not_create_deletable_generation(fleet): + root, _, activate = fleet + first, _ = activate("first") + second, row = activate("second") + data = retention.read_ledger(root) + data["generations"][-1]["phase"] = "prepared" + cleanup.write_journal(root / retention.LEDGER, data) + (root / "current").unlink() + (root / "current").symlink_to(first) + retention.abort(root, row["id"]) + third, _ = activate("third") + prune(root) + assert first.exists() and second.exists() and third.exists() + assert Path(row["snapshot"]).exists() + + +def test_copy_failure_leaves_explicit_unfinished_record(fleet): + root, service, activate = fleet + activate("first") + service.unlink() + service.symlink_to(root / "releases/first/payload") + with pytest.raises(ValueError, match="symlink"): + activate("second") + assert retention.read_ledger(root)["generations"][-1]["phase"] == "prepared" + with pytest.raises(ValueError, match="unfinished"): + activate("third") + + +def test_legacy_outside_root_is_kept_for_explicit_migration_acceptance(fleet, tmp_path): + root, service, _ = fleet + legacy = tmp_path.resolve() / "legacy/release" + legacy.mkdir(parents=True) + release = root / "releases/new" + release.mkdir() + # Reuse the normal fixture manifest without installing another generation. + manifest = {"schema": 1, "product_version": "4.0.9", "protocol_version": 74, + "git_sha": "a" * 40, "role": "wrapper", "os": "linux", "arch": "x86_64", + "python": "3.13.9", "uv": "0.11.16"} + (release / "release-manifest.json").write_text(json.dumps(manifest)) + identity = retention.begin(root, release, str(legacy), role="wrapper", home=root, + service="wrapper", service_file=service, configs=[]) + (root / "current").symlink_to(release) + retention.commit(root, identity) + with pytest.raises(ValueError, match="legacy rollback"): + prune(root) + assert legacy.is_dir() + + +def test_dormant_service_and_environment_dependencies(tmp_path, monkeypatch): + services = tmp_path / 'units' + services.mkdir() + old = tmp_path / 'release-old' + old.mkdir() + env = tmp_path / 'daemon.env' + env.write_text(f'CONFIG={old}/settings.json\n') + (services / 'idle.service').write_text(f'[Service]\nEnvironmentFile={env}\n') + (services / 'masked.service').symlink_to('/dev/null') + monkeypatch.setattr(retention, 'service_roots', lambda row: [services]) + assert retention.service_dependencies({}, [old]) == [str(old)] + (services / 'idle.service').write_text('[Service]\nEnvironmentFile=/private/%i.env\n') + with pytest.raises(ValueError, match='dynamic'): + retention.service_dependencies({}, [old]) + + +@pytest.mark.parametrize('failure', ['none', 'wrong-protocol', 'offline', 'restarted', 'native-config']) +def test_wrapper_acceptance_binds_service_owner_public_route_and_fresh_config(tmp_path, monkeypatch, failure): + from types import SimpleNamespace + from cc_remote import update_relay + from deploy import work_registry_snapshot + import dotenv + + root = tmp_path.resolve() + release = root / 'releases/current' + release.mkdir(parents=True) + (release / 'release-manifest.json').write_text(json.dumps({ + 'schema': 1, 'product_version': '4.0.9', 'protocol_version': 74, + 'git_sha': 'a' * 40, 'role': 'wrapper', 'os': 'linux', 'arch': 'x86_64', + 'python': '3.13.9', 'uv': '0.11.16', + })) + (root / 'installation.json').write_text(json.dumps({'schema': 1, 'user': 'service-owner'})) + (root / 'current').symlink_to(release) + row = {'release': str(release), 'role': 'wrapper', 'home': str(root), + 'service_file': '/etc/wrapper.service', 'started_at': 123, 'snapshot': str(root / 'snapshot')} + calls = [] + identities = iter([{'pid': 1}, {'pid': 2 if failure == 'restarted' else 1}]) + monkeypatch.setattr(retention.sys, 'platform', 'linux') + monkeypatch.setattr(retention, 'os', SimpleNamespace(geteuid=lambda: 0)) + monkeypatch.setattr(retention.pwd, 'getpwnam', lambda _: SimpleNamespace(pw_name='service-owner', pw_uid=1000)) + monkeypatch.setattr(retention, 'service_identity', lambda row: next(identities)) + monkeypatch.setattr(retention.time, 'sleep', lambda _: None) + monkeypatch.setattr(dotenv, 'dotenv_values', lambda _: {'CC_REMOTE_MACHINE_ID': 'fixture-machine'}) + monkeypatch.setattr(work_registry_snapshot, 'resolve_wrapper_state_dir', lambda *a, **k: root / 'private') + monkeypatch.setattr(update_relay, 'relay_origin', lambda _: 'https://relay.test') + monkeypatch.setattr(update_relay, '_get_json', lambda _: {'machines': [] if failure == 'offline' else ['fixture-machine']}) + monkeypatch.setattr(update_relay, 'relay_release', lambda _: {'version': '4.0.9', 'protocol': 75 if failure == 'wrong-protocol' else 74}) + def command(argv): + calls.append(argv) + if failure == 'native-config': + raise ValueError('native config failed') + return '' + monkeypatch.setattr(retention, 'command', command) + if failure == 'none': + retention.acceptance(root, row) + assert any('verify' in call and str(root / 'snapshot') in call for call in calls) + else: + with pytest.raises(ValueError): + retention.acceptance(root, row) + assert calls[0][:4] == ['runuser', '-u', 'service-owner', '--'] + assert calls[0][-3:] == ['--live-config', '--state-dir', str(root / 'private')] + + +def test_stopped_independent_service_pointer_keeps_its_old_release(fleet): + root, _, activate = fleet + first, _ = activate('first') + (root / 'service-current').symlink_to(first) + activate('second') + activate('third') + assert prune(root)['status'] == 'deferred' + assert first.is_dir() diff --git a/tests/test_update.py b/tests/test_update.py index f47b396c..56139f3d 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -36,8 +36,8 @@ def _manifest(role="wrapper", system="darwin", version="4.0.0"): } -def _installation(tmp_path, monkeypatch, *, role="wrapper", system="darwin"): - root = tmp_path / f"managed {role}" +def _installation(tmp_path, monkeypatch, *, role="wrapper", system="darwin", root_name=None): + root = tmp_path / (root_name or f"managed {role}") release = root / "releases/old" release.mkdir(parents=True) (root / "current").symlink_to(release) @@ -146,6 +146,35 @@ def test_check_never_downloads_locks_or_activates(tmp_path, monkeypatch, capsys) assert (installation.root / "current").resolve() == installation.release +@pytest.mark.parametrize("check,recorded,exit_code", [(True, True, 0), (False, False, 0), + (False, True, 0), (False, True, 1)]) +def test_same_version_retention_is_locked_and_check_stays_read_only( + tmp_path, monkeypatch, capsys, check, recorded, exit_code, +): + installation = _installation(tmp_path, monkeypatch) + retention = installation.release / "deploy/release_retention.py" + retention.parent.mkdir() + retention.write_text("fixture") + if recorded: + (installation.root / ".release-generations.json").write_text("{}") + calls = [] + def run(argv, descriptor): + from deploy.install_lock import verify_install_lock + with monkeypatch.context() as actual_identity: + actual_identity.setattr(updater.os, "geteuid", os.getuid) + verify_install_lock(installation.root, descriptor) + calls.append(argv) + return exit_code + monkeypatch.setattr(updater, "run_installer", run) + monkeypatch.setattr(updater, "download_bundle", lambda *a: pytest.fail("unexpected install")) + assert main(["update", "--version", "4.0.0", *(["--check"] if check else [])]) == 0 + assert len(calls) == int(recorded and not check) + if calls: + assert calls[0][-3:] == ["prune", "--root", str(installation.root)] + if exit_code: + assert "retained" in capsys.readouterr().out + + @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) @@ -573,3 +602,28 @@ def test_macos_upgrade_preserves_operator_environment_and_service_socket(tmp_pat assert all(result["EnvironmentVariables"][key] == value for key, value in environment.items()) assert result["ProgramArguments"][0] == str(tmp_path / "current/.venv/bin/python") assert result["Label"] == "org.example.cc-remote" + + +def test_supervisor_update_downloads_bundle_and_preserves_full_binding(tmp_path, monkeypatch): + from deploy import linux_service + installation = _installation(tmp_path.resolve(), monkeypatch, system='linux', root_name='managed') + root = installation.root + profile = dict(manager='supervisor', name='wrapper', user='root', home=str(root / 'home'), + service_file=str(root / 'wrapper.conf'), + supervisor_config=str(root / 'supervisor.conf'), + env_file=str(root / 'wrapper.env'), device_file=str(root / 'device.json')) + (root / 'installation.json').write_text(json.dumps( + dict(schema=1, role='wrapper', user='root', linux_service=profile))) + monkeypatch.setattr(linux_service, 'trusted', lambda _: None) + mirror, marker, _ = _bundle(tmp_path, installation) + monkeypatch.setenv('CC_REMOTE_RELEASE_BASE_URL', mirror.as_uri()) + assert main(['update', '--version', '4.0.1']) == 0 + [call] = json.loads(marker.read_text()) + for flag, value in [('--user', 'root'), ('--install-root', str(root)), + ('--service-manager', 'supervisor'), ('--service-name', 'wrapper'), + ('--home', profile['home']), ('--device-file', profile['device_file']), + ('--env-file', profile['env_file']), ('--service-file', profile['service_file']), + ('--supervisor-config', profile['supervisor_config'])]: + assert call[call.index(flag) + 1] == value + assert '--adopt-supervisor' not in call + assert (root / 'operator-config').read_text() == 'preserve operator settings\n' diff --git a/tests/test_update_installer_relay.py b/tests/test_update_installer_relay.py index 10766b9c..83031e62 100644 --- a/tests/test_update_installer_relay.py +++ b/tests/test_update_installer_relay.py @@ -88,7 +88,8 @@ def test_real_installer_block_runs_new_bundle_preflight_before_local_stop(tmp_pa assert stop < source.index('if [ "$service_was_running" -eq 1 ]; then', stop) settings = {"previous": str(previous), "pair_code": "", "appdir": str(root), "target": str(target), "target_user": "service-user", "system": "darwin", - "service_label": "org.example.wrapper", "relay_ssh": "", "allow_protocol_change": "0"} + "service_label": "org.example.wrapper", "relay_ssh": "", "allow_protocol_change": "0", + "adopt_root": ""} marker = tmp_path / "local-service-stopped" # A v4.0.1 caller has no new CLI flags or coordination environment marker. harness = "set -euo pipefail\n" + "\n".join( @@ -117,3 +118,33 @@ def verify(): monkeypatch.setattr(relay, "_finish", lambda _: remote.update(version="4.0.1")) relay.ensure("4.0.1", 72, allow_protocol_change=False) assert json.loads(relay.settings.read_text()) == {"relay_ssh": "operator@relay"} + + +@pytest.mark.parametrize('compatible', [True, False]) +def test_explicit_migration_reads_old_contract_but_keeps_upstream_records_in_new_root( + tmp_path, monkeypatch, compatible, +): + import shutil + + installation = _installation(tmp_path, monkeypatch, system='linux') + destination = tmp_path / 'standard-install' + destination.mkdir() + bundle = tmp_path / 'downloaded' + shutil.copytree(installation.release, bundle) + (bundle / 'release-manifest.json').write_text(json.dumps(_manifest(system='linux', version='4.0.1'))) + if not compatible: + (bundle / 'requirements-wrapper.lock').write_text('claude-agent-sdk==0.2.157\n') + calls = [] + def ensure(self, *args, **kwargs): + assert self.journal.parent == destination + assert self.settings.parent == destination + assert self.installation.release == installation.release + calls.append(args) + monkeypatch.setattr(update_relay.RelayUpdate, 'ensure', ensure) + assert update_relay.installer_main([ + '--root', str(destination), '--previous-root', str(installation.root), + '--bundle', str(bundle), '--user', 'service-user', + ]) == (0 if compatible else 1) + assert bool(calls) is compatible + assert not (destination / 'current').exists() + assert (installation.root / 'current').resolve() == installation.release From cae824a9c5343711e4990581c04d59c81fd494c2 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 08:35:30 +0800 Subject: [PATCH 2/9] fix(deploy): protect releases during incomplete retention checks - Discover systemd system, global user and per-user load paths, including active manager overrides, before retiring releases. - Defer deletion when unit directories or manager paths cannot be inspected completely. - Preserve prepared Relay releases when backup creation fails or is interrupted before returning its transaction ID. - Cover dormant unit references and interrupted backup creation with regression tests. --- deploy/README.md | 4 + deploy/release_retention.py | 71 +++++++++++++++--- deploy/setup-vps.sh | 4 + deploy/setup_transaction.sh | 2 +- tests/test_deploy.py | 63 ++++++++++++++++ tests/test_release_retention.py | 126 ++++++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 10 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index abc5a38d..c6522d0a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -146,6 +146,10 @@ all deletion to `cleanup.py`, inheriting the installation lock. Fresh acceptance checks public Relay/Web identity, stable service identity, Wrapper connectivity, snapshot integrity and live Codex configuration as the service user. Process, symlink and dormant service/configuration dependencies override retention limits. +Linux discovery reads the installed systemd system/global/user load paths and +the actual `UnitPath` of running managers, including runtime and generated units. +Missing tools, unreachable managers or unreadable directories defer cleanup; +there is no fallback to a partial hard-coded directory list. Failed/incomplete acceptance or insufficient process visibility retains files and warns without rolling back an already committed installation. A same-version diff --git a/deploy/release_retention.py b/deploy/release_retention.py index a307dc16..65ea13c4 100644 --- a/deploy/release_retention.py +++ b/deploy/release_retention.py @@ -230,16 +230,65 @@ def abort(root: Path, identity: str) -> None: retirement.write_journal(root / LEDGER, data) +def unit_paths(argv: list[str], *, lines: bool = False) -> list[Path]: + raw = command(argv) + require(len(raw) <= 65536, "systemd unit path discovery exceeds bound") + values = raw.splitlines() if lines else shlex.split(raw) + require(0 < len(values) <= 256, "systemd unit path discovery is incomplete") + require(all(value.startswith('/') and not re.search(r'[\\\x00-\x1f]', value) for value in values), + "systemd unit paths must be literal absolute paths") + return [Path(value) for value in values] + + def service_roots(row: dict) -> list[Path]: home = Path(row["home"]) - roots = ([home / "Library/LaunchAgents", Path("/Library/LaunchAgents"), - Path("/Library/LaunchDaemons")] if sys.platform == "darwin" else - [Path("/etc/systemd/system"), Path("/usr/lib/systemd/system"), - Path("/usr/local/lib/systemd/system"), home / ".config/systemd/user"]) - if sys.platform != "darwin" and os.geteuid() == 0: - roots.extend(Path(account.pw_dir) / ".config/systemd/user" for account in pwd.getpwall() - if account.pw_dir.startswith("/") and Path(account.pw_dir).is_dir()) - return roots + if sys.platform == "darwin": + return [home / "Library/LaunchAgents", Path("/Library/LaunchAgents"), Path("/Library/LaunchDaemons")] + require(os.geteuid() == 0, "Linux service discovery requires visibility of every service user") + # systemd-analyze includes the distribution's complete default load paths, + # including generated/transient/control units and global user directories. + # It does not query managers: also include their actual UnitPath overrides. + # See systemd-analyze(1), "unit-paths". Failure is not an empty inventory. + clean_env = ['env', '-i', 'PATH=/usr/sbin:/usr/bin:/sbin:/bin'] + roots = [] + for scope in ('system', 'global'): + roots.extend(unit_paths([*clean_env, 'systemd-analyze', '--' + scope, 'unit-paths'], lines=True)) + roots.extend(unit_paths([*clean_env, 'systemctl', '--system', 'show', '-p', 'UnitPath', '--value'])) + active = command([*clean_env, 'systemctl', '--system', 'list-units', '--all', '--plain', '--no-legend', + '--no-pager', '--state=active,activating,reloading,deactivating', 'user@*.service']) + require(len(active) <= 65536, "user manager discovery exceeds bound") + active_users = set() + for line in active.splitlines(): + match = re.fullmatch(r'user@(\d+)\.service\s+.+', line.strip()) + require(match is not None, "user manager discovery is incomplete") + active_users.add(int(match[1])) + accounts = pwd.getpwall() + require(len(accounts) <= 1024, "service user discovery exceeds bound") + for account in accounts: + require(account.pw_dir.startswith('/'), "service user has an unknown HOME") + user_home = Path(account.pw_dir) + try: + is_directory = stat.S_ISDIR(user_home.stat().st_mode) + except FileNotFoundError: + is_directory = False + require(is_directory or account.pw_uid not in active_users, "active user manager HOME is unavailable") + if not is_directory: + continue + runtime = f'/run/user/{account.pw_uid}' + environment = [*clean_env, f'HOME={user_home}', f'XDG_RUNTIME_DIR={runtime}'] + roots.extend(unit_paths([*environment, 'systemd-analyze', '--user', 'unit-paths'], lines=True)) + if account.pw_uid in active_users: + roots.extend(unit_paths([ + 'runuser', '-u', account.pw_name, '--', *environment, + f'DBUS_SESSION_BUS_ADDRESS=unix:path={runtime}/bus', + 'systemctl', '--user', 'show', '-p', 'UnitPath', '--value', + ])) + active_users.remove(account.pw_uid) + require(not active_users, "an active user manager has no discoverable service account") + # A bound service can deliberately use a different HOME from passwd. + roots.extend([home / '.config/systemd/user', home / '.config/systemd/user.control', + home / '.local/share/systemd/user']) + return list(dict.fromkeys(roots)) def service_dependencies(row: dict, candidates: list[Path]) -> list[str]: @@ -255,8 +304,12 @@ def walk_error(error: OSError) -> None: raise error for directory in set(service_roots(row)): - if not directory.exists(): + try: + info = directory.stat() + except FileNotFoundError: + require(not directory.is_symlink(), "dangling service directory requires explicit inspection") continue + require(stat.S_ISDIR(info.st_mode), "service search path is not a directory") for parent, directories, names in os.walk(directory, onerror=walk_error): require(not any((Path(parent) / name).is_symlink() for name in directories), "symlinked service directory requires explicit retention inventory") diff --git a/deploy/setup-vps.sh b/deploy/setup-vps.sh index 0e770e68..aa631cbb 100755 --- a/deploy/setup-vps.sh +++ b/deploy/setup-vps.sh @@ -45,6 +45,7 @@ CADDY_TEMPLATE="" MANAGED_RELEASE=0 CLI_PATH=/usr/local/bin/cc-remote RETENTION_GENERATION="" +RETENTION_STARTED=0 [ -r "$SOURCE_DIR/deploy/setup_transaction.sh" ] || { echo "ERROR: $SOURCE_DIR/deploy/setup_transaction.sh is missing" >&2 @@ -339,6 +340,9 @@ chown -R root:ccremote "$RUNTIMES_DIR" harden_release_permissions "$RUNTIMES_DIR" if (( MANAGED_RELEASE )); then + # begin journals the prepared transaction before copying configuration. Its + # stdout may be empty on failure/interruption; keep the release either way. + RETENTION_STARTED=1 RETENTION_GENERATION="$( "$NEW_RELEASE_DIR/.venv/bin/python" "$NEW_RELEASE_DIR/deploy/release_retention.py" begin \ --root "$APPDIR" --release "$NEW_RELEASE_DIR" --previous "$PREVIOUS_RELEASE" \ diff --git a/deploy/setup_transaction.sh b/deploy/setup_transaction.sh index a50ae55c..8573ac5a 100644 --- a/deploy/setup_transaction.sh +++ b/deploy/setup_transaction.sh @@ -194,7 +194,7 @@ cleanup() { # A restored current link alone does not prove Caddy, service and data # recovery. Leave a failed managed activation prepared for inspection. if [ -n "$NEW_RELEASE_DIR" ]; then - if (( RELEASE_SWITCHED )) || [ -n "${RETENTION_GENERATION:-}" ]; then + if (( RELEASE_SWITCHED || ${RETENTION_STARTED:-0} )) || [ -n "${RETENTION_GENERATION:-}" ]; then # A failed rollback may have left current pointing at the new release. # Keep that tree intact rather than turning the active symlink into a # dangling link; the operator can repair current and remove it later. diff --git a/tests/test_deploy.py b/tests/test_deploy.py index d70c839f..2cfdf583 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -9,9 +9,11 @@ from pathlib import Path import plistlib import re +import shlex import sqlite3 import stat import subprocess +import sys import pytest @@ -720,6 +722,67 @@ def test_post_switch_activation_commits_or_restores_full_release_caddy_and_unit( assert not unit_backup.exists() +@pytest.mark.parametrize('failure', ['backup-error', 'interrupted-copy']) +def test_relay_retention_setup_failure_keeps_the_prepared_release(tmp_path, failure): + appdir = tmp_path.resolve() / 'app' + old = appdir / 'releases/release-old' + new = appdir / 'releases/release-new' + old.mkdir(parents=True) + (new / 'deploy').mkdir(parents=True) + (appdir / 'current').symlink_to(old) + (new / 'release-manifest.json').write_text(json.dumps({ + 'schema': 1, 'product_version': '4.0.9', 'protocol_version': 74, + 'git_sha': 'a' * 40, 'role': 'relay', 'os': 'linux', 'arch': 'x86_64', + 'python': '3.13.9', 'uv': '0.11.16', + })) + (appdir / 'relay.service').write_text('original service\n') + (appdir / '.env').write_text('PUBLIC_ORIGIN=https://relay.test\n') + # A genuine backup error after begin has persisted its prepared ledger. + (appdir / 'Caddyfile').symlink_to(appdir / '.env') + driver = new / 'deploy/release_retention.py' + driver.write_text( + 'import os, runpy, shutil, signal\n' + + ('shutil.copyfileobj = lambda *a: os.kill(os.getpid(), signal.SIGTERM)\n' + if failure == 'interrupted-copy' else '') + + f'runpy.run_path({str(ROOT / "deploy/release_retention.py")!r}, run_name="__main__")\n' + ) + python = new / '.venv/bin/python' + python.parent.mkdir(parents=True) + python.write_text('#!/bin/sh\nexec ' + shlex.quote(sys.executable) + ' "$@"\n') + python.chmod(0o755) + setup = (ROOT / 'deploy/setup-vps.sh').read_text() + initial_state = setup[setup.index('NEW_RELEASE_DIR=""'):setup.index('[ -r "$SOURCE_DIR/deploy/setup_transaction.sh"')] + stop = setup.index('echo "==> Caddy config') + begin = setup[setup.rindex('if (( MANAGED_RELEASE )); then', 0, stop):stop] + harness = r''' +set -euo pipefail +source "$1" +APPDIR="$2" +RELEASES_DIR="$APPDIR/releases" +CURRENT_LINK="$APPDIR/current" +''' + initial_state + r''' +MANAGED_RELEASE=1 +NEW_RELEASE_DIR="$RELEASES_DIR/release-new" +PREVIOUS_RELEASE="$RELEASES_DIR/release-old" +RELAY_UNIT_FILE="$APPDIR/relay.service" +CADDYFILE="$APPDIR/Caddyfile" +ENV_FILE="$APPDIR/.env" +trap cleanup EXIT +''' + begin + result = subprocess.run(['bash', '-c', harness, 'retention-setup-test', + str(ROOT / 'deploy/setup_transaction.sh'), str(appdir)], + text=True, capture_output=True) + assert result.returncode != 0 + ledger = json.loads((appdir / '.release-generations.json').read_text()) + [row] = ledger['generations'] + assert row['phase'] == ledger['phase'] == 'prepared' + assert row['release'] == str(new) and row['artifacts'] == {} + assert Path(row['backup']).is_dir() + assert new.is_dir() and (appdir / 'current').resolve() == old + assert (appdir / 'relay.service').read_text() == 'original service\n' + assert 'retaining staged release for transaction inspection' in result.stderr + + def test_failed_release_rollback_never_deletes_the_still_active_release(tmp_path): appdir = tmp_path / "app" releases = appdir / "releases" diff --git a/tests/test_release_retention.py b/tests/test_release_retention.py index c96b2461..883d4bf8 100644 --- a/tests/test_release_retention.py +++ b/tests/test_release_retention.py @@ -10,6 +10,132 @@ from deploy import cleanup, release_retention as retention from deploy.install_lock import acquire_install_lock +SERVICE_DEPENDENCIES = retention.service_dependencies + + +@pytest.fixture +def systemd_paths(tmp_path, monkeypatch): + from types import SimpleNamespace + + filesystem = tmp_path.resolve() / 'systemd' + home = filesystem / 'home/operator' + home.mkdir(parents=True) + account = SimpleNamespace(pw_dir=str(home), pw_uid=1000, pw_name='operator') + paths = { + 'system': [filesystem / p for p in ( + 'run/systemd/system', 'run/systemd/generator.early', + 'run/systemd/generator', 'run/systemd/generator.late', + 'etc/systemd/system.control', 'run/systemd/transient')], + 'global': [filesystem / p for p in ( + 'etc/systemd/user', 'usr/lib/systemd/user', 'usr/local/lib/systemd/user')], + 'user': [home / '.config/systemd/user', home / '.local/share/systemd/user', + filesystem / 'run/user/1000/systemd/user'], + 'live-system': [filesystem / 'custom-system-units'], + 'live-user': [filesystem / 'custom-user-units'], + } + monkeypatch.setattr(retention.sys, 'platform', 'linux') + monkeypatch.setattr(retention, 'os', SimpleNamespace(**{**vars(os), 'geteuid': lambda: 0})) + monkeypatch.setattr(retention.pwd, 'getpwall', lambda: [account]) + calls = [] + + def command(argv): + calls.append(argv) + if 'unit-paths' in argv: + scope = next(s for s in ('system', 'global', 'user') if '--' + s in argv) + if scope == 'user': + assert 'HOME=' + str(home) in argv + assert 'XDG_RUNTIME_DIR=/run/user/1000' in argv + return '\n'.join(map(str, paths[scope])) + '\n' + if 'list-units' in argv: + return 'user@1000.service loaded active running User Manager for UID 1000\n' + if '--user' in argv: + assert argv[:3] == ['runuser', '-u', 'operator'] + return ' '.join(map(str, paths['live-user'])) + '\n' + return ' '.join(map(str, paths['live-system'])) + '\n' + + monkeypatch.setattr(retention, 'command', command) + return home, paths, calls + + +@pytest.mark.parametrize('scope,index', [ + ('system', 0), ('system', 1), ('system', 2), ('system', 3), + ('system', 4), ('system', 5), ('global', 0), ('global', 1), + ('global', 2), ('user', 1), ('user', 2), ('live-system', 0), ('live-user', 0), +]) +def test_dormant_units_in_all_load_paths_protect_old_releases( + fleet, systemd_paths, monkeypatch, scope, index, +): + root, _, activate = fleet + home, paths, _ = systemd_paths + old, _ = activate('first') + activate('second') + activate('third') + units = paths[scope][index] + units.mkdir(parents=True, exist_ok=True) + (units / 'dormant.service').write_text(f'[Service]\nExecStart={old}/payload\n') + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + report = prune(root) + assert report['status'] == 'deferred' + assert old.is_dir() + assert all(p in retention.service_roots({'home': str(home)}) for group in paths.values() for p in group) + + +@pytest.mark.parametrize('failure', ['unavailable', 'empty', 'relative', 'global-unavailable', + 'unknown-user', 'user-manager-unavailable']) +def test_incomplete_unit_path_discovery_never_deletes(fleet, systemd_paths, monkeypatch, failure): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + + discover = retention.command + + def incomplete(argv): + if failure == 'unavailable': + raise OSError('unit path discovery unavailable') + if failure == 'global-unavailable' and '--global' in argv: + raise OSError('global unit path discovery unavailable') + if failure == 'unknown-user' and 'list-units' in argv: + return 'user@2000.service loaded active running User Manager for UID 2000\n' + if failure == 'user-manager-unavailable' and '--user' in argv and 'show' in argv: + raise ValueError('user manager not reachable') + if failure in {'empty', 'relative'}: + return '' if failure == 'empty' else 'relative/unit/path\n' + return discover(argv) + + monkeypatch.setattr(retention, 'command', incomplete) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + with pytest.raises((ValueError, OSError)): + prune(root) + assert old.is_dir() + + +@pytest.mark.parametrize('failure', ['permission', 'dangling-directory']) +def test_unreadable_unit_directory_never_deletes(fleet, systemd_paths, monkeypatch, failure): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + _, paths, _ = systemd_paths + directory = paths['global'][0] + directory.parent.mkdir(parents=True) + if failure == 'permission': + directory.mkdir() + original = Path.stat + + def unreadable(path, *args, **kwargs): + if path == directory: + raise PermissionError('cannot inspect unit directory') + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, 'stat', unreadable) + else: + directory.symlink_to(directory.with_name('missing')) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + with pytest.raises((ValueError, OSError)): + prune(root) + assert old.is_dir() + @pytest.fixture def fleet(tmp_path, monkeypatch): From d00b3cb04a48e6fe530fd95a296dd51b4d87c9cb Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 10:22:25 +0800 Subject: [PATCH 3/9] fix(deploy): parse systemd environment file assignments safely - Discover spaced and continued EnvironmentFile directives before release cleanup. - Preserve literal filenames and defer ambiguous paths without shell unquoting. - Cover dormant release retention and optional-file handling with regression tests. --- deploy/README.md | 4 ++ deploy/release_retention.py | 52 ++++++++++++++++---- tests/test_release_retention.py | 84 +++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index c6522d0a..e89b8bc8 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -150,6 +150,10 @@ Linux discovery reads the installed systemd system/global/user load paths and the actual `UnitPath` of running managers, including runtime and generated units. Missing tools, unreachable managers or unreadable directories defer cleanup; there is no fallback to a partial hard-coded directory list. +Environment-file discovery handles spaced assignments and continued lines, +preserves literal spaces in filenames, and retains dependencies even across +overrides/resets. Unresolved specifiers, wildcards or ambiguous quoting/escapes +defer cleanup instead of treating an optional file as absent. Failed/incomplete acceptance or insufficient process visibility retains files and warns without rolling back an already committed installation. A same-version diff --git a/deploy/release_retention.py b/deploy/release_retention.py index 65ea13c4..ed900730 100644 --- a/deploy/release_retention.py +++ b/deploy/release_retention.py @@ -291,6 +291,44 @@ def service_roots(row: dict) -> list[Path]: return list(dict.fromkeys(roots)) +def service_environment_files(raw: bytes) -> list[tuple[Path, bool]]: + """Find literal systemd EnvironmentFile paths without interpreting shell syntax. + + Join logical lines before splitting assignments; systemd ignores whitespace + around '=' and skips full-line comments even during a continuation. Keep a + superset across sections, repeated assignments and resets: retaining an + overridden dependency is safer than guessing the effective drop-in order. + """ + paths = [] + parts = [] + # The sentinel also flushes a continuation at EOF, as systemd does. + for physical in [*raw.splitlines(), b'']: + physical = physical.removeprefix(b'\xef\xbb\xbf') + if physical.lstrip().startswith((b'#', b';')): + continue + trailing_slashes = len(physical) - len(physical.rstrip(b'\\')) + if trailing_slashes % 2: + parts.append(physical[:-1] + b' ') + continue + parts.append(physical) + key, separator, value = b''.join(parts).partition(b'=') + parts.clear() + if not separator or key.strip() != b'EnvironmentFile': + continue + name = value.strip().decode('utf-8') + if not name: # reset: do not discard dependencies already discovered + continue + optional = name.startswith('-') + name = name.removeprefix('-') + # EnvironmentFile takes one filename, including literal internal spaces, + # not a shell word list. Never erase escapes/quotes before validation or + # an optional path could silently turn into a different, missing file. + require(name.startswith('/') and not re.search(r'''[%$*?\[\\'"\x00-\x1f]''', name), + 'dynamic or ambiguous service environment requires explicit retention inventory') + paths.append((Path(name), optional)) + return paths + + def service_dependencies(row: dict, candidates: list[Path]) -> list[str]: """Read dormant service definitions and their literal environment files. @@ -341,16 +379,10 @@ def inspect(path: Path) -> bytes: for path in files: raw = inspect(path) - for value in re.findall(rb"^\s*EnvironmentFile=(.+)$", raw, re.M): - for name in shlex.split(value.decode()): - optional = name.startswith("-") - name = name.lstrip("-") - require(name.startswith("/") and not re.search(r"[%$*?\[\\]", name), - "dynamic service environment requires explicit retention inventory") - env = Path(name) - if optional and not env.exists() and not env.is_symlink(): - continue - inspect(env) + for env, optional in service_environment_files(raw): + if optional and not env.exists() and not env.is_symlink(): + continue + inspect(env) return sorted(found) diff --git a/tests/test_release_retention.py b/tests/test_release_retention.py index 883d4bf8..303279bf 100644 --- a/tests/test_release_retention.py +++ b/tests/test_release_retention.py @@ -350,6 +350,90 @@ def test_dormant_service_and_environment_dependencies(tmp_path, monkeypatch): retention.service_dependencies({}, [old]) +@pytest.mark.parametrize('assignment', [ + 'EnvironmentFile={env}', + 'EnvironmentFile = {env}', + '\tEnvironmentFile\t=\t{env}\t', + 'EnvironmentFile = -{env}', + 'EnvironmentFile = \\\n# ignored during continuation\n; also ignored\n {env}', + 'EnvironmentFile \\\n = {env}', + 'EnvironmentFile = {env}\nEnvironmentFile =', + 'EnvironmentFile = {env}\r\n', +]) +def test_environment_assignments_keep_dormant_release(fleet, tmp_path, monkeypatch, assignment): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units = tmp_path / 'units' + dropin = units / 'dormant.service.d' + dropin.mkdir(parents=True) + (units / 'dormant.service').write_text('[Service]\nExecStart=/bin/true\n') + env = tmp_path / 'external.env' + env.write_text(f'CONFIG={old}/payload\n') + (dropin / 'override.conf').write_text('[Service]\n' + assignment.format(env=env) + '\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + assert prune(root)['status'] == 'deferred' + assert old.is_dir() and (old / 'payload').read_text() == 'installed bytes' + + +@pytest.mark.parametrize('continued', [False, True]) +def test_environment_path_with_spaces_is_one_filename(fleet, tmp_path, monkeypatch, continued): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units = tmp_path / 'units' + units.mkdir() + env = tmp_path / 'external settings.env' + env.write_text(f'CONFIG={old}/payload\n') + name = str(env).replace('external settings', 'external\\\nsettings') if continued else str(env) + (units / 'dormant.service').write_text(f'[Service]\nEnvironmentFile=-{name}\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + assert prune(root)['status'] == 'deferred' + assert old.is_dir() + + +@pytest.mark.parametrize('value', [ + '-{env}\\x20suffix', '-"{env}"', "-'{env}'", '-{env}/%i', '-{env}/*', + '-{env}/$NAME', '-relative.env', '--{env}', +]) +def test_unresolved_environment_assignment_never_deletes(fleet, tmp_path, monkeypatch, value): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units = tmp_path / 'units' + units.mkdir() + (units / 'dormant.service').write_text( + '[Service]\nEnvironmentFile = ' + value.format(env=tmp_path / 'external.env') + '\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + with pytest.raises(ValueError, match='environment'): + prune(root) + assert old.is_dir() + + +def test_optional_missing_environment_and_empty_assignment_allow_retirement(fleet, tmp_path, monkeypatch): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units = tmp_path / 'units' + units.mkdir() + (units / 'dormant.service').write_text( + '[Service]\nEnvironmentFile =\n' + f'EnvironmentFile = -{tmp_path}/missing.env\n' + '# EnvironmentFile = /comment/missing.env\n' + '; EnvironmentFile = /comment/missing.env\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + assert prune(root)['status'] == 'complete' + assert not old.exists() + + @pytest.mark.parametrize('failure', ['none', 'wrong-protocol', 'offline', 'restarted', 'native-config']) def test_wrapper_acceptance_binds_service_owner_public_route_and_fresh_config(tmp_path, monkeypatch, failure): from types import SimpleNamespace From 7c1b531af34c8631181b84fcba300470363ea55a Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 11:02:43 +0800 Subject: [PATCH 4/9] fix(deploy): retain dormant service symlink dependencies - Resolve literal service and configuration paths through every symlink component. - Protect intermediate release owners and preserve their runtime dependency closure. - Defer ambiguous, cyclic, broken or inaccessible dependencies before cleanup. - Cover executable, directory, environment and launchd aliases with retention regressions. --- deploy/README.md | 6 ++ deploy/release_retention.py | 132 ++++++++++++++++++++++++++++--- tests/test_release_retention.py | 134 ++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 11 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index e89b8bc8..8b763534 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -154,6 +154,12 @@ Environment-file discovery handles spaced assignments and continued lines, preserves literal spaces in filenames, and retains dependencies even across overrides/resets. Unresolved specifiers, wildcards or ambiguous quoting/escapes defer cleanup instead of treating an optional file as absent. +Literal paths in service definitions, launchd plists and external environment +files are resolved component by component, including multi-hop aliases and +intermediate links owned by an old release. Those releases retain their runtime +dependency closure. Broken/cyclic links, unreadable paths, bare executable names +or ambiguous path expressions defer cleanup; discovery never executes a service +or recursively crawls arbitrary external directories. Failed/incomplete acceptance or insufficient process visibility retains files and warns without rolling back an already committed installation. A same-version diff --git a/deploy/release_retention.py b/deploy/release_retention.py index ed900730..6d02dc01 100644 --- a/deploy/release_retention.py +++ b/deploy/release_retention.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse +from collections import deque import json import os from pathlib import Path @@ -291,15 +292,9 @@ def service_roots(row: dict) -> list[Path]: return list(dict.fromkeys(roots)) -def service_environment_files(raw: bytes) -> list[tuple[Path, bool]]: - """Find literal systemd EnvironmentFile paths without interpreting shell syntax. - - Join logical lines before splitting assignments; systemd ignores whitespace - around '=' and skips full-line comments even during a continuation. Keep a - superset across sections, repeated assignments and resets: retaining an - overridden dependency is safer than guessing the effective drop-in order. - """ - paths = [] +def service_lines(raw: bytes) -> list[bytes]: + """Join systemd logical lines, preserving literal path whitespace.""" + lines = [] parts = [] # The sentinel also flushes a continuation at EOF, as systemd does. for physical in [*raw.splitlines(), b'']: @@ -311,8 +306,20 @@ def service_environment_files(raw: bytes) -> list[tuple[Path, bool]]: parts.append(physical[:-1] + b' ') continue parts.append(physical) - key, separator, value = b''.join(parts).partition(b'=') + lines.append(b''.join(parts)) parts.clear() + return lines + + +def service_environment_files(raw: bytes) -> list[tuple[Path, bool]]: + """Find literal systemd EnvironmentFile paths without interpreting shell syntax. + + Keep a superset across sections, repeated assignments and resets: retaining + an overridden dependency is safer than guessing the effective drop-in order. + """ + paths = [] + for line in service_lines(raw): + key, separator, value = line.partition(b'=') if not separator or key.strip() != b'EnvironmentFile': continue name = value.strip().decode('utf-8') @@ -329,6 +336,73 @@ def service_environment_files(raw: bytes) -> list[tuple[Path, bool]]: return paths +def service_literal_paths(value: str, *, atomic: bool = False) -> set[Path]: + """Extract only literal paths; ambiguous shell/expansion syntax defers cleanup. + + Also inspect the complete scalar for path-valued settings with unquoted + spaces. Tokenizing it alone could mistake an existing prefix for the path. + """ + if '/' not in value and not atomic: + return set() + require(not re.search(r'[\\%$`*?{}\x00-\x1f]', value), + 'nonliteral service paths require explicit retention inventory') + if atomic: + require(value.startswith('/'), 'relative service paths require explicit retention inventory') + return {Path(value)} + paths = set() + scalar = value.strip().removeprefix('-') + if scalar.startswith('/'): + paths.add(Path(scalar)) + for word in shlex.split(value): + # Assignments and command options can carry a path as their value. + if '=' in word and not word.startswith('/'): + word = word.partition('=')[2] + if re.match(r'^[A-Za-z][A-Za-z0-9+.-]*://', word): + continue + if '/' not in word: + continue + name = word.lstrip('-@:+!') # systemd executable prefixes + require(name.startswith('/') and not re.search(r'[;:,|<>\[\]]', name), + 'ambiguous service paths require explicit retention inventory') + paths.add(Path(name)) + return paths + + +def service_path_dependencies(path: Path, candidates: list[Path]) -> set[str]: + """Follow literal path components without losing intermediate symlink owners. + + A link inside an old release is a dependency even if its ultimate target is + outside the release. Missing ordinary paths may be future output files; + broken links, cycles and inaccessible components are incomplete visibility. + """ + require(path.is_absolute(), 'service dependency path must be absolute') + pending = deque(path.parts[1:]) + current = Path('/') + links = 0 + found = set() + while pending: + part = pending.popleft() + if part == '..': + current = current.parent + continue + current /= part + found.update(str(candidate) for candidate in candidates if current.is_relative_to(candidate)) + try: + info = current.lstat() + except FileNotFoundError: + break + if stat.S_ISLNK(info.st_mode): + links += 1 + require(links <= 64, 'service dependency symlink chain is cyclic or exceeds limit') + # Validate the link itself, not a possibly not-yet-created output + # suffix below it. Keep walking components to retain every owner. + current.resolve(strict=True) + target = Path(os.readlink(current)) + current = Path('/') if target.is_absolute() else current.parent + pending.extendleft(reversed(target.parts[1:] if target.is_absolute() else target.parts)) + return found + + def service_dependencies(row: dict, candidates: list[Path]) -> list[str]: """Read dormant service definitions and their literal environment files. @@ -359,9 +433,33 @@ def walk_error(error: OSError) -> None: files.update(config_files(Path(profile['supervisor_config']))) files.update(Path(profile[key]) for key in ('env_file', 'device_file') if Path(profile[key]).exists()) total = 0 + paths_seen = set() + + def protect(path: Path) -> None: + if path not in paths_seen: + paths_seen.add(path) + require(len(paths_seen) <= 8192, 'service path discovery exceeds bound') + found.update(service_path_dependencies(path, candidates)) + + def inspect_value(value: str, *, atomic: bool = False) -> None: + for path in service_literal_paths(value, atomic=atomic): + protect(path) + + def inspect_plist(value, key: str = '') -> None: + if isinstance(value, dict): + for child_key, child in value.items(): + inspect_plist(child, child_key) + elif isinstance(value, list): + for index, child in enumerate(value): + inspect_plist(child, 'Program' if key == 'ProgramArguments' and index == 0 else '') + elif isinstance(value, str): + inspect_value(value, atomic=key in { + 'Program', 'WorkingDirectory', 'RootDirectory', 'StandardOutPath', 'StandardErrorPath', + }) def inspect(path: Path) -> bytes: nonlocal total + protect(path) target = path.resolve(strict=True) if str(target) == "/dev/null": # masked systemd unit return b"" @@ -371,7 +469,19 @@ def inspect(path: Path) -> bytes: and total <= 32 * 1024 * 1024, "service dependency scan incomplete or oversized") raw = path.read_bytes() if path.suffix == ".plist": - raw = json.dumps(plistlib.loads(raw), default=str).encode() + value = plistlib.loads(raw) + inspect_plist(value) + raw = json.dumps(value, default=str).encode() + else: + for line in service_lines(raw): + key, separator, value = line.partition(b'=') + if key.strip() == b'EnvironmentFile': + continue # exact single-filename handling below + if (key.strip().startswith(b'Exec') or key.strip() == b'command') and value.strip(): + words = shlex.split(value.decode('utf-8')) + require(bool(words) and words[0].lstrip('-@:+!').startswith('/'), + 'nonliteral service executable requires explicit retention inventory') + inspect_value((value if separator else line).decode('utf-8')) for candidate in candidates: if str(candidate).encode() in raw or target.is_relative_to(candidate): found.add(str(candidate)) diff --git a/tests/test_release_retention.py b/tests/test_release_retention.py index 303279bf..a918b277 100644 --- a/tests/test_release_retention.py +++ b/tests/test_release_retention.py @@ -350,6 +350,140 @@ def test_dormant_service_and_environment_dependencies(tmp_path, monkeypatch): retention.service_dependencies({}, [old]) +@pytest.mark.parametrize('kind', [ + 'executable', 'relative-chain', 'directory', 'argument', 'environment', + 'environment-file', 'intermediate-owner', 'plist', 'plist-spaces', 'supervisor', +]) +def test_dormant_service_alias_keeps_release(fleet, tmp_path, monkeypatch, kind): + import plistlib + + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units = tmp_path / 'units' + units.mkdir() + alias = tmp_path / 'stable-worker' + alias.symlink_to(old / 'payload') + service = units / 'dormant.service' + text = f'[Service]\nExecStart={alias}\n' + if kind == 'relative-chain': + next_alias = tmp_path / 'second-alias' + next_alias.symlink_to(alias.name) + text = f'[Service]\nExecStart=-@{next_alias} worker\n' + elif kind == 'directory': + alias.unlink() + alias.symlink_to(old, target_is_directory=True) + text = f'[Service]\nExecStart={alias}/payload\nWorkingDirectory={alias}\n' + elif kind == 'argument': + text = f'[Service]\nExecStart=/bin/true --config={alias}\n' + elif kind == 'environment': + env = tmp_path / 'external.env' + env.write_text(f'WORKER="{alias}"\n') + text = f'[Service]\nEnvironmentFile={env}\n' + elif kind == 'environment-file': + text = f'[Service]\nEnvironmentFile={alias}\n' + elif kind == 'intermediate-owner': + external = tmp_path / 'external-worker' + external.write_text('worker') + (old / 'payload').unlink() + (old / 'payload').symlink_to(external) + elif kind in {'plist', 'plist-spaces'}: + if kind == 'plist-spaces': + directory = tmp_path / 'Application Support' + directory.mkdir() + alias = directory / 'worker' + alias.symlink_to(old / 'payload') + service = units / 'dormant.plist' + service.write_bytes(plistlib.dumps({'ProgramArguments': [str(alias), '--serve']})) + elif kind == 'supervisor': + service = units / 'worker.conf' + text = f'[program:worker]\ncommand={alias} --serve\n' + if kind not in {'plist', 'plist-spaces'}: + service.write_text(text) + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + assert prune(root)['status'] == 'deferred' + assert old.is_dir() + assert alias.resolve(strict=True).exists() + + +@pytest.mark.parametrize('failure', ['broken', 'cycle', 'permission', 'expansion', 'escaped', 'bare-command']) +def test_unresolved_service_alias_never_deletes(fleet, tmp_path, monkeypatch, failure): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units = tmp_path / 'units' + units.mkdir() + alias = tmp_path / 'worker' + alias.symlink_to(old / 'payload') + command = str(alias) + if failure == 'broken': + alias.unlink() + alias.symlink_to(tmp_path / 'missing') + elif failure == 'cycle': + alias.unlink() + alias.symlink_to(alias.name) + elif failure == 'permission': + original = Path.lstat + + def unreadable(path, *args, **kwargs): + if path == alias: + raise PermissionError('cannot inspect service alias') + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, 'lstat', unreadable) + elif failure == 'expansion': + command = str(alias.parent / '${WORKER}') + elif failure == 'escaped': + command = str(alias).replace('worker', r'wor\x6ber') + else: + command = 'worker' + (units / 'dormant.service').write_text(f'[Service]\nExecStart={command}\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + with pytest.raises((ValueError, OSError, RuntimeError)): + prune(root) + assert old.is_dir() + + +def test_unrelated_service_alias_allows_retirement(fleet, tmp_path, monkeypatch): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units = tmp_path / 'units' + units.mkdir() + worker = tmp_path / 'external-worker' + worker.write_text('worker') + alias = tmp_path / 'stable-worker' + alias.symlink_to(worker) + (units / 'dormant.service').write_text(f'[Service]\nExecStart={alias}\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + assert prune(root)['status'] == 'complete' + assert not old.exists() and alias.resolve(strict=True) == worker + + +def test_dormant_alias_preserves_transitive_release_dependencies(fleet, tmp_path, monkeypatch): + root, _, activate = fleet + runtime, _ = activate('first') + worker, _ = activate('second') + (worker / 'runtime').symlink_to(runtime, target_is_directory=True) + activate('third') + activate('fourth') + units = tmp_path / 'units' + units.mkdir() + alias = tmp_path / 'worker' + alias.symlink_to(worker / 'payload') + (units / 'dormant.service').write_text(f'[Service]\nExecStart={alias}\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + assert prune(root)['status'] == 'deferred' + assert worker.is_dir() and runtime.is_dir() + + @pytest.mark.parametrize('assignment', [ 'EnvironmentFile={env}', 'EnvironmentFile = {env}', From 13786679fef6121f66926d1d2a893cf2c5ee383e Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 11:34:53 +0800 Subject: [PATCH 5/9] fix(deploy): remove fresh Supervisor groups on rollback - Unregister the stopped group after a failed first installation removes its definition. - Preserve existing program registrations and report unverified cleanup as failed recovery. - Cover scoped group removal and fresh or existing installation rollback with regression tests. --- deploy/README.md | 5 ++- deploy/install-wrapper.sh | 7 +++- deploy/linux_service.py | 29 +++++++++++++- tests/test_install_commit.py | 76 ++++++++++++++++++++++++++++++++++++ tests/test_linux_service.py | 68 ++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 8b763534..8b177a52 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -310,7 +310,10 @@ Quarantine is temporary transaction state, not another retained backup or Trash. root-owned non-replaceable code/config paths. Never infer arbitrary launchers: first adoption requires `--adopt-supervisor` and operator-reconciled environment selectors. Only replace/reload that program; preserve other sections and the - independent Claude service. Snapshot selectors, Relay preflight, readiness and + independent Claude service. A failed fresh installation removes its newly + loaded Supervisor group after stopping it and removing the new definition; + previously configured programs remain registered during rollback. + Snapshot selectors, Relay preflight, readiness and retention use that same binding. See the complete [Supervisor installation/adoption procedure](../docs/installation_en.md#linux-supervisor-installation-and-adoption). Partial process visibility defers retention; it is not permission to delete diff --git a/deploy/install-wrapper.sh b/deploy/install-wrapper.sh index f8093252..04d3b1e3 100755 --- a/deploy/install-wrapper.sh +++ b/deploy/install-wrapper.sh @@ -374,7 +374,12 @@ restart_after_rollback() { done return 1 else - linux_service stop + linux_service stop || return 1 + if [ "$service_had_file" -eq 0 ] && [ ! -e "$service_file" ]; then + # Fresh rollback removed the definition; stop alone leaves a stale + # group loaded in Supervisor. Forget only this transaction's group. + linux_service remove || return 1 + fi fi else systemctl daemon-reload >/dev/null 2>&1 || return 1 diff --git a/deploy/linux_service.py b/deploy/linux_service.py index c7b158de..7c0d9e04 100644 --- a/deploy/linux_service.py +++ b/deploy/linux_service.py @@ -166,6 +166,31 @@ def stop(profile: dict) -> None: require(exc.faultCode in {10, 70}, 'Supervisor could not stop the bound program') +def remove(profile: dict) -> None: + """Forget only a stopped, no-longer-configured group after fresh rollback.""" + require(supervisor_program(profile, optional=True) is None, + 'refusing to remove a configured Supervisor program') + server = rpc(profile) + name = profile['name'] + added, changed, removed = server.reloadConfig()[0] + require(name not in added and name not in changed, + 'Supervisor program reappeared during rollback') + try: + info = server.getProcessInfo(name) + except xmlrpc.client.Fault as exc: + require(exc.faultCode == 10, 'Supervisor could not inspect the rolled-back program') + return # already absent, including a failure before registration + require(name in removed and not info['pid'] and info['statename'] in {'STOPPED', 'EXITED', 'FATAL'}, + 'stop the removed Wrapper before forgetting its Supervisor group') + server.removeProcessGroup(name) + try: + server.getProcessInfo(name) + except xmlrpc.client.Fault as exc: + require(exc.faultCode == 10, 'Supervisor could not verify rolled-back group removal') + return + raise ValueError('rolled-back Supervisor group is still loaded') + + def start(profile: dict) -> None: server = rpc(profile) added, changed, removed = server.reloadConfig()[0] @@ -386,7 +411,7 @@ def verify_health(root: Path, profile: dict) -> None: def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('action', choices=['preflight', 'state', 'stop', 'start', 'render', 'snapshot', 'probe-state', 'health', 'restore']) + parser.add_argument('action', choices=['preflight', 'state', 'stop', 'remove', 'start', 'render', 'snapshot', 'probe-state', 'health', 'restore']) parser.add_argument('--profile', type=Path, required=True) parser.add_argument('--root', type=Path, required=True) parser.add_argument('--destination', type=Path) @@ -403,6 +428,8 @@ def main(argv=None) -> int: verify_health(args.root, profile) elif args.action == 'stop': stop(profile) + elif args.action == 'remove': + remove(profile) elif args.action == 'start': start(profile) elif args.action == 'restore': diff --git a/tests/test_install_commit.py b/tests/test_install_commit.py index a6b13898..05d12ee0 100644 --- a/tests/test_install_commit.py +++ b/tests/test_install_commit.py @@ -14,6 +14,82 @@ ROOT = Path(__file__).resolve().parents[1] +@pytest.mark.parametrize('prior', ['absent', 'stopped', 'running']) +@pytest.mark.parametrize('remove_fails', [False, True]) +def test_supervisor_rollback_restores_registration(tmp_path, prior, remove_fails): + root = tmp_path / 'managed' + 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() + shutil.copyfile(ROOT / 'deploy/atomic_symlink.py', target / 'deploy/atomic_symlink.py') + current = root / 'current' + current.symlink_to(target) + service_file = tmp_path / 'wrapper.conf' + service_file.write_text('new wrapper definition') + backup = tmp_path / 'wrapper.backup' + if prior != 'absent': + previous.mkdir() + backup.write_text('old wrapper definition') + loaded = tmp_path / 'loaded-wrapper' + loaded.write_text('new group') + native = tmp_path / 'independent-service' + native.write_text('unchanged native process') + calls = tmp_path / 'calls' + settings = { + 'system': 'linux', 'service_manager': 'supervisor', 'appdir': str(root), + 'target': str(target), 'adopt_root': '', 'current': str(current), + 'previous': str(previous) if prior != 'absent' else '', + 'service_file': str(service_file), 'service_label': 'wrapper', + 'service_had_file': '0' if prior == 'absent' else '1', + 'service_backup': str(backup) if prior != 'absent' else '', 'service_changed': '1', + 'service_stopped': '0', 'service_was_running': '1' if prior == 'running' else '0', + 'stage': '', 'device_backup': '', 'device_changed': '0', 'unit_verify_dir': '', + 'rollback_snapshot': '', 'snapshot_created': '0', 'switched': '1', + 'activation_committed': '0', 'retention_generation': '', + 'test_loaded': str(loaded), 'test_calls': str(calls), + 'test_remove_fails': '1' if remove_fails else '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] + harness = 'set -euo pipefail\n' + '\n'.join( + f'{key}={shlex.quote(value)}' for key, value in settings.items() + ) + '\n' + helpers + r''' +linux_service() { + echo "$1" >> "$test_calls" + case "$1" in + restore) cp "$3" "$service_file" ;; + stop) return 0 ;; + remove) + [ ! -e "$service_file" ] || return 1 + [ "$test_remove_fails" -eq 0 ] || return 1 + rm "$test_loaded" ;; + start|state) return 0 ;; + *) return 1 ;; + esac +} +exit 42 +''' + result = subprocess.run(['bash', '-c', harness], capture_output=True, text=True, timeout=10) + assert result.returncode == 42, result.stderr + operations = calls.read_text().splitlines() + if prior == 'absent': + assert not current.exists() and not current.is_symlink() + assert not service_file.exists() + assert loaded.exists() == remove_fails + assert operations == ['stop', 'remove'] + assert ('manual data recovery' in result.stderr) == remove_fails + else: + assert current.resolve() == previous + assert service_file.read_text() == 'old wrapper definition' + assert loaded.exists() + assert operations == (['restore', 'start', 'state'] if prior == 'running' else ['restore', 'stop']) + assert 'code and wrapper data were restored' in result.stderr + assert native.read_text() == 'unchanged native process' + + @pytest.mark.parametrize("previous_install", [False, True]) @pytest.mark.parametrize("phase", ["interrupt-readiness", "success", "fail-output", "retention-failure"]) def test_wrapper_registration_matches_the_activation_commit(tmp_path, previous_install, phase): diff --git a/tests/test_linux_service.py b/tests/test_linux_service.py index db6d567d..f67585b7 100644 --- a/tests/test_linux_service.py +++ b/tests/test_linux_service.py @@ -116,6 +116,74 @@ def stop(name, wait): service.stop(profile) +@pytest.fixture +def fresh_binding(binding): + root, profile = binding + main = Path(profile['supervisor_config']) + main.write_text(main.read_text().split('[program:wrapper]')[0] + + f'[include]\nfiles={root}/fresh.conf\n') + return root, {**profile, 'service_file': str(root / 'fresh.conf')} + + +@pytest.mark.parametrize('loaded', [False, True]) +def test_remove_fresh_group_is_scoped_and_idempotent(fresh_binding, monkeypatch, loaded): + _, profile = fresh_binding + groups = {'wrapper', 'claude-service'} if loaded else {'claude-service'} + calls = [] + + def info(name): + if name not in groups: + raise xmlrpc.client.Fault(10, 'BAD_NAME') + return {'statename': 'STOPPED', 'pid': 0} + + def remove(name): + calls.append(name) + groups.remove(name) + + server = SimpleNamespace( + reloadConfig=lambda: [[['unrelated-added'], ['claude-service'], list(groups)]], + getProcessInfo=info, removeProcessGroup=remove, + ) + monkeypatch.setattr(service, 'rpc', lambda _: server) + service.remove(profile) + service.remove(profile) + assert groups == {'claude-service'} + assert calls == (['wrapper'] if loaded else []) + + +@pytest.mark.parametrize('failure', ['configured', 'running', 'reappeared', 'unreported', + 'reload', 'inspect', 'remove', 'still-loaded']) +def test_remove_fresh_group_refuses_incomplete_rollback(fresh_binding, monkeypatch, failure): + _, profile = fresh_binding + if failure == 'configured': + Path(profile['service_file']).write_text('[program:wrapper]\ncommand=/bin/true\n') + calls = [] + + def reload_config(): + if failure == 'reload': + raise xmlrpc.client.Fault(92, 'CANT_REREAD') + return [[[], ['wrapper'] if failure == 'reappeared' else [], + [] if failure == 'unreported' else ['wrapper']]] + + def info(name): + assert name == 'wrapper' + if failure == 'inspect': + raise xmlrpc.client.Fault(1, 'UNKNOWN_METHOD') + return {'statename': 'RUNNING' if failure == 'running' else 'STOPPED', + 'pid': 123 if failure == 'running' else 0} + + def remove(name): + calls.append(name) + if failure == 'remove': + raise xmlrpc.client.Fault(91, 'STILL_RUNNING') + + monkeypatch.setattr(service, 'rpc', lambda _: SimpleNamespace( + reloadConfig=reload_config, getProcessInfo=info, removeProcessGroup=remove)) + with pytest.raises((ValueError, xmlrpc.client.Fault)): + service.remove(profile) + assert calls == (['wrapper'] if failure in {'remove', 'still-loaded'} else []) + + def test_unix_transport_preserves_authentication(): transport = service.UnixTransport('/run/fixture.sock') connection = transport.make_connection('fixture:password@localhost') From 896960840d8fc72e84ab28301d51178f88817bba Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 12:45:06 +0800 Subject: [PATCH 6/9] fix(deploy): support older Supervisor config responses - Accept omitted directory and UID fields while preserving native active-config comparison. - Cover fresh installation, adoption, updates and unapplied configuration changes. - Document compatibility verified against Supervisor 4.2.1 through 4.3.0. --- deploy/README.md | 6 ++- deploy/linux_service.py | 12 ++++-- docs/installation.md | 2 + docs/installation_en.md | 3 ++ tests/test_linux_service.py | 75 +++++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 8b177a52..6321d65b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -310,7 +310,11 @@ Quarantine is temporary transaction state, not another retained backup or Trash. root-owned non-replaceable code/config paths. Never infer arbitrary launchers: first adoption requires `--adopt-supervisor` and operator-reconciled environment selectors. Only replace/reload that program; preserve other sections and the - independent Claude service. A failed fresh installation removes its newly + independent Claude service. Preflight supports older Supervisor RPC responses + (including 4.2.1/4.2.4) without the directory/uid fields added in 4.2.5. Always + require the native `reloadConfig` comparison against active groups to report + no changes for the bound program; a reread does not apply those changes. + A failed fresh installation removes its newly loaded Supervisor group after stopping it and removing the new definition; previously configured programs remain registered during rollback. Snapshot selectors, Relay preflight, readiness and diff --git a/deploy/linux_service.py b/deploy/linux_service.py index 7c0d9e04..c2662c97 100644 --- a/deploy/linux_service.py +++ b/deploy/linux_service.py @@ -334,10 +334,16 @@ def preflight(root: Path, profile: dict, *, adopt: bool = False) -> None: require(expected_user == ('root' if managed else profile['user']), 'Supervisor Wrapper user mismatch') configs = [x for x in server.getAllConfigInfo() if x['name'] == profile['name']] require(len(configs) == 1 and configs[0]['group'] == profile['name'] - and shlex.split(configs[0]['command']) == command - and configs[0]['directory'] == program.get('directory', 'none') - and configs[0]['uid'] == (pwd.getpwnam(expected_user).pw_uid if 'user' in program else 'none'), + and shlex.split(configs[0]['command']) == command, 'loaded Supervisor config differs from disk; reconcile it before updating') + expected = {'directory': program.get('directory', 'none'), + 'uid': pwd.getpwnam(expected_user).pw_uid if 'user' in program else 'none'} + require(all(key not in configs[0] or configs[0][key] == value for key, value in expected.items()), + 'loaded Supervisor config differs from disk; reconcile it before updating') + # Before 4.2.5 getAllConfigInfo omits directory/uid. The native comparison + # below still checks both against the active process-group configuration + # (even for stopped programs). It reads config without applying changes; + # never skip it just because the fields above are absent or match. changes = server.reloadConfig()[0] require(not any(profile['name'] in group for group in changes), 'Supervisor program has unapplied configuration changes; reconcile it before updating') diff --git a/docs/installation.md b/docs/installation.md index 55fc8d70..f981910e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -190,6 +190,8 @@ Claude 服务,不复制原生会话。自定义命令、额外 systemd drop-in 当前源码支持已有 Supervisor 管理的 Wrapper,包括没有 systemd 的 Linux 容器。 **v4.0.9 不包含此功能**;必须先使用包含该改动的安装包完成一次安装/接管,之后直接 执行 `cc-remote update`。无需安装 systemd,也不负责启动 Supervisor 守护进程或重建镜像。 +兼容 Supervisor 4.2.1/4.2.4 的旧接口,仍会校验目标服务的实际配置;若磁盘配置有尚未 +应用的变更,安装器会要求先核对,不会跳过检查或自动重启其他服务。 先确认 Supervisor 已以 root 运行并配置本地 Unix 管理 socket;显式指定实际配置文件, 不依赖 `supervisorctl` 的默认搜索路径。准备 root 所有、0600 的外部 Wrapper 环境文件, diff --git a/docs/installation_en.md b/docs/installation_en.md index 52f72bf5..81ae45b2 100644 --- a/docs/installation_en.md +++ b/docs/installation_en.md @@ -231,6 +231,9 @@ Current source supports an existing Supervisor, including Linux containers witho systemd. **v4.0.9 does not include this feature**: first install/adopt using a bundle containing it, then use the same `cc-remote update`. The installer does not install systemd, start supervisord, or rebuild a container image. +Older interfaces, including Supervisor 4.2.1/4.2.4, retain the same preflight +checks. Unapplied changes to the selected service must be reconciled first; +the installer does not skip validation or automatically restart other services. Use an existing root-owned Supervisor with a local Unix control socket. Specify its actual configuration explicitly; do not rely on supervisorctl search paths. diff --git a/tests/test_linux_service.py b/tests/test_linux_service.py index f67585b7..f57c7f41 100644 --- a/tests/test_linux_service.py +++ b/tests/test_linux_service.py @@ -125,6 +125,81 @@ def fresh_binding(binding): return root, {**profile, 'service_file': str(root / 'fresh.conf')} +@pytest.fixture +def preflight_binding(fresh_binding): + root, profile = fresh_binding + release = root / 'releases/old' + release.mkdir(parents=True) + (root / 'current').symlink_to(release) + cli = Path(profile['home']) / '.local/bin/claude' + cli.parent.mkdir(parents=True) + cli.write_text('#!/bin/sh\nexit 0\n') + cli.chmod(0o755) + return root, profile + + +@pytest.mark.parametrize('mode', ['fresh', 'adopt', 'update']) +@pytest.mark.parametrize('legacy', [False, True]) +def test_preflight_accepts_old_and_new_supervisor_config_fields(preflight_binding, monkeypatch, mode, legacy): + root, profile = preflight_binding + if mode == 'adopt': + Path(profile['service_file']).write_text('[program:wrapper]\ncommand=/bin/sleep 120\n') + elif mode == 'update': + service.render(root, profile, root / 'unused') + (root / 'installation.json').write_text(json.dumps({'linux_service': profile})) + program = service.supervisor_program(profile, optional=True) + configs = [] if program is None else [dict(name='wrapper', group='wrapper', command=program['command'])] + if configs and not legacy: + configs[0].update(directory=program.get('directory', 'none'), uid=0 if mode == 'update' else 'none') + calls = [] + + def reload_config(): + calls.append('reloadConfig') + return [[['unrelated-added'], ['claude-service'], ['unrelated-removed']]] + + monkeypatch.setattr(service, 'rpc', lambda _: SimpleNamespace( + getAllConfigInfo=lambda: configs, reloadConfig=reload_config, + getProcessInfo=lambda _: {'statename': 'STOPPED', 'pid': 0, 'start': 0})) + before = {path: path.read_bytes() for path in root.rglob('*') if path.is_file()} + service.preflight(root, profile, adopt=mode == 'adopt') + assert calls == ([] if mode == 'fresh' else ['reloadConfig']) + assert all(path.read_bytes() == content for path, content in before.items()) + + +@pytest.mark.parametrize('legacy', [False, True]) +@pytest.mark.parametrize('change', ['added', 'changed', 'removed', 'fault']) +def test_preflight_requires_native_diff_even_when_rpc_fields_match_or_are_absent( + preflight_binding, monkeypatch, legacy, change, +): + root, profile = preflight_binding + service.render(root, profile, root / 'unused') + config = dict(name='wrapper', group='wrapper', command=service.supervisor_program(profile)['command']) + if not legacy: + config.update(directory=str(root / 'current'), uid=0) + + def reload_config(): + if change == 'fault': + raise xmlrpc.client.Fault(92, 'CANT_REREAD') + return [[['wrapper'] if change == name else [] for name in ('added', 'changed', 'removed')]] + + monkeypatch.setattr(service, 'rpc', lambda _: SimpleNamespace( + getAllConfigInfo=lambda: [config], reloadConfig=reload_config)) + with pytest.raises((ValueError, xmlrpc.client.Fault), match='unapplied|CANT_REREAD'): + service.preflight(root, profile) + + +@pytest.mark.parametrize('field,value', [('directory', '/wrong'), ('uid', 12345)]) +def test_preflight_checks_identity_fields_when_supervisor_exposes_them(preflight_binding, monkeypatch, field, value): + root, profile = preflight_binding + service.render(root, profile, root / 'unused') + config = dict(name='wrapper', group='wrapper', command=service.supervisor_program(profile)['command'], + directory=str(root / 'current'), uid=0) + config[field] = value + monkeypatch.setattr(service, 'rpc', lambda _: SimpleNamespace(getAllConfigInfo=lambda: [config])) + with pytest.raises(ValueError, match='differs from disk'): + service.preflight(root, profile) + + @pytest.mark.parametrize('loaded', [False, True]) def test_remove_fresh_group_is_scoped_and_idempotent(fresh_binding, monkeypatch, loaded): _, profile = fresh_binding From 2523c13f1061ef6ad60bb38ce50adc6f539c7d73 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 14:04:18 +0800 Subject: [PATCH 7/9] fix(deploy): revalidate service dependencies before retirement - Persist service discovery context and rescan before quarantine and permanent removal. - Stop cleanup and restore intact artifacts when dependencies change or discovery fails. - Cover concurrent definition, environment, load-path and alias changes with regression tests. --- deploy/README.md | 16 +++- deploy/cleanup.py | 30 ++++++- deploy/release_retention.py | 2 + tests/test_deploy_cleanup.py | 9 ++ tests/test_release_retention.py | 142 ++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 6321d65b..0a81e37f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -160,6 +160,11 @@ intermediate links owned by an old release. Those releases retain their runtime dependency closure. Broken/cyclic links, unreadable paths, bare executable names or ambiguous path expressions defer cleanup; discovery never executes a service or recursively crawls arbitrary external directories. +The generated inventory also stores the service discovery context. Cleanup +rediscovers load paths, definitions, environment files and aliases before each +quarantine rename and permanent deletion, after any artifact tree walk. A newly +referenced candidate or incomplete scan stops the transaction and restores all +still-intact quarantined artifacts; regenerate the inventory before retrying. Failed/incomplete acceptance or insufficient process visibility retains files and warns without rolling back an already committed installation. A same-version @@ -208,6 +213,7 @@ Inventory schema (replace the illustrative paths): "/opt/cc-remote/rollback-data/before-new" ], "protected_paths": [], + "service_context": {"home": "/home/cc-remote"}, "cleanup_roots": ["/opt/cc-remote/releases"], "candidates": ["/opt/cc-remote/releases/release-old"], "transactions": [ @@ -228,6 +234,12 @@ accept only completed states (`committed`, `complete`, `deployed_verified`, `ready`), and the records must remain unchanged throughout cleanup. Unknown layouts remain retained until their provenance is established. +`service_context` enables repeated dormant-service discovery during cleanup. +Use the actual service HOME and, for Supervisor, include the registered +`linux_service` binding in that object. Managed installers always supply it; +older manual inventories without this optional field retain their static +`protected_paths` contract and must be refreshed/reviewed by the operator. + `checks` are operator-reviewed **read-only** argv arrays (no shell interpolation), run before retirement, after quarantine, and after removal. They must cover the actual role's release identity, stable services and health. For a Codex Wrapper, @@ -257,8 +269,8 @@ Preview does not run acceptance commands or remove artifacts. Apply rescans live references immediately before each rename and permanent deletion. Candidates are temporarily renamed beside their original path; a failed quarantine check restores the intact directory. A new live reference defers deletion and restores that path. -Checks are observations, not a lock on arbitrary external programs: operators must -not launch jobs against retired paths during cleanup. +Checks are observations, not a lock on external programs or configuration edits: +operators must not launch jobs or repoint services at retired paths during cleanup. The private `/.cleanup-transaction.json` records intent before each mutation. Exit 0 means success (or preview), 2 means applied cleanup completed diff --git a/deploy/cleanup.py b/deploy/cleanup.py index 7a08ebf6..dba87509 100644 --- a/deploy/cleanup.py +++ b/deploy/cleanup.py @@ -76,9 +76,21 @@ def load_inventory(path: Path) -> dict: data, digest = read_json(path) required = {"schema", "installation_root", "current_release", "rollback_paths", "cleanup_roots", "candidates", "transactions", "checks"} - if (data.keys() - required - {"protected_paths"} or required - data.keys() + if (data.keys() - required - {"protected_paths", "service_context"} or required - data.keys() or data["schema"] != 1): raise CleanupError("invalid cleanup inventory schema") + if "service_context" in data: + context = data["service_context"] + if (not isinstance(context, dict) or "home" not in context + or context.keys() - {"home", "linux_service"}): + raise CleanupError("invalid service discovery context") + if not absolute_path(context["home"]).is_dir(): + raise CleanupError("service discovery HOME is unavailable") + if "linux_service" in context: + from deploy.linux_service import validate + validate(context["linux_service"]) + if context["linux_service"]["home"] != context["home"]: + raise CleanupError("service discovery HOME differs from the service binding") for key in ["rollback_paths", "cleanup_roots", "candidates", "transactions", "checks"]: if not isinstance(data[key], list) or not 0 < len(data[key]) <= 128: raise CleanupError(f"{key} must be a nonempty bounded list") @@ -299,6 +311,15 @@ def protections(plan: dict, paths: list[Path]) -> dict[Path, list[str]]: for candidate in paths: if overlaps(target, candidate): found[candidate].append("symlink dependency of retained artifact") + if context := plan.get("service_context"): + from deploy.release_retention import service_dependencies + # Rediscover load paths, definitions, environment files and aliases, not + # just files from the initial inventory. Include both original and + # quarantined names. Abort on new dependencies so all intact artifacts + # can be restored together, including a newly needed runtime closure. + dependencies = service_dependencies(context, list(found)) + if any(not any(overlaps(Path(p), kept) for kept in plan["protected"]) for p in dependencies): + raise CleanupError("service dependencies changed; regenerate the cleanup inventory") return {p: found[p] for p in paths} @@ -405,6 +426,10 @@ def cleanup(inventory: Path, *, apply: bool = False, lock_descriptor: int | None report["status"] = "removing" write_journal(journal, report) for path, quarantine, row in moved: + # Complete the potentially long tree walk before the final live + # process/service dependency check, not between that check and rm. + for _ in artifact_entries(quarantine): + pass guarded = protections(plan, [path, quarantine]) reasons = guarded[path] + guarded[quarantine] if reasons: @@ -415,9 +440,6 @@ def cleanup(inventory: Path, *, apply: bool = False, lock_descriptor: int | None else: if list(identity(quarantine)) != row["identity"]: raise CleanupError("quarantined artifact changed identity") - # Recheck for mounts before a recursive, symlink-safe removal. - for _ in artifact_entries(quarantine): - pass parent = os.open(absolute_path(quarantine.parent.as_posix()), os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: diff --git a/deploy/release_retention.py b/deploy/release_retention.py index 6d02dc01..8ecaaf78 100644 --- a/deploy/release_retention.py +++ b/deploy/release_retention.py @@ -536,8 +536,10 @@ def inventory(root: Path, data: dict, row: dict) -> dict | None: python = str(Path(row["release"]) / ".venv/bin/python") check = [python, "-B", str(Path(row["release"]) / "deploy/release_retention.py"), "verify", "--root", str(root), "--generation", row["id"]] + service_context = {key: row[key] for key in ("home", "linux_service") if key in row} return {"schema": 1, "installation_root": str(root), "current_release": row["release"], "rollback_paths": rollback, "protected_paths": sorted(protected), + "service_context": service_context, "cleanup_roots": [str(root / name) for name in ("releases", BACKUPS, "rollback-data") if (root / name).is_dir()], "candidates": sorted(candidates), "transactions": [{"path": str(root / LEDGER), "field": "phase", "equals": "committed"}], diff --git a/tests/test_deploy_cleanup.py b/tests/test_deploy_cleanup.py index 097b1d8a..d8aa0175 100644 --- a/tests/test_deploy_cleanup.py +++ b/tests/test_deploy_cleanup.py @@ -57,6 +57,15 @@ def test_preview_never_moves_deletes_or_runs_acceptance(installation, monkeypatc assert not (root / module.JOURNAL).exists() +@pytest.mark.parametrize('context', [None, {}, {'home': 'relative'}, {'home': '/', 'unknown': True}]) +def test_invalid_service_discovery_context_never_retires(installation, context): + _, _, _, old, inventory, _ = installation + update_inventory(installation, service_context=context) + with pytest.raises(module.CleanupError): + module.cleanup(inventory, apply=True) + assert (old / 'payload').read_text() == 'must not be lost' + + def test_success_retains_complete_rollback_and_checks_after_removal(installation, monkeypatch): root, current, previous, old, inventory, _ = installation observed = [] diff --git a/tests/test_release_retention.py b/tests/test_release_retention.py index a918b277..018b5d8b 100644 --- a/tests/test_release_retention.py +++ b/tests/test_release_retention.py @@ -200,6 +200,148 @@ def test_three_upgrades_keep_current_and_complete_previous_recovery(fleet): assert not Path(row["backup"]).exists() and not Path(row["snapshot"]).exists() +@pytest.mark.parametrize('when', ['inventory', 'preview', 'before-rename', 'quarantined', 'before-delete']) +@pytest.mark.parametrize('kind', ['systemd', 'launchd', 'supervisor', 'environment', 'optional-environment']) +def test_new_service_dependency_during_retirement_preserves_artifacts(fleet, tmp_path, monkeypatch, when, kind): + import plistlib + from deploy import linux_service + + root, _, activate = fleet + old, _ = activate('first') + previous, _ = activate('second') + current, _ = activate('third') + units = tmp_path / 'units' + units.mkdir() + environment = tmp_path / 'external.env' + if kind in {'environment', 'optional-environment'}: + (units / 'dormant.service').write_text(f'[Service]\nEnvironmentFile=-{environment}\n') + if kind == 'environment': + environment.write_text('WORKER=/bin/true\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units] if kind != 'supervisor' else []) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + if kind == 'supervisor': + # Exercise the saved binding and a newly added include, outside systemd + # discovery, rather than pretending Supervisor is a systemd directory. + main = tmp_path / 'supervisord.conf' + main.write_text(f'[include]\nfiles={units}/late.conf\n') + profile = dict(manager='supervisor', name='wrapper', user='root', home=str(root), + service_file=str(units / 'wrapper.conf'), env_file=str(environment), + device_file=str(tmp_path / 'device.json'), supervisor_config=str(main)) + data = retention.read_ledger(root) + data['generations'][-1]['linux_service'] = profile + cleanup.write_journal(root / retention.LEDGER, data) + monkeypatch.setattr(linux_service, 'trusted', lambda path: None) + injected = False + + def add_reference(): + nonlocal injected + assert not injected + injected = True + if kind == 'systemd': + (units / 'late.service').write_text(f'[Service]\nExecStart={old}/payload\n') + elif kind == 'launchd': + (units / 'late.plist').write_bytes(plistlib.dumps({'ProgramArguments': [str(old / 'payload')]})) + elif kind == 'supervisor': + (units / 'late.conf').write_text(f'[program:late]\ncommand={old}/payload\n') + else: + environment.write_text(f'WORKER={old}/payload\n') + + if when == 'inventory': + original = retention.inventory + + def inventory(*args): + result = original(*args) + add_reference() + return result + + monkeypatch.setattr(retention, 'inventory', inventory) + elif when == 'preview': + original = cleanup.cleanup + + def after_preview(*args, **kwargs): + result = original(*args, **kwargs) + if not kwargs.get('apply'): + add_reference() + return result + + monkeypatch.setattr(cleanup, 'cleanup', after_preview) + elif when == 'before-delete': + original = cleanup.artifact_entries + + def entries(path): + yield from original(path) + if path.name.startswith('.cc-remote-retired-') and not injected: + add_reference() + + monkeypatch.setattr(cleanup, 'artifact_entries', entries) + else: + checks = 0 + + def acceptance(plan): + nonlocal checks + checks += 1 + if checks == (1 if when == 'before-rename' else 2): + add_reference() + + monkeypatch.setattr(cleanup, 'run_checks', acceptance) + with pytest.raises(cleanup.CleanupError, match='service dependencies changed'): + prune(root) + assert injected and (old / 'payload').read_text() == 'installed bytes' + assert previous.is_dir() and current.is_dir() + assert not list(root.rglob('.cc-remote-retired-*')) + plan = json.loads((root / retention.INVENTORY).read_text()) + assert plan['service_context']['home'] == str(root) + if kind == 'supervisor': + assert plan['service_context']['linux_service'] == profile + # No artifact (including old rollback snapshots) may be lost in these races. + assert all(Path(p).exists() for p in plan['candidates']) + + +@pytest.mark.parametrize('failure', ['unreadable', 'new-load-path', 'retargeted-alias']) +def test_service_rediscovery_after_quarantine_retains_runtime_closure(fleet, tmp_path, monkeypatch, failure): + root, _, activate = fleet + runtime, _ = activate('first') + worker, _ = activate('second') + (worker / 'runtime').symlink_to(runtime) + activate('third') + activate('fourth') + units = tmp_path / 'units' + units.mkdir() + extra = tmp_path / 'extra-units' + extra.mkdir() + executable = tmp_path / 'external-worker' + executable.write_text('external') + alias = tmp_path / 'alias' + alias.symlink_to(executable) + if failure == 'retargeted-alias': + (units / 'worker.service').write_text(f'[Service]\nExecStart={alias}\n') + checks = 0 + + def roots(row): + if checks >= 2 and failure == 'unreadable': + raise PermissionError('service discovery incomplete') + return [units, extra] if checks >= 2 else [units] + + def acceptance(plan): + nonlocal checks + checks += 1 + if checks == 2: + if failure == 'retargeted-alias': + alias.unlink() + alias.symlink_to(worker / 'payload') + else: + (extra / 'worker.service').write_text(f'[Service]\nExecStart={worker}/payload\n') + + monkeypatch.setattr(retention, 'service_roots', roots) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + monkeypatch.setattr(cleanup, 'run_checks', acceptance) + with pytest.raises((cleanup.CleanupError, OSError)): + prune(root) + assert runtime.is_dir() and worker.is_dir() + assert (worker / 'runtime').resolve(strict=True) == runtime + assert not list(root.rglob('.cc-remote-retired-*')) + + def test_first_preledger_release_becomes_eligible_only_after_next_success(fleet): root, _, activate = fleet first = root / "releases/pre-ledger" From 83d06ff6560d0094f4b5f1e72d99bc2251e2f546 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 14:37:45 +0800 Subject: [PATCH 8/9] fix(deploy): resolve systemd executable search paths - Resolve bare Exec commands through systemd's native search path and literal service overrides. - Preserve executable aliases and defer cleanup for unresolved or custom lookup contexts. - Cover standard units, dependency retention and lookup changes during cleanup. --- deploy/README.md | 12 ++- deploy/release_retention.py | 88 ++++++++++++++++-- tests/test_release_retention.py | 155 ++++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 8 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 0a81e37f..2fef9de0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -157,9 +157,15 @@ defer cleanup instead of treating an optional file as absent. Literal paths in service definitions, launchd plists and external environment files are resolved component by component, including multi-hop aliases and intermediate links owned by an old release. Those releases retain their runtime -dependency closure. Broken/cyclic links, unreadable paths, bare executable names -or ambiguous path expressions defer cleanup; discovery never executes a service -or recursively crawls arbitrary external directories. +dependency closure. Bare systemd `Exec*` filenames are resolved using the installed +systemd's `systemd-path search-binaries-default`, never the updater's shell PATH. +Discovery also retains a superset of literal `ExecSearchPath` and service `PATH` +overrides across definitions, drop-ins and environment files, following every +matching executable alias. Missing discovery tools, unresolved executables, +custom filesystem namespaces/inherited PATH, broken/cyclic links, unreadable +paths or ambiguous expressions defer cleanup. Supervisor commands still require +absolute paths. Discovery never executes a service or recursively crawls +arbitrary external directories. The generated inventory also stores the service discovery context. Cleanup rediscovers load paths, definitions, environment files and aliases before each quarantine rename and permanent deletion, after any artifact tree walk. A newly diff --git a/deploy/release_retention.py b/deploy/release_retention.py index 8ecaaf78..ad8f70ce 100644 --- a/deploy/release_retention.py +++ b/deploy/release_retention.py @@ -368,6 +368,26 @@ def service_literal_paths(value: str, *, atomic: bool = False) -> set[Path]: return paths +def executable_search_paths(value: str) -> list[Path]: + """Validate a literal systemd search path without using the caller's PATH.""" + require(len(value) <= 65536, 'service executable search path exceeds bound') + names = value.strip().split(':') + require(0 < len(names) <= 256, 'service executable search path is incomplete') + paths = [] + for name in names: + paths.extend(service_literal_paths(name, atomic=True)) + return paths + + +def systemd_executable_paths() -> list[Path]: + # systemd.service(5): bare Exec* filenames use the installed systemd's + # compile-time search path, which is not necessarily the updater's PATH. + return executable_search_paths(command([ + 'env', '-i', 'PATH=/usr/sbin:/usr/bin:/sbin:/bin', + 'systemd-path', 'search-binaries-default', + ])) + + def service_path_dependencies(path: Path, candidates: list[Path]) -> set[str]: """Follow literal path components without losing intermediate symlink owners. @@ -434,6 +454,15 @@ def walk_error(error: OSError) -> None: files.update(Path(profile[key]) for key in ('env_file', 'device_file') if Path(profile[key]).exists()) total = 0 paths_seen = set() + executable_names = set() + search_paths = set() + custom_namespace = False + + def search_path(value: str) -> None: + search_paths.update(executable_search_paths(value)) + require(len(search_paths) <= 256, 'service executable search path exceeds bound') + for directory in search_paths: + protect(directory) def protect(path: Path) -> None: if path not in paths_seen: @@ -458,7 +487,7 @@ def inspect_plist(value, key: str = '') -> None: }) def inspect(path: Path) -> bytes: - nonlocal total + nonlocal total, custom_namespace protect(path) target = path.resolve(strict=True) if str(target) == "/dev/null": # masked systemd unit @@ -475,12 +504,41 @@ def inspect(path: Path) -> bytes: else: for line in service_lines(raw): key, separator, value = line.partition(b'=') + key = key.strip() + text = value.strip().decode('utf-8') if key.strip() == b'EnvironmentFile': continue # exact single-filename handling below - if (key.strip().startswith(b'Exec') or key.strip() == b'command') and value.strip(): - words = shlex.split(value.decode('utf-8')) - require(bool(words) and words[0].lstrip('-@:+!').startswith('/'), - 'nonliteral service executable requires explicit retention inventory') + if key in {b'RootDirectory', b'RootImage', b'BindPaths', b'BindReadOnlyPaths', + b'TemporaryFileSystem'} and text: + custom_namespace = True + if key == b'PassEnvironment' and 'PATH' in shlex.split(text): + custom_namespace = True + if key == b'ExecSearchPath': + if text: # keep a superset across resets and drop-ins + search_path(text) + continue + if key == b'PATH': # external EnvironmentFile + words = shlex.split(text) + require(len(words) == 1, 'ambiguous service PATH requires explicit retention inventory') + search_path(words[0]) + continue + if key == b'Environment': + for assignment in shlex.split(text): + if assignment.startswith('PATH='): + search_path(assignment.removeprefix('PATH=')) + else: + inspect_value(assignment) + continue + if key in {b'ExecCondition', b'ExecStartPre', b'ExecStart', b'ExecStartPost', + b'ExecReload', b'ExecStop', b'ExecStopPost', b'command'} and text: + words = shlex.split(text) + require(bool(words), 'service executable is missing') + executable = words[0].lstrip('-@:+!') + if not executable.startswith('/'): + require(key != b'command' and re.fullmatch(r'[A-Za-z0-9_+.-]+', executable) + and executable not in {'.', '..'} and '\\' not in text, + 'nonliteral service executable requires explicit retention inventory') + executable_names.add(executable) inspect_value((value if separator else line).decode('utf-8')) for candidate in candidates: if str(candidate).encode() in raw or target.is_relative_to(candidate): @@ -493,6 +551,26 @@ def inspect(path: Path) -> bytes: if optional and not env.exists() and not env.is_symlink(): continue inspect(env) + if executable_names: + require(not custom_namespace, + 'custom service executable namespace requires explicit retention inventory') + # Include all literal overrides from definitions, drop-ins and external + # environment files. Keeping a superset avoids guessing unit precedence + # or access permissions for dormant service users. Recomputed on every + # cleanup revalidation, including aliases in an earlier search directory. + search_paths.update(systemd_executable_paths()) + require(len(search_paths) <= 256, 'service executable search path exceeds bound') + for name in executable_names: + resolved = False + for directory in search_paths: + executable = directory / name + protect(executable) + try: + info = executable.stat() + except FileNotFoundError: + continue + resolved |= stat.S_ISREG(info.st_mode) and bool(info.st_mode & 0o111) + require(resolved, 'unresolved service executable requires explicit retention inventory') return sorted(found) diff --git a/tests/test_release_retention.py b/tests/test_release_retention.py index 018b5d8b..499ce3d4 100644 --- a/tests/test_release_retention.py +++ b/tests/test_release_retention.py @@ -608,6 +608,161 @@ def test_unrelated_service_alias_allows_retirement(fleet, tmp_path, monkeypatch) assert not old.exists() and alias.resolve(strict=True) == worker +@pytest.fixture +def executable_units(tmp_path, monkeypatch): + units = tmp_path / 'units' + units.mkdir() + paths = [tmp_path / 'local-bin', tmp_path / 'bin'] + for directory in paths: + directory.mkdir() + calls = [] + + def discover(argv): + calls.append(argv) + assert argv == ['env', '-i', 'PATH=/usr/sbin:/usr/bin:/sbin:/bin', + 'systemd-path', 'search-binaries-default'] + return ':'.join(map(str, paths)) + '\n' + + monkeypatch.setattr(retention, 'command', discover) + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + return units, paths, calls + + +@pytest.mark.parametrize('directive,prefix', [ + ('ExecStart', ''), ('ExecStartPre', '-'), ('ExecStartPost', '+'), + ('ExecCondition', ':'), ('ExecReload', '@'), ('ExecStop', '!'), ('ExecStopPost', '-@'), +]) +def test_systemd_bare_executable_allows_retirement( + fleet, executable_units, tmp_path, monkeypatch, directive, prefix, +): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units, paths, calls = executable_units + binary = paths[-1] / 'systemd-tmpfiles' + binary.write_text('must never execute the discovered service') + binary.chmod(0o755) + ambient = tmp_path / 'updater-bin' + ambient.mkdir() + (ambient / binary.name).symlink_to(old / 'payload') + monkeypatch.setenv('PATH', str(ambient)) + (units / 'tmpfiles.service').write_text( + f'[Service]\n{directive} = {prefix}{binary.name} --create --remove --boot\n') + assert prune(root)['status'] == 'complete' + assert not old.exists() and binary.exists() + assert len(calls) > 1, 'each destructive boundary rediscovers the native search path' + + +@pytest.mark.parametrize('lookup', ['default', 'drop-in', 'environment', 'environment-file']) +def test_systemd_bare_executable_alias_keeps_release(fleet, executable_units, tmp_path, lookup): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + (old / 'payload').chmod(0o755) + units, paths, _ = executable_units + custom = tmp_path / 'custom-bin' + custom.mkdir() + directory = paths[0] if lookup == 'default' else custom + (directory / 'worker').symlink_to(old / 'payload') + # A second match must not hide an alias used under a different service + # user, search-path override, or effective drop-in precedence. + (paths[-1] / 'worker').write_text('unrelated binary') + (paths[-1] / 'worker').chmod(0o755) + config = '[Service]\nExecStart=-@worker worker --serve\n' + if lookup == 'drop-in': + drop_in = units / 'worker.service.d' + drop_in.mkdir() + (drop_in / 'path.conf').write_text(f'[Service]\nExecSearchPath={custom}:{paths[-1]}\n') + elif lookup == 'environment': + config += f'Environment="PATH={custom}:{paths[-1]}"\n' + elif lookup == 'environment-file': + env = tmp_path / 'worker.env' + env.write_text(f'PATH="{custom}:{paths[-1]}"\n') + config += f'EnvironmentFile = {env}\n' + (units / 'worker.service').write_text(config) + assert prune(root)['status'] == 'deferred' + assert old.is_dir() and (directory / 'worker').resolve(strict=True).exists() + + +@pytest.mark.parametrize('change', ['alias', 'search-path']) +def test_bare_executable_rediscovery_restores_quarantined_artifacts( + fleet, executable_units, tmp_path, monkeypatch, change, +): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units, paths, _ = executable_units + worker = paths[-1] / 'worker' + worker.write_text('unrelated binary') + worker.chmod(0o755) + (units / 'worker.service').write_text('[Service]\nExecStart=worker\n') + checks = 0 + + def acceptance(plan): + nonlocal checks + checks += 1 + if checks == 2: + directory = paths[0] + if change == 'search-path': + directory = tmp_path / 'new-systemd-bin' + directory.mkdir() + paths.insert(0, directory) + (directory / 'worker').symlink_to(old / 'payload') + + monkeypatch.setattr(cleanup, 'run_checks', acceptance) + with pytest.raises((cleanup.CleanupError, OSError)): + prune(root) + plan = json.loads((root / retention.INVENTORY).read_text()) + assert checks == 2 and all(Path(p).exists() for p in plan['candidates']) + assert not list(root.rglob('.cc-remote-retired-*')) + + +@pytest.mark.parametrize('failure', [ + 'discovery', 'empty-path', 'relative-path', 'missing', 'non-executable', 'broken', + 'custom-root', 'inherited-path', 'relative-command', 'shell-prefix', 'supervisor', +]) +def test_unresolved_bare_executable_never_deletes(fleet, executable_units, monkeypatch, failure): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units, paths, _ = executable_units + worker = paths[0] / 'worker' + worker.write_text('binary') + worker.chmod(0o755) + config = '[Service]\nExecStart=worker\n' + if failure == 'discovery': + def unavailable(argv): + raise OSError('systemd-path unavailable') + monkeypatch.setattr(retention, 'command', unavailable) + elif failure in {'empty-path', 'relative-path'}: + monkeypatch.setattr(retention, 'command', lambda argv: '' if failure == 'empty-path' else 'relative/bin') + elif failure in {'missing', 'broken'}: + worker.unlink() + if failure == 'broken': + worker.symlink_to(old / 'missing') + elif failure == 'non-executable': + worker.chmod(0o644) + elif failure == 'custom-root': + config += f'RootDirectory={paths[0]}\n' + elif failure == 'inherited-path': + config += 'PassEnvironment=PATH\n' + elif failure == 'relative-command': + config = '[Service]\nExecStart=bin/worker\n' + elif failure == 'shell-prefix': + config = '[Service]\nExecStart=|worker\n' + elif failure == 'supervisor': + config = '[program:worker]\ncommand=worker\n' + (units / 'worker.service').write_text(config) + with pytest.raises((ValueError, OSError)): + prune(root) + assert old.is_dir() + + def test_dormant_alias_preserves_transitive_release_dependencies(fleet, tmp_path, monkeypatch): root, _, activate = fleet runtime, _ = activate('first') From c1eb2e8da3153fab1e2b925485f526c0c5d5abc4 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 8 Oct 2026 15:19:43 +0800 Subject: [PATCH 9/9] fix(deploy): defer retention for indirect service commands - Validate interpreter and launcher payloads across systemd, Supervisor and launchd. - Preserve releases when command lookup is unresolved, including aliases and concurrent service changes. - Cover inherited PATH regressions and document conservative retention limits. --- deploy/README.md | 12 ++- deploy/release_retention.py | 63 ++++++++++++++-- tests/test_release_retention.py | 130 ++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 7 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 2fef9de0..cfd9513c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -164,8 +164,16 @@ overrides across definitions, drop-ins and environment files, following every matching executable alias. Missing discovery tools, unresolved executables, custom filesystem namespaces/inherited PATH, broken/cyclic links, unreadable paths or ambiguous expressions defer cleanup. Supervisor commands still require -absolute paths. Discovery never executes a service or recursively crawls -arbitrary external directories. +absolute paths. An absolute interpreter is not proof of a literal payload: +shell command strings, inline code, module lookup (including Python `-m`), +relative scripts and known command launchers such as `env`/`nohup` defer cleanup. +This guard also checks executable aliases and launchd `ProgramArguments`; it +never substitutes the updater's PATH for a service manager's inherited PATH. +Direct absolute script arguments remain literal dependencies, including the +installer's isolated Python `wrapper_exec.py` command. Hosts with unresolved +indirect commands need an explicitly reviewed retention inventory; a successful +update can therefore retain more than one previous generation. Discovery never +executes a service or recursively crawls arbitrary external directories. The generated inventory also stores the service discovery context. Cleanup rediscovers load paths, definitions, environment files and aliases before each quarantine rename and permanent deletion, after any artifact tree walk. A newly diff --git a/deploy/release_retention.py b/deploy/release_retention.py index ad8f70ce..0f3db538 100644 --- a/deploy/release_retention.py +++ b/deploy/release_retention.py @@ -388,6 +388,45 @@ def systemd_executable_paths() -> list[Path]: ])) +def literal_service_command(executable: Path, arguments: list[str]) -> None: + """Do not mistake an interpreter/launcher for its indirectly selected payload. + + The service manager's inherited environment and interpreter/module lookup + are not established by scanning literal paths. Never execute the command to + discover them. Check both the configured name and the symlink target, since + an alias must not bypass this guard. + """ + names = {executable.name, executable.resolve().name} + launchers = { + 'env', 'sudo', 'doas', 'su', 'runuser', 'chroot', 'chpst', 'setpriv', + 'setsid', 'nohup', 'timeout', 'nice', 'ionice', 'stdbuf', 'taskset', + 'flock', 'xargs', 'systemd-run', 'start-stop-daemon', 'daemon', 'exec', + 'busybox', 'toybox', 'java', 'dotnet', 'deno', 'pwsh', 'powershell', + 'awk', 'gawk', 'mawk', 'nawk', 'R', 'Rscript', + } + shells = {'sh', 'bash', 'dash', 'ash', 'ksh', 'ksh93', 'zsh', 'fish', 'csh', 'tcsh'} + python = any(re.fullmatch(r'(?:python|pypy)(?:\d+(?:\.\d+)*)?t?', name) for name in names) + interpreter = python or bool(names & shells) or any( + re.fullmatch(r'(?:node|nodejs|bun|perl|ruby|php|lua|luajit|tclsh|wish)(?:\d+(?:\.\d+)*)?', name) + for name in names + ) + message = 'indirect service command requires explicit retention inventory' + require(not names & launchers, message) + if not interpreter: + return + # A direct absolute script is still discovered by the literal-path scan. + # Only recognize a small set of argument-free Python flags, including the + # isolated wrapper_exec.py invocation generated by our installer. Inline + # code, modules, stdin, relative scripts and unknown options defer cleanup. + remaining = list(arguments) + if python: + while remaining and remaining[0] in {'-I', '-B', '-E', '-s', '-S', '-u', '-O', '-OO'}: + remaining.pop(0) + if remaining and remaining[0] == '--': + remaining.pop(0) + require(bool(remaining) and remaining[0].startswith('/'), message) + + def service_path_dependencies(path: Path, candidates: list[Path]) -> set[str]: """Follow literal path components without losing intermediate symlink owners. @@ -454,7 +493,7 @@ def walk_error(error: OSError) -> None: files.update(Path(profile[key]) for key in ('env_file', 'device_file') if Path(profile[key]).exists()) total = 0 paths_seen = set() - executable_names = set() + executable_commands = set() search_paths = set() custom_namespace = False @@ -476,6 +515,14 @@ def inspect_value(value: str, *, atomic: bool = False) -> None: def inspect_plist(value, key: str = '') -> None: if isinstance(value, dict): + if 'Program' in value or 'ProgramArguments' in value: + arguments = value.get('ProgramArguments', []) + require(isinstance(arguments, list) and all(isinstance(arg, str) for arg in arguments), + 'invalid service program arguments') + executable = value.get('Program') or (arguments[0] if arguments else '') + require(isinstance(executable, str) and executable.startswith('/'), + 'nonliteral service executable requires explicit retention inventory') + literal_service_command(Path(executable), arguments[1:]) for child_key, child in value.items(): inspect_plist(child, child_key) elif isinstance(value, list): @@ -534,11 +581,15 @@ def inspect(path: Path) -> bytes: words = shlex.split(text) require(bool(words), 'service executable is missing') executable = words[0].lstrip('-@:+!') + prefixes = words[0][:len(words[0]) - len(executable)] + arguments = words[2:] if key != b'command' and '@' in prefixes else words[1:] if not executable.startswith('/'): require(key != b'command' and re.fullmatch(r'[A-Za-z0-9_+.-]+', executable) and executable not in {'.', '..'} and '\\' not in text, 'nonliteral service executable requires explicit retention inventory') - executable_names.add(executable) + executable_commands.add((executable, tuple(arguments))) + else: + literal_service_command(Path(executable), arguments) inspect_value((value if separator else line).decode('utf-8')) for candidate in candidates: if str(candidate).encode() in raw or target.is_relative_to(candidate): @@ -551,7 +602,7 @@ def inspect(path: Path) -> bytes: if optional and not env.exists() and not env.is_symlink(): continue inspect(env) - if executable_names: + if executable_commands: require(not custom_namespace, 'custom service executable namespace requires explicit retention inventory') # Include all literal overrides from definitions, drop-ins and external @@ -560,7 +611,7 @@ def inspect(path: Path) -> bytes: # cleanup revalidation, including aliases in an earlier search directory. search_paths.update(systemd_executable_paths()) require(len(search_paths) <= 256, 'service executable search path exceeds bound') - for name in executable_names: + for name, arguments in executable_commands: resolved = False for directory in search_paths: executable = directory / name @@ -569,7 +620,9 @@ def inspect(path: Path) -> bytes: info = executable.stat() except FileNotFoundError: continue - resolved |= stat.S_ISREG(info.st_mode) and bool(info.st_mode & 0o111) + if stat.S_ISREG(info.st_mode) and info.st_mode & 0o111: + literal_service_command(executable, list(arguments)) + resolved = True require(resolved, 'unresolved service executable requires explicit retention inventory') return sorted(found) diff --git a/tests/test_release_retention.py b/tests/test_release_retention.py index 499ce3d4..1a190deb 100644 --- a/tests/test_release_retention.py +++ b/tests/test_release_retention.py @@ -608,6 +608,46 @@ def test_unrelated_service_alias_allows_retirement(fleet, tmp_path, monkeypatch) assert not old.exists() and alias.resolve(strict=True) == worker +@pytest.mark.parametrize('kind', ['systemd', 'supervisor', 'launchd', 'launchd-program']) +def test_indirect_service_command_never_deletes(fleet, tmp_path, monkeypatch, kind): + import plistlib + import subprocess + + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + # This directory exists only in the service manager's environment. The + # updater cannot infer its PATH from its own environment or unit text. + inherited_bin = tmp_path / 'manager-bin' + inherited_bin.mkdir() + (old / 'payload').write_text('#!/bin/sh\nexit 0\n') + (old / 'payload').chmod(0o755) + (inherited_bin / 'worker').symlink_to(old / 'payload') + monkeypatch.setenv('PATH', '/usr/bin:/bin') + manager_env = {**os.environ, 'PATH': f'{inherited_bin}:/usr/bin:/bin'} + subprocess.run(['/bin/sh', '-c', 'worker'], env=manager_env, check=True) + units = tmp_path / 'units' + units.mkdir() + if kind.startswith('launchd'): + payload = {'ProgramArguments': ['/bin/sh', '-c', 'worker']} + if kind == 'launchd-program': + payload['Program'] = '/bin/sh' + payload['ProgramArguments'][0] = 'custom-argv-zero' + (units / 'worker.plist').write_bytes(plistlib.dumps(payload)) + elif kind == 'supervisor': + (units / 'worker.conf').write_text('[program:worker]\ncommand=/bin/sh -c worker\n') + else: + (units / 'worker.service').write_text('[Service]\nExecStart=/bin/sh -c worker\n') + monkeypatch.setattr(retention, 'service_roots', lambda row: [units]) + monkeypatch.setattr(retention, 'service_dependencies', SERVICE_DEPENDENCIES) + with pytest.raises(ValueError, match='indirect service command'): + prune(root) + assert old.is_dir() and (inherited_bin / 'worker').resolve(strict=True).exists() + assert not list(root.rglob('.cc-remote-retired-*')) + subprocess.run(['/bin/sh', '-c', 'worker'], env=manager_env, check=True) + + @pytest.fixture def executable_units(tmp_path, monkeypatch): units = tmp_path / 'units' @@ -763,6 +803,96 @@ def unavailable(argv): assert old.is_dir() +@pytest.mark.parametrize('command', [ + '/bin/sh -c worker', '/bin/bash -lc worker', '-@/bin/sh custom-name -c worker', + '/usr/bin/env worker', '/usr/bin/env -S "sh -c worker"', + '/usr/bin/nohup worker', '/usr/bin/nice -n 5 worker', '/usr/bin/timeout 10 worker', + '/usr/bin/busybox sh -c worker', '/usr/bin/python3.13 -c worker', + '/usr/bin/python3 -I -m worker', '/usr/bin/node --eval worker', + '/usr/bin/perl -e worker', '/usr/bin/ruby -e worker', + '/bin/bash worker', '/bin/sh', '/usr/bin/python3 -', + 'shell-alias -c worker', '{alias} -c worker', +]) +def test_interpreter_and_launcher_indirection_defers_retirement( + fleet, executable_units, tmp_path, command, +): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units, paths, _ = executable_units + shell = tmp_path / 'sh' + shell.write_text('must never execute the service command') + shell.chmod(0o755) + alias = paths[0] / 'shell-alias' + alias.symlink_to(shell) + (units / 'worker.service').write_text( + '[Service]\nExecStart=' + command.format(alias=alias) + '\n') + with pytest.raises(ValueError, match='indirect service command'): + prune(root) + assert old.is_dir() + + +@pytest.mark.parametrize('command', [ + '/bin/sh {script}', '/bin/bash -- {script}', + '/usr/bin/python3 -I -B {script}', '/usr/bin/node {script}', + '/bin/true -c worker', +]) +def test_literal_service_payloads_still_allow_retirement( + fleet, executable_units, tmp_path, command, +): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units, _, _ = executable_units + script = tmp_path / 'worker-script' + script.write_text('must never execute this script') + (units / 'worker.service').write_text( + '[Service]\nExecStart=' + command.format(script=script) + '\n') + assert prune(root)['status'] == 'complete' + assert not old.exists() and script.exists() + + +def test_direct_interpreter_script_alias_keeps_release(fleet, executable_units, tmp_path): + root, _, activate = fleet + old, _ = activate('first') + activate('second') + activate('third') + units, _, _ = executable_units + script = tmp_path / 'worker-script' + script.symlink_to(old / 'payload') + (units / 'worker.service').write_text(f'[Service]\nExecStart=/usr/bin/python3 -I -B {script}\n') + assert prune(root)['status'] == 'deferred' + assert old.exists() and script.resolve(strict=True).exists() + + +def test_late_interpreter_indirection_restores_quarantined_artifacts( + fleet, executable_units, monkeypatch, +): + root, _, activate = fleet + activate('first') + activate('second') + activate('third') + units, _, _ = executable_units + service = units / 'worker.service' + service.write_text('[Service]\nExecStart=/bin/true\n') + checks = 0 + + def acceptance(plan): + nonlocal checks + checks += 1 + if checks == 2: + service.write_text('[Service]\nExecStart=/bin/sh -c worker\n') + + monkeypatch.setattr(cleanup, 'run_checks', acceptance) + with pytest.raises(ValueError, match='indirect service command'): + prune(root) + plan = json.loads((root / retention.INVENTORY).read_text()) + assert checks == 2 and all(Path(p).exists() for p in plan['candidates']) + assert not list(root.rglob('.cc-remote-retired-*')) + + def test_dormant_alias_preserves_transitive_release_dependencies(fleet, tmp_path, monkeypatch): root, _, activate = fleet runtime, _ = activate('first')