Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .agents/skills/cc-remote-deploy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#后续更新)。

| 场景 | 文档 |
Expand Down
27 changes: 23 additions & 4 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down
28 changes: 26 additions & 2 deletions cc_remote/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]]
Expand Down
24 changes: 21 additions & 3 deletions cc_remote/update_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"],
Expand Down
Loading
Loading