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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

[中文](CHANGELOG_zh.md)

## v4.0.2

Fix stale Claude activity and coordinate device upgrades with their Relay.
Wire protocol remains v72. See the bilingual [release notes](docs/releases/v4.0.2.md).

- Retire anonymous activity received before the native human-input echo when
that response ends. Preserve unrelated background tasks and support existing
persistent Claude services without restarting them or resubmitting prompts.
- Check/update the paired VPS before activating a device update; skip an already
current compatible Relay. Preserve the exact remote transaction across lost
SSH connections. The downloaded installer also covers the first v4.0.1 upgrade.
- Show authenticated device/server version mismatches in the Web device list
and active conversation without changing the wire protocol.
- Allow explicit registration of existing immutable Mac installations, retaining
their root, LaunchAgent identity, environment and log paths.
- Initialize Work stores before optional Codex readiness checks so a slow probe
cannot cause a false first-install migration failure.

## v4.0.1

Add managed release updates and verify Codex shared connections after activation.
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

[English](CHANGELOG.md)

## v4.0.2

修复 Claude 假运行状态,并让设备更新与 VPS 协调执行。通信协议保持 v72。
详见[双语发布说明](docs/releases/v4.0.2.md)。

- 原生用户回显前收到的匿名活动,随对应回复结束正确收尾;保留无关后台任务,
兼容已有独立 Claude 服务,不重启服务或重发提示词。
- 设备更新先检查并升级配对的 VPS;VPS 已更新且协议兼容时跳过。SSH 断开后核查
同一远端事务,新安装器也覆盖从 v4.0.1 发起的首次升级。
- 网页设备列表和当前会话显示经过认证的设备/服务端版本不一致提示。
- 允许显式注册已有 Mac 不可变安装,保留目录、LaunchAgent、环境与日志位置。
- Work 数据库先初始化,再进行可选 Codex 连接检查,避免检查缓慢导致首次安装
被误判为数据库迁移失败。

## v4.0.1

新增 Release 更新命令,并在激活后检查 Codex 共享连接。通信协议仍为 v72。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · Web / PWA / TUI

**产品版本:v4.0.1** · Wire protocol v72
**产品版本:v4.0.2** · Wire protocol v72

[English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) ·
[终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md)
Expand Down
2 changes: 1 addition & 1 deletion README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · Web / PWA / TUI

**Product version: v4.0.1** · Wire protocol v72
**Product version: v4.0.2** · Wire protocol v72

