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..cfd9513c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -135,9 +135,65 @@ 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. +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. +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. 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. 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 +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 +`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 @@ -171,6 +227,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": [ @@ -191,6 +248,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, @@ -220,8 +283,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 @@ -261,11 +324,30 @@ 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. 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 + 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 +362,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 +371,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..dba87509 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 @@ -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} @@ -342,12 +363,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", @@ -401,6 +426,10 @@ def cleanup(inventory: Path, *, apply: bool = False) -> dict: 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: @@ -411,9 +440,6 @@ def cleanup(inventory: Path, *, apply: bool = False) -> dict: 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/install-wrapper.sh b/deploy/install-wrapper.sh index 93b5dcac..04d3b1e3 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,22 @@ 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 || 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 if [ "$service_was_running" -eq 1 ] && [ -n "$previous" ] && \ @@ -300,6 +396,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 +414,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 +441,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 +456,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 +483,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 +498,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 +567,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 +605,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 +676,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 +689,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 +709,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 +775,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 +793,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 +845,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 +882,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 +893,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 +927,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..c2662c97 --- /dev/null +++ b/deploy/linux_service.py @@ -0,0 +1,464 @@ +"""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 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] + 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, + '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') + + +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', '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) + 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 == 'remove': + remove(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..0f3db538 --- /dev/null +++ b/deploy/release_retention.py @@ -0,0 +1,765 @@ +"""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 +from collections import deque +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 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"]) + 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_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'']: + 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) + 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') + 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_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 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 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. + + 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. + + 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)): + 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") + 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 + paths_seen = set() + executable_commands = 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: + 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): + 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): + 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, custom_namespace + protect(path) + 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": + 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'=') + key = key.strip() + text = value.strip().decode('utf-8') + if key.strip() == b'EnvironmentFile': + continue # exact single-filename handling below + 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('-@:+!') + 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_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): + found.add(str(candidate)) + return raw + + for path in files: + raw = inspect(path) + for env, optional in service_environment_files(raw): + if optional and not env.exists() and not env.is_symlink(): + continue + inspect(env) + 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 + # 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, arguments in executable_commands: + resolved = False + for directory in search_paths: + executable = directory / name + protect(executable) + try: + info = executable.stat() + except FileNotFoundError: + continue + 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) + + +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"]] + 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"}], + "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..aa631cbb 100755 --- a/deploy/setup-vps.sh +++ b/deploy/setup-vps.sh @@ -44,6 +44,8 @@ PUBLIC_SCHEME=https 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 @@ -337,6 +339,18 @@ harden_release_permissions "$NEW_RELEASE_DIR" 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" \ + --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 +445,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..8573ac5a 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 || ${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. - 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..f981910e 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,96 @@ 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 4.2.1/4.2.4 的旧接口,仍会校验目标服务的实际配置;若磁盘配置有尚未 +应用的变更,安装器会要求先核对,不会跳过检查或自动重启其他服务。 + +先确认 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..81ae45b2 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,92 @@ 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. +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. +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..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 @@ -665,7 +667,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, ) @@ -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_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_install_commit.py b/tests/test_install_commit.py index 823de123..05d12ee0 100644 --- a/tests/test_install_commit.py +++ b/tests/test_install_commit.py @@ -14,8 +14,84 @@ 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"]) +@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 +107,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 +125,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 +135,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 +180,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..f57c7f41 --- /dev/null +++ b/tests/test_linux_service.py @@ -0,0 +1,346 @@ +"""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) + + +@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.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 + 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') + 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..1a190deb --- /dev/null +++ b/tests/test_release_retention.py @@ -0,0 +1,1052 @@ +"""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 + +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): + 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() + + +@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" + 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('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 + + +@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' + 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() + + +@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') + 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}', + '\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 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