diff --git a/AGENTS.md b/AGENTS.md index fe7907f..e683a71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ attachment and optional App-control MCP tools are separate user choices. - **cwd must match resume**: a session's jsonl lives at `~/.claude/projects//.jsonl`. `ClaudeAgentOptions.cwd` MUST equal the original session's cwd or `resume` can't find it. -- **SDK pinned to `claude-agent-sdk==0.2.151`**: message-type shapes and the +- **SDK pinned to `claude-agent-sdk==0.2.157`**: message-type shapes and the interrupt/drain contract can shift between patch versions. Re-run the interrupt+drain verification after any upgrade (`SdkHandle.preflight()` guards the exact verified patch at startup). @@ -78,7 +78,7 @@ attachment and optional App-control MCP tools are separate user choices. - **`include_partial_messages`** is a `ClaudeAgentOptions` field (set at construction, not on `query()`). Streaming events arrive as `StreamEvent` (`.event` = raw Anthropic API stream-event dict) — NOT - `SDKPartialAssistantMessage` (doesn't exist in 0.2.151). Extract + `SDKPartialAssistantMessage` (doesn't exist in 0.2.157). Extract `content_block_delta` → `delta.text` from `StreamEvent.event`. - **tool_use is batched, not streamed**: emit one `tool_use` event from the assembled `AssistantMessage` (full `input`), never as JSON-fragment deltas. diff --git a/CHANGELOG.md b/CHANGELOG.md index 742b151..c80bc37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ [中文](CHANGELOG_zh.md) +## v4.0.4 + +Discover Claude models from the installed CLI and improve inline code emphasis. +Wire protocol remains v72. See the bilingual [release notes](docs/releases/v4.0.4.md). + +- Populate Web, side-chat and TUI model pickers with the native model catalog and + supported effort levels, without sending prompts or resuming sessions. +- Refresh the catalog when opening model settings, scope its cache by account, + directory and CLI build, and preserve explicitly selected historical models. +- Keep Work discovery within its runtime policy and inherit native Claude model + and effort defaults instead of imposing a fixed Opus/max selection. +- Refresh Codex models on page reload, reconnect and picker open; share concurrent + reads per account and retain that account's last good list on discovery failure. +- Support Codex 0.156's protected socket aliases while preserving account ownership + checks and detecting replacement of the underlying app-server listener. +- Upgrade the verified Python Claude Agent SDK from 0.2.151 to 0.2.157. Wrapper + upgrades across this SDK boundary require the documented drain/installer path. +- Render inline code in softer harness colors without a background badge; actual + links retain their link styling and fenced code blocks retain their background. +- Expand Claude thinking text by default. Pressing `/` outside an editor focuses + the composer without inserting a character; pressing it again types normally. + ## v4.0.3 Fix duplicated and misplaced Claude messages when browsing overlapping history pages. diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index a135b9e..3b0366a 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -2,6 +2,24 @@ [English](CHANGELOG.md) +## v4.0.4 + +从本机 Claude CLI 自动发现模型,并优化行内代码高亮。通信协议保持 v72。 +详见[双语发布说明](docs/releases/v4.0.4.md)。 + +- Web、侧聊和 TUI 的模型列表读取原生目录及支持的思考强度,不发送提示词或恢复会话。 +- 打开模型设置时刷新目录,按账号、目录和 CLI 构建缓存;保留用户明确选择的历史模型。 +- Work 模型发现遵循其实际运行策略;Claude 新会话继承原生模型和思考强度默认值, + 不再强制使用固定的 Opus/max 组合。 +- 刷新页面、重连或打开模型菜单时重新读取 Codex 模型;同账号并发请求合并,读取 + 失败时保留该账号上次成功的列表。 +- 兼容 Codex 0.156 的受保护套接字别名,继续校验账号归属并检测底层监听器替换。 +- 已验证的 Python Claude Agent SDK 从 0.2.151 升级至 0.2.157;跨 SDK 版本升级 + Wrapper 时,需按文档等待任务结束、迁移服务并使用安装器升级。 +- 行内代码改为更柔和的引擎主题色文字,去掉背景块;真实链接保留链接样式,代码块 + 仍保留背景。 +- Claude 思考文本默认展开;未聚焦编辑器时按 `/` 只聚焦输入框,再按一次才输入字符。 + ## v4.0.3 修复 Claude 历史翻页时对话重复、后续问题被放到开头的问题。通信协议保持 v72。 diff --git a/CLAUDE.md b/CLAUDE.md index 0ff8daf..1098f75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ a separate choice; sharing alone does not authorize them. - **cwd must match resume**: a session's jsonl lives at `~/.claude/projects//.jsonl`. `ClaudeAgentOptions.cwd` MUST equal the original session's cwd or `resume` can't find it. -- **SDK pinned to `claude-agent-sdk==0.2.151`**: message-type shapes and the +- **SDK pinned to `claude-agent-sdk==0.2.157`**: message-type shapes and the interrupt/drain contract can shift between patch versions. Re-run the interrupt+drain verification after any upgrade (`SdkHandle.preflight()` guards the exact verified patch at startup). @@ -61,7 +61,7 @@ a separate choice; sharing alone does not authorize them. - **`include_partial_messages`** is a `ClaudeAgentOptions` field (set at construction, not on `query()`). Streaming events arrive as `StreamEvent` (`.event` = raw Anthropic API stream-event dict) — NOT - `SDKPartialAssistantMessage` (doesn't exist in 0.2.151). Extract + `SDKPartialAssistantMessage` (doesn't exist in 0.2.157). Extract `content_block_delta` → `delta.text` from `StreamEvent.event`. - **tool_use is batched, not streamed**: emit one `tool_use` event from the assembled `AssistantMessage` (full `input`), never as JSON-fragment deltas. diff --git a/README.md b/README.md index 5d5c115..ba07e65 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · Web / PWA / TUI -**产品版本:v4.0.3** · Wire protocol v72 +**产品版本:v4.0.4** · Wire protocol v72 [English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) · [终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md) @@ -152,7 +152,7 @@ Wrapper 主动出站连接 Relay,设备不需要开放公网入站端口。Rel 至少准备一个可用引擎: - **Claude**:日常 Claude Code `>= 2.1.263`,默认路径 `~/.local/bin/claude`。 - Wrapper 使用该 CLI;Python Agent SDK 固定为 `0.2.151`。 + Wrapper 使用该 CLI;Python Agent SDK 固定为 `0.2.157`。 - **Codex**:已登录的官方 CLI。共享控制需要同时支持 `codex app-server daemon --help` 和 `codex app-server proxy --help`。 diff --git a/README_en.md b/README_en.md index d6ee6ad..4887392 100644 --- a/README_en.md +++ b/README_en.md @@ -4,7 +4,7 @@ Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · Web / PWA / TUI -**Product version: v4.0.3** · Wire protocol v72 +**Product version: v4.0.4** · 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) @@ -173,7 +173,7 @@ development/builds use **Python 3.13 and Node 24**, matching CI and [`.nvmrc`](. Prepare at least one working engine: - **Claude:** daily Claude Code `>= 2.1.263`, normally at `~/.local/bin/claude`. - Wrapper launches that CLI; the Python Agent SDK is pinned to `0.2.151`. + Wrapper launches that CLI; the Python Agent SDK is pinned to `0.2.157`. - **Codex:** an authenticated official CLI. Shared control requires both `codex app-server daemon --help` and `codex app-server proxy --help`. diff --git a/cc_remote/__init__.py b/cc_remote/__init__.py index adcc258..462fc4b 100644 --- a/cc_remote/__init__.py +++ b/cc_remote/__init__.py @@ -5,4 +5,4 @@ - control link: client <-> relay(WS) <-> wrapper <-> ClaudeSDKClient <-> cc """ -__version__ = "4.0.3" +__version__ = "4.0.4" diff --git a/cc_remote/claude_paths.py b/cc_remote/claude_paths.py index 47d54b0..c9fbf6a 100644 --- a/cc_remote/claude_paths.py +++ b/cc_remote/claude_paths.py @@ -16,7 +16,7 @@ def claude_config_dir() -> Path: """Return the active native Claude Code config root.""" configured = os.environ.get("CLAUDE_CONFIG_DIR") if configured: - # Match claude-agent-sdk 0.2.151 exactly: the environment value is a + # Match claude-agent-sdk 0.2.157 exactly: the environment value is a # filesystem path, not a shell expression, so do not expand "~" or # resolve symlinks behind the SDK's back. return Path(unicodedata.normalize("NFC", configured)) diff --git a/cc_remote/protocol.py b/cc_remote/protocol.py index 75c70c2..a88a48c 100644 --- a/cc_remote/protocol.py +++ b/cc_remote/protocol.py @@ -1797,9 +1797,8 @@ class DirList(_Base): class GetModels(_Command): """client -> wrapper: what models does this engine actually offer? - `codex` answers with app-server's real catalog. Claude has no equivalent - catalog RPC, but can resolve explicit no-override settings for a cwd; - its model list therefore remains empty and the client keeps the static table. + Codex answers with app-server's model/list. Claude reads the native picker + from a prompt-free initialization and resolves no-override settings for cwd. """ type: Literal["get_models"] = "get_models" engine: Optional[Literal["cc", "claude", "codex"]] = None diff --git a/cc_remote/tui_settings.py b/cc_remote/tui_settings.py index 2b4476a..917b0c0 100644 --- a/cc_remote/tui_settings.py +++ b/cc_remote/tui_settings.py @@ -131,6 +131,8 @@ def args(self, kind): values = {"cwd": self.values["cwd"], **self.profiles} if kind == "models": values["engine"] = self.engine + if self.space == "work": + values.pop("cwd", None) else: values.pop("claude_profile_id", None) return values @@ -237,8 +239,8 @@ def choices(self, field): choices = [("Engine default (no override)", None)] if self.new else [] if field == "model": models = self.catalog("models").get("models", []) - if self.engine == "claude": - # Claude has no model/list RPC; same curated choices as Web. + if self.engine == "claude" and not models: + # Offline fallback only; the native CLI catalog wins. models = [{"id": value} for value in CLAUDE_MODELS] for model in models: identity = model.get("id") @@ -258,7 +260,7 @@ def choices(self, field): {}, ) efforts = model.get("efforts", []) - if self.engine == "claude": + if self.engine == "claude" and not model: from cc_remote.wrapper.claude_controls import CLAUDE_EFFORTS efforts = sorted(CLAUDE_EFFORTS) @@ -467,9 +469,5 @@ def created(self, success): } CLAUDE_MODELS = ( - "claude-opus-5[1m]", - "claude-mythos-5-1", - "claude-sonnet-5", - "claude-haiku-4-5", - "claude-fable-5-1", + "opus[1m]", "sonnet", "haiku", ) diff --git a/cc_remote/wrapper/claude_models.py b/cc_remote/wrapper/claude_models.py new file mode 100644 index 0000000..6c97234 --- /dev/null +++ b/cc_remote/wrapper/claude_models.py @@ -0,0 +1,181 @@ +"""Discover models from the daily Claude CLI's initialization response. + +This is the same native catalog exposed by the SDK's get_server_info(). No +prompt, resume, or model request is sent. A short-lived, customization-disabled +child preserves native account/model settings without running hooks or MCPs. +""" +from __future__ import annotations + +import asyncio +from collections import OrderedDict +from contextlib import ExitStack +import json +import os +from pathlib import Path +import tempfile +import time + +from cc_remote.log import logger +from cc_remote.wrapper.child_env import claude_profile_process_env +from cc_remote.wrapper.claude_controls import valid_claude_model +from cc_remote.wrapper.claude_runtime import resolve_claude_cli +from cc_remote.workspaces import _claude_runtime_settings + +log = logger("cc_remote.wrapper.claude_models") +_TTL = 60.0 +_TIMEOUT = 20.0 +_MAX_SCOPES = 64 +_cache: OrderedDict[tuple, tuple[float, list[dict]]] = OrderedDict() +_lock = asyncio.Lock() + + +def _normalize(raw: object) -> list[dict]: + if not isinstance(raw, list): + return [] + models: dict[str, dict] = {} + for item in raw[:256]: + if not isinstance(item, dict): + continue + # Keep Default as a native selection: availableModels can permit the + # tier default while forbidding an explicit selection of its resolved + # ID. Named rows use resolved IDs to expose newly shipped versions. + value = valid_claude_model(item.get("value")) + identity = ("default" if value == "default" else + valid_claude_model(item.get("resolvedModel")) or value) + if not identity: + continue + levels = item.get("supportedEffortLevels") + levels = levels if isinstance(levels, list) else [] + efforts = list(dict.fromkeys( + level for level in levels[:16] + if isinstance(level, str) and 0 < len(level) <= 64 + )) if item.get("supportsEffort") is not False else [] + is_default = item.get("value") == "default" + previous = models.get(identity) + # Repeated rows must not create duplicate choices. + if previous and is_default: + previous["is_default"] = True + continue + models[identity] = { + "id": identity, + "display_name": str(item.get("displayName") or identity)[:4096], + "description": str(item.get("description") or "")[:4096], + "efforts": efforts, + "default_effort": None, + "is_default": is_default or bool(previous and previous["is_default"]), + } + return list(models.values()) + + +async def _read_catalog( + binary: str, cwd: str, config_dir: str | None, isolate_account_env: bool, + work_only: bool, +) -> list[dict]: + env = claude_profile_process_env( + config_dir, isolate_account_env=isolate_account_env) + env.pop("CLAUDECODE", None) + args = [ + binary, "--print", "--input-format", "stream-json", + "--output-format", "stream-json", "--verbose", + "--no-session-persistence", "--safe-mode", "--strict-mcp-config", + "--tools", "", + ] + with ExitStack() as scope: + if work_only: + # Match Work's sole policy source and provider allowlist. Never load + # user/project customizations or put provider credentials in argv. + settings = scope.enter_context(tempfile.NamedTemporaryFile( + mode="w+", prefix="cc-remote-models-", suffix=".json")) + json.dump(_claude_runtime_settings(config_dir), settings) + settings.flush() + args.extend(["--setting-sources", "", "--settings", settings.name]) + elif isolate_account_env: + args.extend(["--setting-sources", "user"]) + return await _initialize_catalog(args, cwd, env) + + +async def _initialize_catalog(args: list[str], cwd: str, env: dict[str, str]) -> list[dict]: + proc = await asyncio.create_subprocess_exec( + *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, cwd=cwd, env=env, + limit=4 * 1024 * 1024, + ) + try: + async with asyncio.timeout(_TIMEOUT): + proc.stdin.write((json.dumps({ + "type": "control_request", "request_id": "model-catalog", + "request": {"subtype": "initialize"}, + }) + "\n").encode()) + await proc.stdin.drain() + while line := await proc.stdout.readline(): + message = json.loads(line) + if not isinstance(message, dict): + continue + response = message.get("response") + if (message.get("type") != "control_response" + or not isinstance(response, dict) + or response.get("request_id") != "model-catalog"): + continue + if response.get("subtype") != "success": + raise RuntimeError("Claude initialization rejected") + result = response.get("response") + return _normalize(result.get("models") if isinstance(result, dict) else None) + raise RuntimeError("Claude closed before model discovery") + finally: + proc.stdin.close() + if proc.returncode is None: + try: + proc.terminate() + except ProcessLookupError: + pass + try: + await asyncio.wait_for(proc.wait(), 3.0) + except asyncio.TimeoutError: + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + + +async def claude_model_catalog( + *, claude_bin: str, cwd: str | None = None, + config_dir: str | None = None, isolate_account_env: bool = False, +) -> list[dict]: + """Bounded account/cwd cache; CLI replacement bypasses the TTL immediately. + + Missing cwd means Work discovery using its filtered runtime settings, never + borrowing customizations from the Wrapper's Code project or user settings. + """ + async with _lock: + try: + binary, _ = resolve_claude_cli( + claude_bin or str(Path.home() / ".local/bin/claude")) + binary = os.path.realpath(binary) + stat = os.stat(binary) + target = os.path.realpath(os.path.expanduser(cwd or str(Path.home()))) + key = ( + binary, stat.st_mtime_ns, stat.st_size, target, + os.path.realpath(config_dir) if config_dir else None, + isolate_account_env, cwd is None, + ) + except (OSError, RuntimeError): + return [] + cached_at, cached = _cache.get(key, (0.0, [])) + if key in _cache and time.monotonic() - cached_at < _TTL: + _cache.move_to_end(key) + return cached + try: + models = await _read_catalog( + binary, target, config_dir, isolate_account_env, cwd is None) + except Exception as exc: + # Native errors can contain provider/account information; report + # only their class, never dump raw initialization or stderr. + log.warning("Claude model discovery failed", reason=type(exc).__name__) + models = [] + result = models or cached + _cache[key] = (time.monotonic(), result) + _cache.move_to_end(key) + while len(_cache) > _MAX_SCOPES: + _cache.popitem(last=False) + return result diff --git a/cc_remote/wrapper/claude_runtime.py b/cc_remote/wrapper/claude_runtime.py index 5501150..b694d6c 100644 --- a/cc_remote/wrapper/claude_runtime.py +++ b/cc_remote/wrapper/claude_runtime.py @@ -16,7 +16,7 @@ import claude_agent_sdk -VERIFIED_SDK_VERSION = "0.2.151" +VERIFIED_SDK_VERSION = "0.2.157" MINIMUM_CLAUDE_CLI_VERSION = "2.1.263" _CLI_VERSION_TIMEOUT = 3.0 _VERSION_RE = re.compile( diff --git a/cc_remote/wrapper/claude_transport.py b/cc_remote/wrapper/claude_transport.py index 6f7bbbc..a1c064e 100644 --- a/cc_remote/wrapper/claude_transport.py +++ b/cc_remote/wrapper/claude_transport.py @@ -14,7 +14,7 @@ class AccountIsolatedSubprocessCLITransport( ): """Spawn one Claude child without inherited account selectors. - Agent SDK 0.2.151 treats ``ClaudeAgentOptions.env`` as an overlay on the + Agent SDK 0.2.157 treats ``ClaudeAgentOptions.env`` as an overlay on the Wrapper process environment. Empty strings are not equivalent to unsetting modern Claude authentication selectors, while mutating ``os.environ`` would race unrelated Codex and tool spawns. This pinned adapter mirrors the SDK's diff --git a/cc_remote/wrapper/codex_daemon.py b/cc_remote/wrapper/codex_daemon.py index d037894..12dae6a 100644 --- a/cc_remote/wrapper/codex_daemon.py +++ b/cc_remote/wrapper/codex_daemon.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import hashlib import json import os import re @@ -362,6 +363,43 @@ def _managed_daemon_process_identity( return process_identity(pid) +def _protected_socket_directory(uid: int) -> Path: + # Match codex-uds: independent of HOME, TMPDIR, and account settings. + return Path("/tmp").resolve() / f"codex-daemon-{uid}" + + +def socket_identity(path: str, *, owner_uid: int | None = None) -> tuple[int, int, int]: + """Validate a native listener, including Codex 0.156's protected alias. + + Native Unix aliases point to /tmp/codex-daemon-UID/SHA256(canonical address). + Accept only that exact mapping, never an arbitrary or cross-account link. + The physical listener's identity fences replacement behind an unchanged alias. + """ + uid = os.getuid() if owner_uid is None else owner_uid + address = Path(path) + parent = address.parent + parent_info = parent.lstat() + if (not address.is_absolute() or str(parent.resolve()) != str(parent) + or not stat.S_ISDIR(parent_info.st_mode) or parent_info.st_uid != uid + or parent_info.st_mode & 0o022): + raise ValueError("Codex socket parent is not private to its owner") + info = address.lstat() + if stat.S_ISLNK(info.st_mode): + directory = _protected_socket_directory(uid) + target = directory / hashlib.sha256(os.fsencode(address)).hexdigest() + if info.st_uid != uid or os.readlink(address) != str(target): + raise ValueError("Codex socket alias does not match its account address") + directory_info = directory.lstat() + if (not stat.S_ISDIR(directory_info.st_mode) or directory_info.st_uid != uid + or stat.S_IMODE(directory_info.st_mode) != 0o700): + raise ValueError("Codex protected socket directory is not private") + info = target.lstat() + if (not stat.S_ISSOCK(info.st_mode) or info.st_uid != uid + or info.st_mode & 0o077): + raise ValueError("Codex socket is not private to its owner") + return info.st_dev, info.st_ino, info.st_ctime_ns + + def _standalone_socket_identity( codex_home: str, socket_path: str, ) -> Optional[CodexSocketIdentity]: @@ -370,7 +408,7 @@ def _standalone_socket_identity( A standalone app-server has no managed PID record. Its Unix listener is replaced on restart, so inode/ctime changes fence old proxies just as a PID/start-token change does for a managed daemon. Never substitute another - account's socket, follow a socket symlink, or accept a shared-writable path. + account's socket, follow an arbitrary symlink, or accept a shared-writable path. The proxy handshake still proves that the observed listener speaks Codex. """ try: @@ -380,20 +418,10 @@ def _standalone_socket_identity( return None parent = path.parent.resolve() parent.relative_to(home) - parent_stat = parent.stat() - info = path.lstat() - if ( - not stat.S_ISDIR(parent_stat.st_mode) - or parent_stat.st_uid != os.getuid() - or parent_stat.st_mode & 0o022 - or not stat.S_ISSOCK(info.st_mode) - or info.st_uid != os.getuid() - or info.st_mode & 0o077 - ): - return None + device, inode, created_ns = socket_identity(str(parent / path.name)) return CodexSocketIdentity( str(home), str(parent / path.name), - info.st_dev, info.st_ino, info.st_ctime_ns, + device, inode, created_ns, ) except (OSError, ValueError, RuntimeError): return None diff --git a/cc_remote/wrapper/codex_models.py b/cc_remote/wrapper/codex_models.py index 9a8e2f0..f2ac88e 100644 --- a/cc_remote/wrapper/codex_models.py +++ b/cc_remote/wrapper/codex_models.py @@ -32,16 +32,16 @@ log = logger("cc_remote.wrapper.codex_models") -_TTL = 600.0 # catalog is baked into the binary; re-probe only if it's upgraded +# Internal effort/default lookups may reuse a catalog. Explicit picker requests +# refresh it: native availability can change independently of the CLI version. +_TTL = 600.0 _RPC_TIMEOUT = 30.0 _MAX_MODELS = 256 _MAX_EFFORTS = 16 _MAX_CATALOG_TEXT = 4096 -_cache: Optional[list[dict]] = None -_cache_ts: float = 0.0 _profile_cache: dict[str, tuple[list[dict], float]] = {} -_lock = asyncio.Lock() +_inflight: dict[str, asyncio.Task[list[dict]]] = {} # Cost/latency order, low -> high. Used only to clamp an unsupported request DOWN # to something the model accepts; unknown levels sort last so they never win. @@ -155,32 +155,39 @@ async def codex_catalog( *, codex_home: str | None = None, ) -> list[dict]: - """Normalized model list, cached. Never raises: on failure we serve the last - good catalog (or []), and the web falls back to its static table.""" - global _cache, _cache_ts + """Refresh on explicit reads; cache internal lookups by account home. + + Concurrent readers share one native query per account. A disconnected + reader cannot cancel another reader's refresh, and a slow account cannot + block other accounts. Discovery failures retain only this account's last + good catalog (or []), without extending its cache lifetime. + """ cache_key = _profile_cache_key(codex_home) - async with _lock: - cached, cached_ts = ( - (_cache, _cache_ts) if not cache_key - else _profile_cache.get(cache_key, (None, 0.0)) - ) - if not force and cached is not None and (time.time() - cached_ts) < _TTL: + pending = _inflight.get(cache_key) + if pending is None: + cached, cached_ts = _profile_cache.get(cache_key, (None, 0.0)) + if not force and cached is not None and time.monotonic() - cached_ts < _TTL: return cached - try: - data = _normalize(await _rpc_model_list(codex_home)) - except Exception as e: - log.warning("codex model/list failed", error=str(e)) - return cached or [] + pending = asyncio.create_task(_refresh_catalog(cache_key, codex_home)) + _inflight[cache_key] = pending + return await asyncio.shield(pending) + + +async def _refresh_catalog(cache_key: str, codex_home: str | None) -> list[dict]: + cached, _ = _profile_cache.get(cache_key, (None, 0.0)) + try: + data = _normalize(await _rpc_model_list(codex_home)) if not data: log.warning("codex model/list returned nothing") return cached or [] - now = time.time() - if cache_key: - _profile_cache[cache_key] = (data, now) - else: - _cache, _cache_ts = data, now + _profile_cache[cache_key] = (data, time.monotonic()) log.info("codex catalog", count=len(data), ids=[m["id"] for m in data]) return data + except Exception as e: + log.warning("codex model/list failed", error=str(e)) + return cached or [] + finally: + _inflight.pop(cache_key, None) async def efforts_for( diff --git a/cc_remote/wrapper/codex_readiness.py b/cc_remote/wrapper/codex_readiness.py index 74e753d..979b359 100644 --- a/cc_remote/wrapper/codex_readiness.py +++ b/cc_remote/wrapper/codex_readiness.py @@ -13,7 +13,6 @@ import os from pathlib import Path import signal -import stat import tempfile import time from typing import Any @@ -24,7 +23,7 @@ from websockets.uri import parse_uri from cc_remote import __version__ -from cc_remote.wrapper.codex_daemon import CodexDaemonManager +from cc_remote.wrapper.codex_daemon import CodexDaemonManager, socket_identity from cc_remote.wrapper.process_scan import process_identity REPORT_NAME = "codex-readiness.json" @@ -32,17 +31,6 @@ _TIMEOUT = 8.0 -def socket_identity(path: str) -> tuple[int, int, int]: - info = os.lstat(path) - parent = Path(path).parent - parent_info = parent.stat() - if (not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid() - or info.st_mode & 0o077 or parent_info.st_uid != os.getuid() - or parent_info.st_mode & 0o022 or str(parent.resolve()) != str(parent)): - raise ValueError("Codex socket is not owned by the Wrapper user") - return info.st_dev, info.st_ino, info.st_ctime_ns - - async def probe_proxy(binary: str, env: dict[str, str], socket_path: str) -> None: """Initialize through the official raw WebSocket proxy, without a thread.""" process = await asyncio.create_subprocess_exec( @@ -138,7 +126,7 @@ async def check_profile( return row expected = os.path.join(os.path.realpath(home), "app-server-control", "app-server-control.sock") - if os.path.realpath(info.socket_path) != expected: + if os.path.abspath(info.socket_path) != expected: row["reason"] = "account_socket_mismatch" return row before = socket_identity(expected) @@ -153,8 +141,8 @@ async def check_profile( daily = await manager.version(daily_cli, env) if ( not daily or daily.get("status") != "running" - or os.path.realpath(str(daily.get("socketPath", ""))) != expected - or os.path.realpath(str(wrapper.get("socketPath", ""))) != expected + or os.path.abspath(str(daily.get("socketPath", ""))) != expected + or os.path.abspath(str(wrapper.get("socketPath", ""))) != expected ): row["reason"] = "daily_cli_mismatch" return row diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index ae3740c..dfbb15e 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -99,6 +99,7 @@ CodexProfileTopologyTransition, ) from cc_remote.wrapper import claude_catalog +from cc_remote.wrapper.claude_models import claude_model_catalog from cc_remote.codex_daemon_restart import ( CodexDaemonRestartState, read_restart_state, @@ -225,8 +226,6 @@ normalize_claude_questions, ) from cc_remote.wrapper.sdk import ( - CLAUDE_DEFAULT_EFFORT, - CLAUDE_DEFAULT_MODEL, ClaudeAutonomousFollowupPending, ClaudeBackgroundBoundary, ClaudeServiceReplayRequired, @@ -18644,8 +18643,8 @@ def _claude_configured_model( """Resolve an explicit new-session model without starting Claude CLI. Claude's account/organization runtime Default cannot be read without a - live CLI, so an absent explicit value returns None. The caller then uses - cc-remote's curated new-session default. + live CLI, so an absent explicit value returns None and leaves that + selection to the native runtime. """ root = cls._claude_project_root(cwd) user_settings = str( @@ -18728,11 +18727,11 @@ async def _claude_new_session_defaults( cwd: Optional[str], *, claude_profile: Optional[ClaudeProfile] = None, - ) -> tuple[Optional[str], str]: + ) -> tuple[Optional[str], Optional[str]]: raw_cwd = cwd or self.cfg.cc_cwd target_cwd = os.path.realpath(os.path.expanduser(raw_cwd)) if not os.path.isdir(target_cwd): - return CLAUDE_DEFAULT_MODEL, CLAUDE_DEFAULT_EFFORT + return None, None profile = claude_profile or self._claude_profile() model = await asyncio.to_thread( self._claude_configured_model, @@ -18740,23 +18739,16 @@ async def _claude_new_session_defaults( config_dir=self._claude_config_root(profile), isolate_account_env=self._claude_profiles_explicit, ) - # Claude Code uses ``[1m]`` as its native context-qualification marker - # and strips it before calling a third-party provider. Pin only models - # that cc-remote's curated catalog explicitly presents as 1M; every - # unknown/custom id remains provider-owned and passes through unchanged. - return ( - normalize_claude_model_selection(model) - or CLAUDE_DEFAULT_MODEL, - CLAUDE_DEFAULT_EFFORT, - ) + # Keep explicit model selections, including their context qualification. + # An unspecified model/effort must inherit native defaults, not force a + # historical Opus/max combination onto a different account's catalog. + return normalize_claude_model_selection(model), None async def _handle_get_models(self, cmd) -> None: """Answer with the engine's catalog and effective new-session defaults. - Codex exposes its catalog through app-server. Claude has no side-effect- - free catalog/default RPC, so its list stays empty while bounded settings - reads resolve a cwd-aware model and fall back to the curated default; - the client keeps its static presentation table. + Codex uses model/list; Claude exposes its picker in the initialization + response. Neither discovery starts a model turn or resumes a session. """ engine = getattr(cmd, "engine", None) or "cc" claude_profile = None @@ -18789,10 +18781,13 @@ async def _handle_get_models(self, cmd) -> None: await self.transport.send(error) return error codex_home = self._codex_home(codex_profile) + # Page reloads, reconnects and opening the picker are explicit reads. + # They must bypass the internal effort cache so newly available models + # appear immediately. The catalog coalesces concurrent reads per home. models = ( - await codex_catalog() + await codex_catalog(force=True) if engine == "codex" and codex_home is None - else await codex_catalog(codex_home=codex_home) + else await codex_catalog(force=True, codex_home=codex_home) if engine == "codex" else [] ) default_model = None @@ -18839,12 +18834,28 @@ async def _handle_get_models(self, cmd) -> None: if isinstance(value, str) and value: default_effort = value elif engine in {"cc", "claude"}: - defaults_cwd = getattr(cmd, "cwd", None) or self.cfg.cc_cwd - default_model, default_effort = ( - await self._claude_new_session_defaults( - defaults_cwd, - claude_profile=claude_profile, - )) + defaults_cwd = getattr(cmd, "cwd", None) + models = await claude_model_catalog( + claude_bin=self.cfg.claude_bin, + cwd=defaults_cwd, + config_dir=self._claude_config_root(claude_profile), + isolate_account_env=self._claude_profiles_explicit, + ) + if defaults_cwd: + default_model, default_effort = ( + await self._claude_new_session_defaults( + defaults_cwd, + claude_profile=claude_profile, + )) + if default_model is None: + native_default = next(( + item for item in models if item.get("is_default") + ), None) + if native_default: + default_model = native_default["id"] + default_effort = native_default.get("default_effort") + # No cwd is Work's catalog-only request. Its native policy owns the + # default; do not read Code settings or overwrite Code's cwd cache. msg = Models( engine=engine, models=models, default_model=default_model, default_effort=default_effort, cwd=defaults_cwd, @@ -35548,7 +35559,8 @@ async def codex_profile_allowed(profile_id: str) -> bool: auto_compact_mode = saved_controls.auto_compact_mode auto_compact_threshold_tokens = ( saved_controls.auto_compact_threshold_tokens) - elif engine == "claude" and resume_id is None and model is None: + elif (engine == "claude" and space == "code" + and resume_id is None and model is None): # Resolve the cwd-aware default at spawn time so a fresh session # starts on the model shown by Remote. The browser still sends null # for an implicit choice; this read is local, current, and cannot be @@ -35829,7 +35841,9 @@ async def codex_profile_allowed(profile_id: str) -> bool: "codex_home": self._codex_home(codex_profile), }), ) - if effort: + if effort or (engine == "claude" and resume_id is None): + # A new Claude session with no override inherits native effort. + # SdkHandle's legacy resume fallback must not inject max here. sdk.effort = effort sdk.applied_effort = effort work_context_baseline = ( diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py index 2ebb4ec..c1f7740 100644 --- a/cc_remote/wrapper/sdk.py +++ b/cc_remote/wrapper/sdk.py @@ -78,8 +78,8 @@ CLAUDE_DEFAULT_EFFORT = "max" CLAUDE_MAX_BUFFER_SIZE = 16 * 1024 * 1024 _CLAUDE_1M_MODEL_PINS = { - "opus": CLAUDE_DEFAULT_MODEL, - "opus[1m]": CLAUDE_DEFAULT_MODEL, + "opus": "opus[1m]", + "opus[1m]": "opus[1m]", "claude-opus-5": CLAUDE_DEFAULT_MODEL, "claude-opus-5[1m]": CLAUDE_DEFAULT_MODEL, "claude-fable-5-1": "claude-fable-5-1[1m]", @@ -94,7 +94,7 @@ def normalize_claude_model_selection(model: str | None) -> str | None: - """Keep curated long-context aliases pinned across every child generation.""" + """Preserve long context without pinning native family aliases to a version.""" if model is None: return None normalized = model.strip() @@ -437,7 +437,7 @@ def _options( else effort_override ) if auto_compact is not None: - # SDK 0.2.151 has no typed option yet, but intentionally forwards + # SDK 0.2.157 has no typed option yet, but intentionally forwards # bounded extra_args to the pinned Claude Code runtime. extra_args["autocompact"] = auto_compact if self.work_mode: @@ -529,7 +529,7 @@ def _options( ), skills=[] if self.work_mode else None, # The wrapper-owned Work settings file already contains the complete - # fail-closed sandbox including its filesystem allowlist. SDK 0.2.151 + # fail-closed sandbox including its filesystem allowlist. SDK 0.2.157 # replaces (rather than deep-merges) that object when `sandbox=` is # also supplied, silently dropping filesystem policy and inlining # provider credentials in argv. Pass only the policy path instead. diff --git a/deploy/check_codex_readiness.py b/deploy/check_codex_readiness.py index b2f8bca..9734335 100644 --- a/deploy/check_codex_readiness.py +++ b/deploy/check_codex_readiness.py @@ -13,6 +13,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from cc_remote.wrapper.codex_readiness import REPORT_NAME +from cc_remote.wrapper.codex_daemon import socket_identity from cc_remote.wrapper.process_scan import ProcessIdentity, process_identity, process_owner_uid from deploy.work_registry_snapshot import resolve_wrapper_state_dir @@ -32,9 +33,7 @@ def socket_still_ready(row: dict, owner_uid: int) -> bool: path = row["socket"] if not isinstance(path, str) or not os.path.isabs(path): return False - info = os.lstat(path) - if (not stat.S_ISSOCK(info.st_mode) or info.st_uid != owner_uid - or [info.st_dev, info.st_ino, info.st_ctime_ns] != row["socket_identity"]): + if list(socket_identity(path, owner_uid=owner_uid)) != row["socket_identity"]: return False # No account binary, authentication or model API is invoked as root. # Check only that the exact recently verified listener still accepts. @@ -42,7 +41,7 @@ def socket_still_ready(row: dict, owner_uid: int) -> bool: connection.settimeout(0.2) connection.connect(path) return True - except (OSError, KeyError, TypeError, ValueError): + except (OSError, KeyError, TypeError, ValueError, RuntimeError): return False diff --git a/deploy/install.sh b/deploy/install.sh index bff236d..03a9905 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -2,7 +2,7 @@ # Download, verify, and run a role-specific cc-remote release installer. set -euo pipefail -VERSION="${CC_REMOTE_VERSION:-4.0.3}" +VERSION="${CC_REMOTE_VERSION:-4.0.4}" REPOSITORY="${CC_REMOTE_GITHUB_REPOSITORY:-muggle-stack/cc-remote}" BASE_URL="${CC_REMOTE_RELEASE_BASE_URL:-https://github.com/$REPOSITORY/releases/download/v$VERSION}" diff --git a/docs/configuration.md b/docs/configuration.md index e7ac3d9..47cf5b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,7 +15,7 @@ Wrapper 使用日常 Claude Code,默认 `~/.local/bin/claude`,最低版本 `2.1.263`。 `CLAUDE_BIN` 留空仍使用该路径;显式覆盖必须是绝对路径。Agent SDK 固定为 -`0.2.151`,不会使用 SDK 自带 CLI 替代你的日常安装。 +`0.2.157`,不会使用 SDK 自带 CLI 替代你的日常安装。 原生 CLI、Desktop 和 Agent View 拥有的会话先在 Remote 中只读镜像。用户主动接管时, Wrapper 仅向核验过的同用户 Claude 进程发送 SIGTERM,确认释放后恢复同一会话; diff --git a/docs/configuration_en.md b/docs/configuration_en.md index c82cf7e..d4f2a71 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -15,7 +15,7 @@ Wrapper uses daily Claude Code, normally `~/.local/bin/claude`, with a minimum version of `2.1.263`. Empty `CLAUDE_BIN` still selects this path; an explicit -override must be absolute. Agent SDK is pinned to `0.2.151`; its bundled CLI does +override must be absolute. Agent SDK is pinned to `0.2.157`; its bundled CLI does not replace your daily installation. Native CLI, Desktop and Agent View sessions are mirrored read-only until explicit diff --git a/docs/installation.md b/docs/installation.md index 0a8ad78..ee7c0a6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -28,11 +28,11 @@ Web,Wrapper 包只含本机控制端;两者都自带 `uv`,安装时创建 ### 1)下载并校验引导脚本 在 GitHub Release 页面确认版本与 release attestation,再在待安装机器下载同一版本的 -`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.3`;请先确认对应版本已发布,或替换为已选定的已发布 tag +`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.4`;请先确认对应版本已发布,或替换为已选定的已发布 tag (变量中不带开头的 `v`)。该路径不会自动安装尚未发布的开发分支: ```bash -export CC_REMOTE_VERSION=4.0.3 +export CC_REMOTE_VERSION=4.0.4 release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}" curl -fLO "$release_base/install.sh" curl -fLO "$release_base/SHA256SUMS" diff --git a/docs/installation_en.md b/docs/installation_en.md index 9a26bdd..9f398e6 100644 --- a/docs/installation_en.md +++ b/docs/installation_en.md @@ -30,13 +30,13 @@ repository, install Node, or paste tokens into service definitions. ### 1) Download and verify the bootstrap Confirm the version and release attestation on GitHub, then download -`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.3`; +`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.4`; first confirm that it is published, or replace it with the published tag you selected (without the leading `v`). This does not select an unpublished development-branch build: ```bash -export CC_REMOTE_VERSION=4.0.3 +export CC_REMOTE_VERSION=4.0.4 release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}" curl -fLO "$release_base/install.sh" curl -fLO "$release_base/SHA256SUMS" diff --git a/docs/releases/v4.0.4.md b/docs/releases/v4.0.4.md new file mode 100644 index 0000000..6f90110 --- /dev/null +++ b/docs/releases/v4.0.4.md @@ -0,0 +1,101 @@ +# cc-remote v4.0.4 + +## 中文 + +v4.0.4 自动读取本机 Claude Code 的模型列表,并改善文件名、参数等行内代码的高亮。 +产品版本 **4.0.4**,通信协议保持 **v72**。 + +### 本次更新 + +- **Claude 模型自动发现**:Web、新会话、BTW 侧聊和 TUI 使用原生 CLI 返回的模型 + 与思考强度。更新 CLI 后,无需再为每个新型号修改 cc-remote 的静态列表。 +- 打开模型设置时刷新目录;缓存按账号、工作目录和 CLI 构建隔离。读取目录不发送 + 提示词,也不恢复已有会话。保留原生默认选项,不自动改写已有会话明确选择的模型。 +- **原生默认值与账号隔离**:Claude 新会话继承原生模型和思考强度默认值;Work 模型 + 发现遵循其实际运行策略,不读取 Code 项目的设置或覆盖其默认值缓存。 +- **Codex 模型刷新**:刷新页面、重连或打开模型菜单时重新读取原生列表,避免新模型 + 被旧缓存遮住。同账号并发请求共用一次读取,失败时保留该账号上次成功的列表。 +- **Codex 0.156 兼容**:识别官方 app-server 的受保护套接字别名,保留账号归属、 + 私有权限及监听器替换检查。原生 CLI 仍需单独更新。 +- **SDK 升级**:Python Claude Agent SDK 从 `0.2.151` 升级至 `0.2.157`,继续使用 + 用户日常登录的 Claude Code CLI。最低 CLI 版本仍为 `2.1.263`;实际可选模型由 + 本机 CLI、账号及原生设置决定。 +- **行内代码高亮**:文件名、参数和代码标识使用较柔和的引擎主题色,去掉暗色背景块。 + 普通代码文字没有下划线,也不会因此变成链接;真实链接和完整代码块保留各自样式。 +- **阅读和输入**:Claude 思考文本默认展开,仍可手动收起;未聚焦编辑器时按 `/` + 只聚焦聊天输入框,再按一次才输入 `/`。输入法组合输入和打开的对话框不受干扰。 + +### 升级 + +**从官方 v4.0.3 升级 Wrapper 需要使用一次安装器流程。** `cc-remote update` 比较 +新旧发布的 SDK/服务协议,遇到本次 SDK 变化会停止,即使没有启用独立 Claude 服务。 +按[安装与升级](https://github.com/muggle-stack/cc-remote/blob/v4.0.4/docs/installation.md) +下载并校验 v4.0.4 安装器;激活前等待 Claude、BTW 和排队消息结束。如果启用了独立 +Claude 服务,还需按 [Claude 服务指南](https://github.com/muggle-stack/cc-remote/blob/v4.0.4/docs/claude-session-service.md) +等待原生任务、回调及后台工作结束,从暂存的新发布完成服务迁移,再激活 Wrapper。 +不要绕过版本检查,也不要重启仍在工作的服务。保留已有安装目录、服务身份和外部配置。 + +安装器会先检查配对的 VPS,跳过已是目标版本的 VPS;其他设备各自更新。完成后重新 +加载网页或 PWA。后续 SDK/服务协议不变的更新可继续使用 `cc-remote update` +(Linux 系统级安装使用 `sudo cc-remote update`)。 + +cc-remote 更新不会代替用户更新原生 Claude Code CLI;如需新模型,请保持日常 CLI +为最新版本并正常登录。v4.0.0 用户同样需要先完成上述安装器升级。 + +本次仍发布 Web、Relay 和 Claude/Codex Wrapper。Electron 桌面客户端、DSH 及 +MCP computer use 开发分支不纳入本次发布;Mac/Linux Wrapper 安装包不是桌面客户端。 + +## English + +v4.0.4 discovers models from the installed Claude Code CLI and improves inline +code emphasis. Product version **4.0.4**, wire protocol **v72**. + +- **Native Claude model discovery:** Web, new chats, BTW side chats and the TUI + use the CLI's model catalog and supported effort levels. Newly reported models + no longer require a static cc-remote model-list change. +- Refresh discovery when opening model settings and cache by account, working + directory and CLI build. Discovery sends no prompt and resumes no session. + Preserve the native default without rewriting existing sessions' explicit models. +- **Native defaults and account isolation:** new Claude sessions inherit native + model and effort defaults. Work discovery uses its runtime policy without reading + Code project settings or replacing Code's cached defaults. +- **Codex catalog refresh:** page reloads, reconnects and picker opens fetch the + native list again. Concurrent reads share one query per account, and discovery + failures preserve only that account's last successful catalog. +- **Codex 0.156 compatibility:** recognize official app-server protected socket + aliases while checking account ownership, private permissions and replacement of + the underlying listener. Update the native CLI separately. +- **SDK upgrade:** Python Claude Agent SDK `0.2.151` → `0.2.157`, still using the + user's daily, authenticated Claude Code CLI. The minimum CLI version remains + `2.1.263`; available models depend on the installed CLI, account and native settings. +- **Inline code emphasis:** softer harness colors replace dark background badges. + Plain code text gains neither an underline nor a link. Actual links and fenced + code blocks retain their respective styles. +- **Reading and input:** expand Claude thinking text by default, with manual + collapse available. Press `/` outside an editor to focus the composer without + inserting text, then press again to type `/`. Respect IME input and open dialogs. + +### Upgrade + +**Wrappers upgrading from official v4.0.3 need a one-time installer upgrade.** +`cc-remote update` compares the old and new release's SDK/service contract and +stops at this SDK change, even without an independent Claude service. Follow the +[installation guide](https://github.com/muggle-stack/cc-remote/blob/v4.0.4/docs/installation_en.md) +to download and verify the v4.0.4 installer. Drain Claude, BTW and queued messages +before activation. If an independent Claude service is enabled, also wait for +native turns, callbacks and background work to finish, then migrate that service +from the staged release using the +[Claude service guide](https://github.com/muggle-stack/cc-remote/blob/v4.0.4/docs/claude-session-service.md) +before activating the Wrapper. Do not bypass version checks or restart a busy +service. Preserve the existing installation root, service identity and configuration. + +The installer checks the paired Relay first and skips it when already at the +target version. Update other devices individually, then reload Web/PWA. Later +updates with an unchanged SDK/service contract can use `cc-remote update` +(`sudo cc-remote update` for system-wide Linux installs). Update and sign in to +the daily Claude Code CLI separately to discover new models; cc-remote updates +do not update that CLI. v4.0.0 users also need the installer upgrade above. + +This release contains Web, Relay and +Claude/Codex Wrappers. Electron, DSH and MCP computer use remain on development +branches; macOS/Linux Wrapper bundles are not desktop clients. diff --git a/requirements-wrapper.lock b/requirements-wrapper.lock index 3bcfe55..a529235 100644 --- a/requirements-wrapper.lock +++ b/requirements-wrapper.lock @@ -134,13 +134,12 @@ cffi==2.1.0 ; platform_python_implementation != 'PyPy' \ # via # -c requirements.lock # cryptography -claude-agent-sdk==0.2.151 \ - --hash=sha256:11d0fe25ca9f3ddad779200770f31fd3c8ac6c01562ff70b0109fd03c6cd22b9 \ - --hash=sha256:45b63f15fce455e81d2e127b56f2a137768933134ecd2e617943c3aff659f119 \ - --hash=sha256:56a437bbbcc928fd1eae59c3d68b104265f67b23afae3f4497eb433714f82240 \ - --hash=sha256:d79396e792c776fb71443c333c77781d59e9adbfd62d607f1b17bf31444cc530 \ - --hash=sha256:d7f49381b393cd01a25d83e444a4804186f4ff33911aa941f9f2dfc341ae04f4 \ - --hash=sha256:f1f0b2940110789999cf16f2bad33580ffca1f6ecdb25807509f584e1a65ef8c +claude-agent-sdk==0.2.157 \ + --hash=sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a \ + --hash=sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6 \ + --hash=sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a \ + --hash=sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f \ + --hash=sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb # via # -c requirements.lock # -r requirements-wrapper.txt diff --git a/requirements-wrapper.txt b/requirements-wrapper.txt index 0c29861..8f76aac 100644 --- a/requirements-wrapper.txt +++ b/requirements-wrapper.txt @@ -1,3 +1,3 @@ -r requirements-common.txt -claude-agent-sdk==0.2.151 +claude-agent-sdk==0.2.157 Pillow==12.3.0 diff --git a/requirements.lock b/requirements.lock index 41a840b..d182627 100644 --- a/requirements.lock +++ b/requirements.lock @@ -361,13 +361,12 @@ charset-normalizer==3.4.9 \ --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests -claude-agent-sdk==0.2.151 \ - --hash=sha256:11d0fe25ca9f3ddad779200770f31fd3c8ac6c01562ff70b0109fd03c6cd22b9 \ - --hash=sha256:45b63f15fce455e81d2e127b56f2a137768933134ecd2e617943c3aff659f119 \ - --hash=sha256:56a437bbbcc928fd1eae59c3d68b104265f67b23afae3f4497eb433714f82240 \ - --hash=sha256:d79396e792c776fb71443c333c77781d59e9adbfd62d607f1b17bf31444cc530 \ - --hash=sha256:d7f49381b393cd01a25d83e444a4804186f4ff33911aa941f9f2dfc341ae04f4 \ - --hash=sha256:f1f0b2940110789999cf16f2bad33580ffca1f6ecdb25807509f584e1a65ef8c +claude-agent-sdk==0.2.157 \ + --hash=sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a \ + --hash=sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6 \ + --hash=sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a \ + --hash=sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f \ + --hash=sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb # via -r requirements.txt click==8.4.2 \ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ diff --git a/requirements.txt b/requirements.txt index 7b3a166..3f6e703 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -claude-agent-sdk==0.2.151 +claude-agent-sdk==0.2.157 websockets==16.0 fastapi==0.139.0 uvicorn==0.50.0 diff --git a/tests/test_child_env.py b/tests/test_child_env.py index 0204d73..2b33266 100644 --- a/tests/test_child_env.py +++ b/tests/test_child_env.py @@ -394,7 +394,7 @@ def test_claude_work_passes_complete_policy_path_without_sdk_replacement( command = transport._build_command() settings_index = command.index("--settings") - # SDK 0.2.151 returns inline JSON here whenever options.sandbox is set, + # SDK 0.2.157 returns inline JSON here whenever options.sandbox is set, # replacing the policy file's complete sandbox object. Work must pass the # wrapper-owned file path verbatim instead. assert command[settings_index + 1] == str(policy) @@ -823,7 +823,7 @@ def test_claude_preflight_inspects_effective_bundled_runtime(monkeypatch): sdk_module, "inspect_claude_runtime", lambda configured: seen.append(configured) or SimpleNamespace( - sdk_version="0.2.151", + sdk_version="0.2.157", cli_version="2.1.220", cli_source="bundled", cli_path="/sdk/_bundled/claude", @@ -841,7 +841,7 @@ def test_claude_preflight_inspects_configured_runtime(monkeypatch, tmp_path): sdk_module, "inspect_claude_runtime", lambda configured: seen.append(configured) or SimpleNamespace( - sdk_version="0.2.151", + sdk_version="0.2.157", cli_version="2.1.220", cli_source="configured", cli_path=configured, diff --git a/tests/test_claude_models.py b/tests/test_claude_models.py new file mode 100644 index 0000000..8f84485 --- /dev/null +++ b/tests/test_claude_models.py @@ -0,0 +1,228 @@ +"""Native model discovery must never submit a prompt or cross account scopes.""" +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from cc_remote.wrapper import claude_models as models + + +def test_native_catalog_keeps_new_models_and_exact_effort_capabilities(): + raw = [ + {"value": "default", "resolvedModel": "claude-opus-5-5", + "displayName": "Default", "supportedEffortLevels": ["high"]}, + {"value": "opus", "resolvedModel": "claude-opus-5-5", + "displayName": "Opus", "description": "Opus 5.5 · Native description", + "supportsEffort": True, "supportedEffortLevels": ["high", "max", "max"]}, + {"value": "haiku", "resolvedModel": "claude-haiku-future", + "supportsEffort": False, "supportedEffortLevels": ["high"]}, + {"value": "provider-deployment", "displayName": "Company model"}, + None, {"value": "bad\nmodel"}, + ] + result = models._normalize(raw) + assert [row["id"] for row in result] == [ + "default", "claude-opus-5-5", "claude-haiku-future", "provider-deployment"] + assert result[1]["display_name"] == "Opus" + assert result[0]["is_default"] is True + assert result[1]["efforts"] == ["high", "max"] + assert result[2]["efforts"] == result[3]["efforts"] == [] + assert raw[0]["displayName"] == "Default" + assert models._normalize({}) == [] + + +@pytest.mark.asyncio +async def test_catalog_cache_isolated_by_account_cwd_and_cli_upgrade(monkeypatch, tmp_path): + models._cache.clear() + binary = tmp_path / "claude" + binary.write_text("old") + monkeypatch.setattr(models, "resolve_claude_cli", lambda _: (str(binary), "configured")) + now = [1.0] + monkeypatch.setattr(models.time, "monotonic", lambda: now[0]) + calls = [] + + async def read(binary, cwd, root, isolated, user_only): + calls.append((binary, cwd, root, isolated, user_only)) + return [{"id": f"model-{len(calls)}"}] + + monkeypatch.setattr(models, "_read_catalog", read) + options = dict(claude_bin=str(binary), cwd=str(tmp_path), + config_dir=str(tmp_path / "personal"), isolate_account_env=True) + first, repeat = await asyncio.gather( + models.claude_model_catalog(**options), models.claude_model_catalog(**options)) + assert first == repeat == [{"id": "model-1"}] + assert len(calls) == 1 + assert await models.claude_model_catalog(**{**options, "config_dir": str(tmp_path / "company")}) != first + assert await models.claude_model_catalog(**{**options, "cwd": str(tmp_path / "project")}) != first + assert await models.claude_model_catalog(**{**options, "cwd": None}) != first + assert calls[-1][-1] is True + binary.write_text("upgraded") + upgraded = await models.claude_model_catalog(**options) + assert upgraded != first + assert len(calls) == 5 + now[0] += models._TTL + 1 + assert await models.claude_model_catalog(**options) != upgraded + assert len(calls) == 6 + + +@pytest.mark.asyncio +async def test_catalog_failure_preserves_only_same_scope_and_backs_off(monkeypatch, tmp_path): + models._cache.clear() + binary = tmp_path / "claude" + binary.touch() + monkeypatch.setattr(models, "resolve_claude_cli", lambda _: (str(binary), "configured")) + read = AsyncMock(return_value=[{"id": "personal-model"}]) + monkeypatch.setattr(models, "_read_catalog", read) + options = dict(claude_bin=str(binary), config_dir=str(tmp_path / "personal")) + saved = await models.claude_model_catalog(**options) + models._cache[next(iter(models._cache))] = (0, saved) + read.side_effect = RuntimeError("private provider error") + assert await models.claude_model_catalog(**options) == saved + assert await models.claude_model_catalog(**{**options, "config_dir": str(tmp_path / "company")}) == [] + assert await models.claude_model_catalog(**options) == saved + assert read.await_count == 3 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("outcome", ["success", "error", "timeout", "cancel"]) +@pytest.mark.parametrize("work_only", [False, True]) +async def test_probe_only_initializes_and_always_reaps_child( + monkeypatch, tmp_path, outcome, work_only, +): + writes = [] + proc = SimpleNamespace( + stdin=SimpleNamespace(write=writes.append, drain=AsyncMock(), close=lambda: None), + stdout=SimpleNamespace(), returncode=None, terminated=False, + ) + + async def line(): + if outcome == "timeout": + await asyncio.Event().wait() + if outcome == "cancel": + raise asyncio.CancelledError() + return (json.dumps({"type": "control_response", "response": { + "subtype": outcome, "request_id": "model-catalog", + "response": {"models": [{"value": "future-model"}]}, + }}) + "\n").encode() + + proc.stdout.readline = line + proc.terminate = lambda: setattr(proc, "terminated", True) + proc.wait = AsyncMock(return_value=0) + async def spawn_child(*args, **kwargs): + if work_only: + path = Path(args[args.index("--settings") + 1]) + assert path.stat().st_mode & 0o777 == 0o600 + assert json.loads(path.read_text()) == { + "model": "work-model", "env": {"ANTHROPIC_API_KEY": "profile-key"}} + return proc + + spawn = AsyncMock(side_effect=spawn_child) + monkeypatch.setattr(models.asyncio, "create_subprocess_exec", spawn) + monkeypatch.setattr(models, "_TIMEOUT", 0.01) + monkeypatch.setenv("ANTHROPIC_API_KEY", "ambient-credential") + monkeypatch.setenv("WRAPPER_TOKEN", "control-credential") + monkeypatch.setenv("CLAUDECODE", "nested") + root = str(tmp_path / "profile") + Path(root).mkdir() + (Path(root) / "settings.json").write_text(json.dumps({ + "model": "work-model", "env": {"ANTHROPIC_API_KEY": "profile-key", + "ANTHROPIC_CUSTOM_MODEL_OPTION": "code-only"}, + "hooks": {"SessionStart": [{"command": "must-not-run"}]}, + "availableModels": ["code-only"], "effortLevel": "max", + })) + request = models._read_catalog("/daily/claude", str(tmp_path), root, True, work_only) + if outcome == "success": + assert (await request)[0]["id"] == "future-model" + else: + with pytest.raises({"error": RuntimeError, "timeout": TimeoutError, + "cancel": asyncio.CancelledError}[outcome]): + await request + args, kwargs = spawn.call_args + assert args[0] == "/daily/claude" + assert {"--no-session-persistence", "--safe-mode", "--strict-mcp-config"} <= set(args) + if work_only: + assert args[-4:-1] == ("--setting-sources", "", "--settings") + assert not Path(args[-1]).exists() + else: + assert args[-2:] == ("--setting-sources", "user") + assert not {"ANTHROPIC_API_KEY", "WRAPPER_TOKEN", "CLAUDECODE"} & kwargs["env"].keys() + assert kwargs["env"]["CLAUDE_CONFIG_DIR"] == root + assert len(writes) == 1 + assert json.loads(writes[0])["request"] == {"subtype": "initialize"} + assert proc.terminated and proc.wait.await_count == 1 + + +@pytest.mark.asyncio +async def test_get_models_routes_selected_profile_without_resuming(monkeypatch, tmp_path): + from cc_remote.protocol import GetModels + from cc_remote.wrapper import machine as machine_module + from tests.test_multisession import _mk_machine + + machine, transport = _mk_machine() + profile = SimpleNamespace(id="company", config_dir=tmp_path / "company") + monkeypatch.setattr(machine, "_claude_profile", lambda profile_id: profile) + monkeypatch.setattr(machine, "_claude_config_root", lambda _: str(profile.config_dir)) + machine._claude_profiles_explicit = True + monkeypatch.setattr(machine, "_claude_new_session_defaults", AsyncMock(return_value=("custom-model", "high"))) + catalog = AsyncMock(return_value=[{"id": "claude-opus-5-5", "efforts": ["high"]}]) + monkeypatch.setattr(machine_module, "claude_model_catalog", catalog) + await machine._handle_get_models(GetModels( + engine="claude", cwd=str(tmp_path), claude_profile_id="company", client_id="client")) + assert catalog.call_args.kwargs == dict( + claude_bin=machine.cfg.claude_bin, cwd=str(tmp_path), + config_dir=str(profile.config_dir), isolate_account_env=True) + event = transport.sent[-1] + assert event.to == "client" and event.claude_profile_id == "company" + assert event.models[0]["id"] == "claude-opus-5-5" + assert not machine.sessions + + +@pytest.mark.asyncio +async def test_work_catalog_never_resolves_code_defaults(monkeypatch, tmp_path): + from cc_remote.protocol import GetModels + from cc_remote.wrapper import machine as machine_module + from tests.test_multisession import _mk_machine + + machine, transport = _mk_machine() + machine.cfg.cc_cwd = str(tmp_path) + defaults = AsyncMock(side_effect=AssertionError("must not read Code defaults")) + monkeypatch.setattr(machine, "_claude_new_session_defaults", defaults) + catalog = AsyncMock(return_value=[{"id": "default", "is_default": True, + "efforts": ["high"]}]) + monkeypatch.setattr(machine_module, "claude_model_catalog", catalog) + await machine._handle_get_models(GetModels(engine="claude")) + event = transport.sent[-1] + assert event.models[0]["id"] == "default" + assert event.cwd is event.default_model is event.default_effort is None + assert catalog.call_args.kwargs["cwd"] is None + defaults.assert_not_called() + assert not machine.sessions + + +@pytest.mark.asyncio +async def test_code_catalog_reports_native_default_without_inventing_effort(monkeypatch, tmp_path): + from cc_remote.protocol import GetModels + from cc_remote.wrapper import machine as machine_module + from tests.test_multisession import _mk_machine + + machine, transport = _mk_machine() + monkeypatch.setattr(machine, "_claude_configured_model", lambda *a, **kw: None) + monkeypatch.setattr(machine_module, "claude_model_catalog", AsyncMock(return_value=[ + {"id": "default", "is_default": True, "efforts": ["low", "high"]}, + ])) + await machine._handle_get_models(GetModels(engine="claude", cwd=str(tmp_path))) + assert transport.sent[-1].default_model == "default" + assert transport.sent[-1].default_effort is None + assert not machine.sessions + + +def test_tui_uses_native_models_and_honors_empty_efforts(): + from cc_remote.tui_settings import SettingsForm + + form = SimpleNamespace(new=False, engine="claude", values={"model": "future"}, + catalog=lambda _: {"models": [ + {"id": "future", "display_name": "Future", "efforts": []}]}) + assert SettingsForm.choices(form, "model") == [("Future", "future")] + assert SettingsForm.choices(form, "effort") == [] diff --git a/tests/test_claude_permission_state.py b/tests/test_claude_permission_state.py index 2147175..61326d6 100644 --- a/tests/test_claude_permission_state.py +++ b/tests/test_claude_permission_state.py @@ -234,8 +234,9 @@ def test_same_claude_model_selection_compares_through_the_pins(): # Surrounding space is never part of the identity. assert same(" claude-fable-5-1 ", "claude-fable-5-1[1m]") # Case folds through the pin table, which is keyed lowercase. - assert same("opus", CLAUDE_DEFAULT_MODEL) - assert same("OPUS", CLAUDE_DEFAULT_MODEL) + assert same("opus", "opus[1m]") + assert not same("opus", CLAUDE_DEFAULT_MODEL) + assert same("OPUS", "opus[1m]") assert same("CLAUDE-FABLE-5-1", "claude-fable-5-1[1m]") # An unpinned id keeps its casing -- only the pin lookup folds. A mixed-case # selection therefore reads as *disagreeing* with a lowercase observation of @@ -943,7 +944,7 @@ async def go(): asyncio.run(go()) -def test_claude_new_session_defaults_use_settings_without_sdk_probe( +def test_claude_new_session_defaults_use_settings_without_session_probe( monkeypatch, tmp_path, ): home = tmp_path / "home" @@ -968,6 +969,12 @@ class ForbiddenProbe: def __init__(self, _cfg): raise AssertionError("default display must not start Claude CLI") + async def catalog(**kwargs): + assert kwargs["cwd"] == str(project / "subdir") + return [{"id": "claude-opus-5-5", "efforts": ["high"]}] + + monkeypatch.setattr(machine_module, "claude_model_catalog", catalog) + async def go(): monkeypatch.setattr(machine_module, "SdkHandle", ForbiddenProbe) machine, transport = _mk_machine() @@ -979,10 +986,11 @@ async def go(): await machine._handle_get_models(command) assert len(transport.sent) == 1 - assert all(event.models == [] for event in transport.sent) + assert all(event.models[0]["id"] == "claude-opus-5-5" + for event in transport.sent) assert all(event.default_model == "claude-mythos-5[1m]" for event in transport.sent) - assert all(event.default_effort == "max" + assert all(event.default_effort is None for event in transport.sent) assert all(event.cwd == str(project / "subdir") for event in transport.sent) @@ -1003,13 +1011,13 @@ async def go(): assert machine._claude_configured_model(str(project)) is None fallback_model, fallback_effort = ( await machine._claude_new_session_defaults(str(project))) - assert fallback_model == CLAUDE_DEFAULT_MODEL - assert fallback_effort == "max" + assert fallback_model is None + assert fallback_effort is None monkeypatch.delenv("ANTHROPIC_MODEL") for configured, expected in ( - ("opus", CLAUDE_DEFAULT_MODEL), - ("opus[1m]", CLAUDE_DEFAULT_MODEL), + ("opus", "opus[1m]"), + ("opus[1m]", "opus[1m]"), ("claude-opus-5", CLAUDE_DEFAULT_MODEL), (CLAUDE_DEFAULT_MODEL, CLAUDE_DEFAULT_MODEL), ("claude-fable-5-1", "claude-fable-5-1[1m]"), @@ -1075,9 +1083,11 @@ def test_claude_multi_profile_default_ignores_project_and_ambient_models( ) is None -def test_fresh_claude_spawn_applies_the_resolved_default_model( +@pytest.mark.parametrize("space", ["code", "work"]) +def test_fresh_claude_spawn_inherits_native_defaults( monkeypatch, tmp_path, + space, ): home = tmp_path / "home" project = tmp_path / "project" @@ -1099,14 +1109,27 @@ async def go(): SdkHandle, "preflight", staticmethod(lambda _path: None)) machine, _ = _mk_machine() machine._load_history = lambda *_args: asyncio.sleep(0) + work_id = None + cwd = str(project) + if space == "work": + record = machine._work.for_engine("claude").create_session( + claude_profile_id=machine._claude_profiles.default.id) + work_id = record.work_id + cwd = record.cwd + + async def forbidden_default(*args, **kwargs): + raise AssertionError("Work must inherit its isolated policy") + + machine._claude_new_session_defaults = forbidden_default ctx = await machine._spawn( - resume_id=None, cwd=str(project), engine="claude") + resume_id=None, cwd=cwd, engine="claude", space=space, work_id=work_id) assert ctx is not None - assert ctx.sdk.model == CLAUDE_DEFAULT_MODEL - assert _FakeClaudeClient.created[-1].model_calls == [ - CLAUDE_DEFAULT_MODEL] + assert ctx.sdk.model == "claude-mythos-5" + assert _FakeClaudeClient.created[-1].model_calls == [] + assert _FakeClaudeClient.created[-1].options.model is None + assert _FakeClaudeClient.created[-1].options.effort is None await ctx.sdk.disconnect() asyncio.run(go()) @@ -1148,8 +1171,8 @@ async def forbidden_default(_cwd): @pytest.mark.parametrize( ("requested", "expected"), [ - ("opus", CLAUDE_DEFAULT_MODEL), - ("opus[1m]", CLAUDE_DEFAULT_MODEL), + ("opus", "opus[1m]"), + ("opus[1m]", "opus[1m]"), ("claude-opus-5", CLAUDE_DEFAULT_MODEL), (CLAUDE_DEFAULT_MODEL, CLAUDE_DEFAULT_MODEL), ("claude-fable-5-1", "claude-fable-5-1[1m]"), @@ -1210,6 +1233,9 @@ async def go(): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + settings = tmp_path / ".claude" / "settings.json" + settings.parent.mkdir() + settings.write_text(json.dumps({"model": CLAUDE_DEFAULT_MODEL})) monkeypatch.setattr( machine_module.WrapperMachine, "_claude_managed_settings_paths", diff --git a/tests/test_codex_daemon.py b/tests/test_codex_daemon.py index 966cbde..d3eeb8b 100644 --- a/tests/test_codex_daemon.py +++ b/tests/test_codex_daemon.py @@ -260,10 +260,11 @@ def test_managed_daemon_identity_uses_same_user_pid_and_start_token( assert daemon_module._managed_daemon_process_identity(tmp_path) is None -@pytest.fixture -def standalone_listener(): +@pytest.fixture(params=[False, True], ids=["socket", "native-alias"]) +def standalone_listener(request, monkeypatch): # Keep the path below macOS's Unix socket length limit. - with tempfile.TemporaryDirectory(prefix="cc-sock-", dir="/tmp") as root: + with (tempfile.TemporaryDirectory(prefix="cc-sock-", dir="/tmp") as root, + tempfile.TemporaryDirectory(prefix="ca-", dir="/tmp") as protected): home = Path(root).resolve() control = home / "app-server-control" control.mkdir(mode=0o700) @@ -272,6 +273,12 @@ def standalone_listener(): listener.bind(str(path)) path.chmod(0o600) listener.listen() + if request.param: + directory = Path(protected).resolve() + monkeypatch.setattr(daemon_module, "_protected_socket_directory", lambda _uid: directory) + target = directory / hashlib.sha256(os.fsencode(path)).hexdigest() + path.rename(target) + path.symlink_to(target) yield home, path diff --git a/tests/test_codex_models.py b/tests/test_codex_models.py new file mode 100644 index 0000000..7c54438 --- /dev/null +++ b/tests/test_codex_models.py @@ -0,0 +1,156 @@ +"""Native model discovery stays fresh on explicit reads and isolated by account.""" + +import asyncio + +import pytest + +from cc_remote.wrapper import codex_models as models + + +@pytest.fixture(autouse=True) +def isolated_catalog(monkeypatch): + monkeypatch.setattr(models, "_profile_cache", {}) + monkeypatch.setattr(models, "_inflight", {}) + + +def ids(catalog): + return [model["id"] for model in catalog] + + +@pytest.mark.parametrize("home", [None, "/account/stack"]) +def test_explicit_refresh_replaces_fresh_cache_and_updates_internal_lookups( + monkeypatch, home, +): + raw = [{"id": "existing"}] + calls = [] + + async def query(codex_home): + calls.append(codex_home) + return list(raw) + + monkeypatch.setattr(models, "_rpc_model_list", query) + + async def run(): + assert ids(await models.codex_catalog(codex_home=home)) == ["existing"] + raw.append({"id": "newly-available"}) + assert ids(await models.codex_catalog(codex_home=home)) == ["existing"] + assert ids(await models.codex_catalog(force=True, codex_home=home)) == [ + "existing", "newly-available", + ] + assert ids(await models.codex_catalog(codex_home=home)) == [ + "existing", "newly-available", + ] + + asyncio.run(run()) + assert calls == [home, home] + + +def test_refreshes_share_inflight_query_without_blocking_other_accounts(monkeypatch): + async def run(): + started, release = asyncio.Event(), asyncio.Event() + calls = [] + + async def query(home): + calls.append(home) + if home == "/account/slow": + started.set() + await release.wait() + return [{"id": home}] + + monkeypatch.setattr(models, "_rpc_model_list", query) + first = asyncio.create_task(models.codex_catalog( + force=True, codex_home="/account/slow")) + await started.wait() + second = asyncio.create_task(models.codex_catalog( + force=True, codex_home="/account/slow/../slow")) + await asyncio.sleep(0) + fast = await asyncio.wait_for(models.codex_catalog( + force=True, codex_home="/account/fast"), timeout=1) + assert ids(fast) == ["/account/fast"] + assert not first.done() and not second.done() + release.set() + assert ids(await first) == ids(await second) == ["/account/slow"] + assert calls == ["/account/slow", "/account/fast"] + + asyncio.run(run()) + + +def test_disconnected_reader_does_not_cancel_shared_refresh(monkeypatch): + async def run(): + started, release = asyncio.Event(), asyncio.Event() + calls = 0 + + async def query(_home): + nonlocal calls + calls += 1 + started.set() + await release.wait() + return [{"id": "new"}] + + monkeypatch.setattr(models, "_rpc_model_list", query) + first = asyncio.create_task(models.codex_catalog(force=True)) + await started.wait() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + second = asyncio.create_task(models.codex_catalog(force=True)) + await asyncio.sleep(0) + release.set() + assert ids(await second) == ["new"] + assert ids(await models.codex_catalog()) == ["new"] + assert calls == 1 + assert models._inflight == {} + + asyncio.run(run()) + + +@pytest.mark.parametrize("failure", ["empty", "exception"]) +def test_failed_refresh_preserves_only_own_catalog_and_allows_retry( + monkeypatch, failure, +): + unavailable = False + + async def query(home): + if unavailable: + if failure == "exception": + raise RuntimeError("native discovery unavailable") + return [] + return [{"id": home}] + + monkeypatch.setattr(models, "_rpc_model_list", query) + + async def run(): + nonlocal unavailable + old = await models.codex_catalog(codex_home="/account/one") + before = dict(models._profile_cache) + unavailable = True + assert await models.codex_catalog(force=True, codex_home="/account/one") == old + assert await models.codex_catalog(force=True, codex_home="/account/two") == [] + assert models._profile_cache == before + unavailable = False + assert ids(await models.codex_catalog(force=True, codex_home="/account/two")) == [ + "/account/two", + ] + + asyncio.run(run()) + + +def test_internal_cache_expires_without_user_refresh(monkeypatch): + now = 100.0 + calls = 0 + + async def query(_home): + nonlocal calls + calls += 1 + return [{"id": f"model-{calls}"}] + + monkeypatch.setattr(models, "_rpc_model_list", query) + monkeypatch.setattr(models.time, "monotonic", lambda: now) + + async def run(): + nonlocal now + assert ids(await models.codex_catalog()) == ["model-1"] + now += models._TTL + 1 + assert ids(await models.codex_catalog()) == ["model-2"] + + asyncio.run(run()) diff --git a/tests/test_codex_profiles.py b/tests/test_codex_profiles.py index ae1f440..00b27b2 100644 --- a/tests/test_codex_profiles.py +++ b/tests/test_codex_profiles.py @@ -3143,7 +3143,8 @@ def test_profile_model_reads_use_catalog_default_from_matching_home( stack_home = str((tmp_path / "stack").resolve()) calls: list[tuple[str, str | None]] = [] - async def catalog(*, codex_home=None): + async def catalog(*, force=False, codex_home=None): + assert force is True, "an explicit picker read must refresh native models" calls.append(("models", codex_home)) return [{ "id": "stack-model", @@ -3224,6 +3225,57 @@ async def run() -> None: ] +@pytest.mark.parametrize("explicit_profiles", [False, True]) +def test_reloaded_picker_discovers_new_model_before_internal_cache_expires( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, explicit_profiles: bool, +) -> None: + from cc_remote.wrapper import codex_models + + cfg = WrapperConfig() + cfg.state_dir = tmp_path / "state" + cfg.claude_work_root = tmp_path / "work" / "claude" + cfg.codex_work_root = tmp_path / "work" / "codex" + cfg.codex_profiles_json = ( + _profiles(tmp_path / "primary", tmp_path / "stack") + if explicit_profiles else "" + ) + transport = _StubTransport() + machine = WrapperMachine(cfg, transport) + profile = machine._codex_profile("stack" if explicit_profiles else None) + home = machine._codex_home(profile) + raw = [{"id": "old", "isDefault": True}] + calls = [] + + async def query(codex_home): + calls.append(codex_home) + return list(raw) + + monkeypatch.setattr(codex_models, "_profile_cache", {}) + monkeypatch.setattr(codex_models, "_inflight", {}) + monkeypatch.setattr(codex_models, "_rpc_model_list", query) + monkeypatch.setattr(machine_module, "codex_model", lambda *a, **kw: "") + monkeypatch.setattr(machine_module, "codex_effort", lambda *a, **kw: "") + + async def run(): + command = SimpleNamespace( + engine="codex", codex_profile_id=profile.id, + cmd_id="models-1", client_id="client-1", cwd=None, + ) + first = await machine._handle_get_models(command) + assert [m["id"] for m in first.models] == ["old"] + raw.append({"id": "newly-available"}) + # A reload sends the same get_models command through a new client. + command.client_id = "client-reloaded" + command.cmd_id = "models-2" + second = await machine._handle_get_models(command) + assert [m["id"] for m in second.models] == ["old", "newly-available"] + assert second.to == "client-reloaded" + assert second.codex_profile_id == profile.id + assert calls == [home, home] + + asyncio.run(run()) + + def test_profile_model_resolution_isolated_and_fails_closed_for_explicit_choice( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_codex_readiness.py b/tests/test_codex_readiness.py index cd52d4c..e17613b 100644 --- a/tests/test_codex_readiness.py +++ b/tests/test_codex_readiness.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import hashlib import json import os from pathlib import Path @@ -77,10 +78,11 @@ async def run(binary, env, *args): assert calls == ["--help", "--help", "version", "start", "version"] -@pytest.fixture -def account_socket(): +@pytest.fixture(params=[False, True], ids=["socket", "native-alias"]) +def account_socket(request, monkeypatch): # macOS Unix paths are limited to 104 bytes; pytest's path can exceed that. - with tempfile.TemporaryDirectory(prefix="cc-readiness-", dir="/tmp") as directory: + with (tempfile.TemporaryDirectory(prefix="cc-readiness-", dir="/tmp") as directory, + tempfile.TemporaryDirectory(prefix="ca-", dir="/tmp") as protected): home = Path(directory).resolve() path = home / "app-server-control/app-server-control.sock" path.parent.mkdir(mode=0o700) @@ -88,6 +90,12 @@ def account_socket(): listener.bind(str(path)) path.chmod(0o600) listener.listen() + if request.param: + root = Path(protected).resolve() + monkeypatch.setattr(daemon, "_protected_socket_directory", lambda _uid: root) + target = root / hashlib.sha256(os.fsencode(path)).hexdigest() + path.rename(target) + path.symlink_to(target) yield home, path @@ -235,6 +243,44 @@ def test_installer_checks_listener_again_before_printing_ready(tmp_path, account assert result["profiles"][0]["reason"] == "daemon_changed" +@pytest.mark.parametrize("unsafe", ["other_account", "target_symlink", "permissions", "directory"]) +def test_native_alias_rejects_unsafe_targets(account_socket, unsafe): + _, path = account_socket + if not path.is_symlink(): + return + target = path.resolve() + if unsafe == "other_account": + other = path.with_name("other-account.sock") + other.symlink_to(target) + path = other + elif unsafe == "target_symlink": + renamed = target.with_name("redirected") + target.rename(renamed) + target.symlink_to(renamed) + elif unsafe == "permissions": + target.chmod(0o666) + else: + target.parent.chmod(0o750) + with pytest.raises(ValueError): + readiness.socket_identity(str(path)) + + +def test_receipt_fences_physical_replacement_behind_native_alias(tmp_path, account_socket): + _, path = account_socket + target = path.resolve() + row = {"profile": "account", "status": "ready", "socket": str(path), + "socket_identity": list(readiness.socket_identity(str(path)))} + before = time.time() + readiness.write_report(tmp_path, [row]) + target.unlink() + with socket.socket(socket.AF_UNIX) as replacement: + replacement.bind(str(target)) + target.chmod(0o600) + replacement.listen() + receipt = installer.read_receipt(tmp_path / readiness.REPORT_NAME, readiness.SOURCE_ROOT, before) + assert receipt["profiles"][0]["reason"] == "daemon_changed" + + @pytest.mark.parametrize("kind", ["plist", "env-file"]) def test_installer_uses_service_state_root_without_executing_config(tmp_path, kind, capsys): state = tmp_path / "custom state" diff --git a/tests/test_deploy.py b/tests/test_deploy.py index ca2d779..1107cf9 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -883,7 +883,7 @@ def test_release_docs_and_examples_describe_one_atomic_layout(): assert "WEB_STATIC_DIR=/opt/cc-remote/current/web/dist" in relay_env assert "WorkingDirectory=/opt/cc-remote/current" in unit assert "ExecStart=/opt/cc-remote/current/.venv/bin/python" in unit - assert "claude-agent-sdk==0.2.151" in claude + assert "claude-agent-sdk==0.2.157" in claude assert f"protocol v{PROTOCOL_VERSION}" in claude assert "0.2.110" not in claude assert "protocol v10" not in claude diff --git a/tests/test_engine_versions.py b/tests/test_engine_versions.py index b668abb..cadd7da 100644 --- a/tests/test_engine_versions.py +++ b/tests/test_engine_versions.py @@ -12,15 +12,19 @@ def test_claude_sdk_policy_is_exact(): - assert claude_runtime.validate_sdk_version("0.2.151") == "0.2.151" - with pytest.raises(RuntimeError, match="not the verified 0.2.151"): - claude_runtime.validate_sdk_version("0.2.150") + assert claude_runtime.validate_sdk_version("0.2.157") == "0.2.157" + for unsupported in ("0.2.151", "0.2.158"): + with pytest.raises(RuntimeError, match="not the verified 0.2.157"): + claude_runtime.validate_sdk_version(unsupported) def test_verified_claude_sdk_matches_dependency_pin(): expected = f"claude-agent-sdk=={claude_runtime.VERIFIED_SDK_VERSION}" - assert expected in (ROOT / "requirements.txt").read_text() - assert expected in (ROOT / "requirements.lock").read_text() + for name in ( + "requirements.txt", "requirements.lock", + "requirements-wrapper.txt", "requirements-wrapper.lock", + ): + assert expected in (ROOT / name).read_text() def test_claude_runtime_prefers_bundle_then_external(monkeypatch, tmp_path): diff --git a/tests/test_product_version.py b/tests/test_product_version.py index b28057b..013035d 100644 --- a/tests/test_product_version.py +++ b/tests/test_product_version.py @@ -13,7 +13,7 @@ def test_product_version_is_consistent_across_runtime_and_web_metadata(): - assert __version__ == "4.0.3" + assert __version__ == "4.0.4" assert re.fullmatch(r"[1-9]\d*\.\d+\.\d+", __version__) package = json.loads((ROOT / "web/package.json").read_text()) diff --git a/web/package-lock.json b/web/package-lock.json index 30b1b19..b4e2ce1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "4.0.3", + "version": "4.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "4.0.3", + "version": "4.0.4", "license": "MIT", "dependencies": { "@tanstack/react-virtual": "3.14.8", diff --git a/web/package.json b/web/package.json index c9886b0..3a7ad1e 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "4.0.3", + "version": "4.0.4", "author": "muggle", "license": "MIT", "type": "module", diff --git a/web/public/cc-remote-build.json b/web/public/cc-remote-build.json index eca0e52..8ece8cf 100644 --- a/web/public/cc-remote-build.json +++ b/web/public/cc-remote-build.json @@ -1,4 +1,4 @@ { - "version": "4.0.3", + "version": "4.0.4", "protocol": 72 } diff --git a/web/src/App.tsx b/web/src/App.tsx index 499167c..d211ff0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -45,7 +45,8 @@ import { resolveNewChatLocalDefaults, } from "./new-chat-selection"; const NewChatView = lazy(() => import("./components/NewChatView").then(m => ({ default: m.NewChatView }))); -import { QuestionSheet } from "./components/QuestionSheet"; +const QuestionSheet = lazy(() => import("./components/QuestionSheet").then( + m => ({ default: m.QuestionSheet }))); import { WorkDashboardSheet } from "./components/WorkDashboardSheet"; import type { HookDraft, SkillDraft } from "./components/CapabilitiesSheet"; import { TerminalControl } from "./components/TerminalControl"; @@ -323,11 +324,12 @@ function catalogForEngineProfile( catalog: Catalog, engine: Engine, profileId?: string | null, + cwd?: string | null, ): Catalog { - const scoped = catalog[modelCatalogScopeKey(engine, profileId)]; + const scoped = catalog[modelCatalogScopeKey(engine, profileId, cwd)]; return { ...catalog, - [engine]: scoped ?? (profileId ? [] : (catalog[engine] ?? [])), + [engine]: scoped ?? [], }; } @@ -1031,7 +1033,8 @@ export default function App() { const newChatCatalogScopeKey = modelCatalogScopeKey( engine, newChatProfileId); const newChatCatalog = catalogForEngineProfile( - state.catalog, engine, newChatProfileId); + state.catalog, engine, newChatProfileId, + space === "code" ? newChatCwd : undefined); const newChatDefaults = resolveNewChatLocalDefaults( engine, space, @@ -1111,7 +1114,8 @@ export default function App() { ? nativeProfileSessionId(rt.ccSessionId) : rt.ccSessionId); const focusedCatalog = catalogForEngineProfile( - state.catalog, focusedEngine, focusedAccountProfileId); + state.catalog, focusedEngine, focusedAccountProfileId, + space === "code" ? focusedSession?.cwd || currentCwd : undefined); const completedGoalRetired = completedGoalHasNewerUserTurn( rt.goal, rt.turns, ) || completedGoalHasNewerUserTurn(rt.goal, historyView.turns); @@ -1147,6 +1151,9 @@ export default function App() { } const planProgressSource = planProgress?.source ?? null; const capabilityCwd = focusedSession?.cwd || currentCwd; + const requestFocusedModels = () => wsRef.current?.sendGetModels( + focusedEngine, focusedEngine === "claude" && space === "code" ? capabilityCwd : undefined, + focusedCodexProfileId, focusedClaudeProfileId); const focusedComposerDraftKey = composerDraftKey( machineId, space, focusedEngine, focusedSid ?? "", ); @@ -1846,11 +1853,10 @@ export default function App() { state.newChat, ]); useEffect(() => { - if (state.newChat || !focusedAccountProfileId - || state.connState !== "connected" || !state.wrapperOnline) return; + if (state.newChat || state.connState !== "connected" || !state.wrapperOnline) return; wsRef.current?.sendGetModels( focusedEngine, - focusedEngine === "claude" ? capabilityCwd : undefined, + focusedEngine === "claude" && space === "code" ? capabilityCwd : undefined, focusedCodexProfileId, focusedClaudeProfileId); }, [ @@ -1859,6 +1865,7 @@ export default function App() { focusedClaudeProfileId, focusedCodexProfileId, focusedEngine, + space, state.connState, state.newChat, state.wrapperOnline, @@ -4099,10 +4106,25 @@ export default function App() { }; }, [focusedSid, requestHistory, state.connState]); - // Cmd/Ctrl+B => toggle sidebar; Cmd/Ctrl+Shift+B => open latest turn's diff + // An unfocused "/" focuses the composer; a second press types normally. + // Cmd/Ctrl+B => toggle sidebar; Cmd/Ctrl+Shift+B => open latest turn's diff. useEffect(() => { if (!authed) return; const onKey = (e: KeyboardEvent) => { + if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) { + if (e.defaultPrevented || e.isComposing || e.repeat + || e.composedPath().some((target) => target instanceof HTMLElement + && (target.matches("input, textarea, select, [role=textbox]") + || target.isContentEditable)) + || document.querySelector('[role="dialog"], [aria-modal="true"], dialog[open]')) return; + const input = document.querySelector( + "textarea[data-chat-composer]:not(:disabled):not([readonly])"); + if (input) { + e.preventDefault(); + input.focus({ preventScroll: true }); + } + return; + } if (!(e.metaKey || e.ctrlKey)) return; const k = e.key.toLowerCase(); if (k === "b" && e.shiftKey) { // diff (shared right slot) @@ -5720,6 +5742,9 @@ export default function App() { autoFocus={newChatAutoFocus} engine={engine} catalog={newChatCatalog} + onRequestModels={() => wsRef.current?.sendGetModels( + engine, engine === "claude" && space === "code" ? state.newChat?.cwd : undefined, + newChatCodexProfileId, newChatClaudeProfileId)} model={state.newChat.model} effort={state.newChat.effort} autoCompact={{ @@ -5903,6 +5928,7 @@ export default function App() { surface={space} state={rt.state} catalog={focusedCatalog} + onRequestModels={requestFocusedModels} connState={state.connState} wrapperOnline={state.wrapperOnline} sendMode={rt.sendMode} @@ -6098,6 +6124,7 @@ export default function App() { onSelect={selectBtw} onCloseChat={closeBtw} catalog={focusedCatalog} + onRequestModels={requestFocusedModels} draftKey={activeBtwDraftKey} draftStore={btwDraftsRef.current} sendMode={activeBtwSendMode} unconfirmedQueued={unconfirmedQueued} @@ -6193,6 +6220,8 @@ export default function App() { onSave={updateQueuedQuery} onRetry={retryQueuedQuery} />} {rt.pendingQuestion && !activeBtwQuestionVisible && ( + }> + )} {shouldOpenCodexStatus(statusOpenSid, focusedSid, focusedEngine) && diff --git a/web/src/components/BtwPanel.tsx b/web/src/components/BtwPanel.tsx index aad75bf..25fb45d 100644 --- a/web/src/components/BtwPanel.tsx +++ b/web/src/components/BtwPanel.tsx @@ -104,6 +104,7 @@ interface Props { onRemoveQueued: (query: PendingQuery) => void; onInspectQueued: (query: PendingQuery) => void; onSetModel: (model: string) => void; + onRequestModels?: () => void; onSetEffort: (effort: string) => void; onSetServiceTier: (tier: string) => boolean; onSetAutoCompact: (selection: AutoCompactSelection) => boolean; @@ -622,7 +623,7 @@ export function BtwPanel(p: Props) {
BTW 设置 - diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index a9969f6..1cbb47b 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -104,6 +104,7 @@ interface Props { engine?: "claude" | "codex"; archived?: boolean; catalog?: Catalog; // engine-reported models/efforts; falls back to data.ts + onRequestModels?: () => void; editPrompt: string | null; onEditConsumed: () => void; onSendQuery: (prompt: string, images?: QueryImg[], files?: QueryFile[]) => boolean; @@ -505,7 +506,7 @@ export function Composer(p: Props) { switch (slash) { case "model": if (args) { p.onSetModel(args); flash(`正在切换模型:${args}`); } - else setSheetKind("models"); + else { p.onRequestModels?.(); setSheetKind("models"); } break; case "permissions": openPermissions(); break; case "clear": p.onClear(); break; @@ -769,6 +770,7 @@ export function Composer(p: Props) { const inputControl = (placeholder: string) => (