[中文](README.md) · [Engine comparison](#engines-and-features) · [Quick start](#quick-start) ·
[Terminal workspace](#terminal-workspace) · [Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md)
Expand Down
2 changes: 1 addition & 1 deletion cc_remote/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
- control link: client <-> relay(WS) <-> wrapper <-> ClaudeSDKClient <-> cc
"""

__version__ = "4.0.1"
__version__ = "4.0.2"
2 changes: 2 additions & 0 deletions cc_remote/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def main(argv: list[str] | None = None) -> int:
command.add_argument("--check", action="store_true", help="check without downloading or restarting")
command.add_argument("--version", dest="target_version", help="select an exact stable version")
command.add_argument("--role", choices=("relay", "wrapper"), help="required when both roles are installed")
command.add_argument("--relay-ssh", help="SSH alias or user@host for Relay upgrades (saved after verification)")
command.add_argument(
"--allow-protocol-change", action="store_true",
help="activate a protocol change during a coordinated multi-machine upgrade",
Expand All @@ -25,6 +26,7 @@ def main(argv: list[str] | None = None) -> int:
return update(
role=args.role, target_version=args.target_version, check=args.check,
allow_protocol_change=args.allow_protocol_change,
relay_ssh=args.relay_ssh,
)
except (UpdateError, OSError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
Expand Down
40 changes: 26 additions & 14 deletions cc_remote/claude_steering.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict:
kind = origin.get("kind") if isinstance(origin, dict) else None
child = value.get("parent_tool_use_id") or value.get("parentToolUseID")
if not child:
if managed_active and is_managed_input(value, pending_compact=True):
# A native request can precede its replayed human input. That
# anonymous activity now belongs to the consumed human response
# and must settle at its Result. Keep explicit task origins;
# they can still have their own, later terminal boundary.
for entry in self._backgrounds.values():
if entry["origin_key"] is None:
entry["managed"] = True
message = value.get("message")
content = message.get("content") if isinstance(message, dict) else None
tool_result = isinstance(content, list) and any(
Expand Down Expand Up @@ -141,20 +149,24 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict:
}}
elif value.get("type") == "result":
ended = background_end_ids(value)
if not ended:
if kind in (None, "human"):
# An unattributed Result closes the physical response,
# including its in-turn task inputs. An older autonomous
# response must not be consumed by a new human terminal.
if managed_active:
ended = tuple(key for key, entry in self._backgrounds.items()
if entry["managed"])
elif kind is None:
ended = tuple(self._backgrounds)
else:
# A precise unrelated origin remains authoritative.
ended = tuple(key for key, entry in self._backgrounds.items()
if entry["origin_key"] in (None, _origin_key(origin)))
local_ends = ()
if kind in (None, "human"):
# An unattributed Result closes the physical response,
# including its in-turn task inputs. An older autonomous
# response must not be consumed by a new human terminal.
if managed_active:
local_ends = tuple(key for key, entry in self._backgrounds.items()
if entry["managed"])
elif kind is None:
local_ends = tuple(self._backgrounds)
else:
# A precise unrelated origin remains authoritative.
local_ends = tuple(key for key, entry in self._backgrounds.items()
if entry["origin_key"] in (None, _origin_key(origin)))
# An older service can journal some ends without knowing about
# the controller's implicit pre-input claim. Retain its exact
# boundaries and include locally established consumption too.
ended = tuple(dict.fromkeys((*ended, *local_ends)))
if ended:
value = {**value, "__cc_background_ends": list(ended)}
if len(ended) == 1:
Expand Down
55 changes: 54 additions & 1 deletion cc_remote/relay/pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,20 @@
from __future__ import annotations

import asyncio
from collections import OrderedDict
import json
import re
import uuid
from typing import Awaitable, Callable, Optional

from fastapi import WebSocket, WebSocketDisconnect

from cc_remote import __version__
from cc_remote.config import RelayConfig
from cc_remote.log import logger
from cc_remote.protocol import (
Error, ProtocolError, WrapperDisconnected, WrapperReconnected,
deserialize, is_client_message, serialize,
PROTOCOL_VERSION, deserialize, is_client_message, serialize,
ERR_BUSY, ERR_WRAPPER_OFFLINE, ERR_WRAPPER_ALREADY_CONNECTED, ERR_PROTOCOL,
)
from cc_remote.relay.forward import ClientConn, SlowClientError
Expand Down Expand Up @@ -69,6 +73,32 @@ def __init__(
# Once a new generation owns client_id, no old generation can enter a
# wrapper send after that point.
self._wrapper_send_lock = asyncio.Lock()
self._wrapper_versions: OrderedDict[str, dict] = OrderedDict()

def remember_wrapper_version(self, machine_id: str, protocol: int, version: str | None) -> None:
if not isinstance(version, str) or not re.fullmatch(r"\d{1,6}\.\d{1,6}\.\d{1,6}", version):
version = None
self._wrapper_versions[machine_id] = {
"wrapper_version": version, "relay_version": __version__,
"wrapper_protocol": protocol, "relay_protocol": PROTOCOL_VERSION,
}
self._wrapper_versions.move_to_end(machine_id)
while len(self._wrapper_versions) > MAX_NAMED_WRAPPERS + 1:
self._wrapper_versions.popitem(last=False)

def device_version(self, machine_id: str) -> dict:
info = self._wrapper_versions.get(machine_id)
if info and (info["wrapper_protocol"] != PROTOCOL_VERSION
or info["wrapper_version"] != __version__):
return {"compatibility": dict(info)}
return {}

@property
def versioned_machine_ids(self) -> tuple[str, ...]:
return tuple(self._wrapper_versions)

def forget_wrapper_version(self, machine_id: str) -> None:
self._wrapper_versions.pop(machine_id, None)

@property
def wrapper_connected(self) -> bool:
Expand Down Expand Up @@ -165,6 +195,27 @@ async def serve_wrapper(
except ProtocolError as e:
log.warning("bad frame from wrapper", error=str(e))
mismatch = "protocol version mismatch" in str(e)
if mismatch and not announced:
# Version diagnostics must remain readable even when
# strict wire decoding rejects an old peer. Only an
# authenticated, correctly scoped hello can publish it.
try:
hello = json.loads(raw)
peer_id = hello.get("machine_id") or "default"
peer_version = hello.get("v")
valid = (
hello.get("type") == "hello" and hello.get("role") == "wrapper"
and isinstance(peer_id, str)
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}", peer_id)
and type(peer_version) is int and 0 < peer_version < 1000000
and expected_machine_id in (None, peer_id)
)
if (valid and (authorize_machine is None or await authorize_machine(peer_id))
and self._wrapper_for(peer_id) is None):
self.remember_wrapper_version(peer_id, peer_version,
getattr(ws, "headers", {}).get("x-cc-remote-version"))
except (ValueError, TypeError, AttributeError):
pass
try:
await ws.send_text(serialize(Error(
code=ERR_PROTOCOL,
Expand Down Expand Up @@ -258,6 +309,8 @@ async def serve_wrapper(
pass
return
log.info("wrapper connected", machine_id=machine_id)
self.remember_wrapper_version(machine_id, PROTOCOL_VERSION,
getattr(ws, "headers", {}).get("x-cc-remote-version"))
assert machine_id is not None
await self._on_wrapper_msg(msg, machine_id)
except WebSocketDisconnect:
Expand Down
8 changes: 7 additions & 1 deletion cc_remote/relay/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -864,12 +864,13 @@ async def device_list(req: Request) -> JSONResponse:
"last_seen": record.last_seen,
"online": record.machine_id in connected,
"managed": True,
**hub.device_version(record.machine_id),
}
for record in records
]
known = {record.machine_id for record in records}
visible_legacy = {
machine_id for machine_id in connected
machine_id for machine_id in connected | set(hub.versioned_machine_ids)
if claims.allows_machine(machine_id)
}
if "*" not in claims.machines:
Expand All @@ -884,6 +885,7 @@ async def device_list(req: Request) -> JSONResponse:
"last_seen": None,
"online": machine_id in connected,
"managed": False,
**hub.device_version(machine_id),
})
pairing_expires_at = await devices.pairing_expires_at(subject)
return JSONResponse(
Expand Down Expand Up @@ -1007,6 +1009,7 @@ async def device_revoke(machine_id: str, req: Request) -> JSONResponse:
if not revoked:
return JSONResponse({"error": "not_found"}, status_code=404)
await hub.disconnect_wrapper(machine_id, reason="device revoked")
hub.forget_wrapper_version(machine_id)
await viewers.disconnect_machine(machine_id)
return JSONResponse({"ok": True}, headers={"Cache-Control": "no-store"})

Expand Down Expand Up @@ -1162,10 +1165,13 @@ async def dynamic_wrapper_authorized(machine_id: str) -> bool:

@app.get("/healthz")
async def healthz() -> JSONResponse:
from cc_remote import __version__

return JSONResponse(
{
"ok": True,
"protocol": PROTOCOL_VERSION,
"version": __version__,
"wrapper_connected": hub.wrapper_connected,
"machines": hub.machine_ids,
"clients": hub.client_count,
Expand Down
34 changes: 30 additions & 4 deletions cc_remote/update.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Update managed Release installations through the existing role installers.

No model imports, credentials, remote shell access, or service restarts belong
here. Activation and rollback remain owned by deploy/install-{role}.sh.
No model imports or direct service restarts belong here. Activation and
rollback remain owned by deploy/install-{role}.sh; update_relay coordinates
the configured upstream through its existing SSH administration boundary.
"""
from __future__ import annotations

Expand Down Expand Up @@ -64,7 +65,8 @@ def role(self) -> str:

def installation_roots(system: str) -> dict[str, Path]:
if system == "darwin":
return {"wrapper": Path.home() / "Library/Application Support/cc-remote"}
return {"wrapper": Path(os.environ.get("CC_REMOTE_MANAGED_ROOT")
or Path.home() / "Library/Application Support/cc-remote")}
return {"relay": Path("/opt/cc-remote"), "wrapper": Path("/opt/cc-remote-wrapper")}


Expand Down Expand Up @@ -95,6 +97,10 @@ def read_installation(root: Path, system: str, machine: str) -> Installation:
user = metadata.get("user")
if not isinstance(user, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", user) or user == "root":
raise UpdateError("wrapper installation has no valid original service user")
label = metadata.get("service_label")
if label is not None and (system != "darwin" or not isinstance(label, str)
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]{0,127}", label)):
raise UpdateError("invalid managed macOS service label")
elif not isinstance(metadata.get("domain"), str) or not re.fullmatch(
r"[a-z0-9][a-z0-9.-]*\.[a-z0-9.-]+", metadata["domain"]
):
Expand Down Expand Up @@ -316,7 +322,7 @@ def run_installer(command: list[str], lock_descriptor: int) -> int:


def update(*, role: str | None, target_version: str | None, check: bool,
allow_protocol_change: bool = False) -> int:
allow_protocol_change: bool = False, relay_ssh: str | None = None) -> int:
system, machine = host_platform()
installation = select_installation(role, system, machine)
repository = release_repository()
Expand All @@ -336,9 +342,22 @@ def update(*, role: str | None, target_version: str | None, check: bool,
installation = fresh
current = version_tuple(installation.manifest["product_version"])
print(f"{installation.role}: installed {installation.manifest['product_version']}; selected {version}", flush=True)
relay = None
if installation.role == "wrapper":
from cc_remote.update_relay import RelayUpdate

relay = RelayUpdate(installation, relay_ssh)
if check:
relay.inspect()
elif relay_ssh:
raise UpdateError("--relay-ssh applies only to a device (wrapper) update")
if selected <= current:
if target_version and selected < current:
raise UpdateError("update does not downgrade private state; use the documented rollback procedure")
if relay and not check and selected == current:
require_independent_terminal()
relay.ensure(version, installation.manifest["protocol_version"],
allow_protocol_change=allow_protocol_change)
print("Already up to date." if selected == current else "Installed version is newer than the latest release.")
return 0
if check:
Expand All @@ -365,11 +384,18 @@ def update(*, role: str | None, target_version: str | None, check: bool,
fresh = read_installation(installation.root, system, machine)
if fresh != installation:
raise UpdateError("installation changed while downloading; inspect it before retrying")
if relay:
relay.ensure(version, target["protocol_version"], allow_protocol_change=allow_protocol_change)
command = ["bash", str(bundle / "deploy" / f"install-{installation.role}.sh"), str(bundle)]
if installation.role == "relay":
command += ["--domain", installation.metadata["domain"]]
elif system == "linux":
command += ["--user", installation.metadata["user"]]
elif installation.metadata.get("service_label"):
command += ["--install-root", str(installation.root),
"--service-label", installation.metadata["service_label"]]
if installation.role == "wrapper" and allow_protocol_change:
command += ["--allow-protocol-change"]
print("Activating with the release installer; previous release retained for rollback.", flush=True)
if run_installer(command, lock_descriptor):
raise UpdateError("installer did not complete successfully; inspect its rollback report before retrying")
Expand Down
Loading
Loading