diff --git a/AGENTS.md b/AGENTS.md index 374dcf7bd..e4bcc106b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,17 @@ custom registration names and full proxy URLs are preserved. Its name and displa are `ug`, with plugin ID `ug@unity-gateway-cowork`. Reconfigure removes the old `unity-gateway@unity-gateway-cowork` registration only after installing/updating `ug`; revert removes both IDs. New sessions use the `mcp__plugin_ug_...` tool namespace. +Relay mode additionally owns `ug-account-docs@unity-gateway-cowork` in that marketplace. +The plugin ID is retained from the Docs-only prototype; it now imports all returned +eligible account connectors and enabled skills. Names are sanitized with deterministic +collision suffixes inside that separate namespace. The existing hidden `cowork-hook` +command refreshes those skills on SessionStart; connector changes require reconfigure. +Per-skill failures preserve prior copies for the same organization and skill ID; confirmed +disabled/deleted skills are removed using an ownership manifest. Supporting files retain +executable permissions. Whole discovery failures preserve the previous account plugin. +Its dedicated stdio proxy uses the subscription token refresh helper without Databricks +credential minting. Tokens are never stored in generated configuration. Non-relay mode +removes the additional plugin on configure; native Cowork validation remains required. See `docs/claude-desktop.md` for native validation limits and revert behavior.
diff --git a/docs/claude-desktop.md b/docs/claude-desktop.md index 4a42bc1b5..ad4b46107 100644 --- a/docs/claude-desktop.md +++ b/docs/claude-desktop.md @@ -183,6 +183,56 @@ state, setup skips silently. Only `ug revert` removes the plugin registration an marketplace. The native Desktop gateway profile is separate and remains under the cleanup rules below. +## Anthropic account resources in relay mode + +When `claude_relayed` is enabled, Cowork setup additionally installs +`ug-account-docs@unity-gateway-cowork`. The ID is retained from the Docs-only prototype; +the plugin now imports all eligible connectors and enabled skills returned by the +account APIs, including custom skills. The ordinary `ug` workspace plugin and its +`system.ai` MCPs remain available, and Desktop stays in gateway/third-party mode. +Managed configure uses Claude's newly configured relay state for Cowork and carries it +forward when configuring other agents, so an earlier mode cannot remove account resources. + +The additional plugin uses Anthropic's internal account APIs with the same Claude +subscription credential as relay authentication. Connector discovery needs the recorded +`user:mcp_servers` grant; account skill access uses the account's existing permissions. +UG stores no bearer in plugin files. Its stdio proxy reads or silently refreshes the +credential for each HTTP request and forwards MCP instructions, tool schemas, and tool +results unchanged. Each proxy checks that its configured connector ID remains eligible +for the current account. Connector additions, removals, or account changes require +reconfigure and a fresh task. Account API and proxy requests +do not follow redirects. These APIs are undocumented and can change. + +Configure downloads each enabled skill's `SKILL.md` and supporting files into the separate +plugin. Files retain their executable permission when provided by the archive. Skill and +connector names are normalized to safe names, with deterministic suffixes for collisions; +the account namespace keeps them separate from workspace skills/connectors of the same name. +Its SessionStart handler uses the existing hidden `ug cowork-hook` command to refresh +enabled skills and request `reloadSkills` before the first turn. Downloads run with up to +four workers; the hook has a 180-second timeout. Confirmed disabled or deleted skills +are removed using an ownership manifest. Individual download failures warn and preserve +prior copies for the same organization and skill ID; other downloads still proceed. +A failed or explicitly incomplete catalog leaves existing resources intact. ZIP archives +are limited to 32 MiB of unpacked files and 512 files per skill; unsafe paths, symlinks, +case-colliding files, and invalid skill roots are rejected. +Both proxy and hook bind to configure's original Claude configuration directory; +Cowork's inherited `CLAUDE_CONFIG_DIR` cannot select a different Keychain entry. Hook +refresh never opens an interactive sign-in flow. Disabling relay removes this additional +plugin on reconfigure; revert cleans up both plugins. + +The API path was checked manually on macOS: account connector listing, MCP initialization, +tool discovery, a read-only `guide` invocation, account skill listing, and `docs` ZIP +download all succeeded with the saved subscription credential. A manual third-party +Cowork smoke check also loaded the imported docs skill, called its read-only `guide`, +and discovered workspace tools in the same task. Component tests cover multi-connector +generation and skill refresh with isolated HTTP/authentication boundaries. +**Broader account skill downloads, other account connectors, and repeated same-session +skill reload still need native validation.** After reconfigure, start a fresh task and +discover both account and workspace connector tools, load an imported skill other than +docs, and check a read-only account connector operation. Do not create or edit content +for this check. Importing a skill does not provide missing native browser, artifact, +computer-use, or other tools; skills that depend on those capabilities may be unusable. + ## Models and authentication UG uses an admin's static model list when supplied. Otherwise it obtains the diff --git a/src/ucode/agents/desktop/anthropic_cowork.py b/src/ucode/agents/desktop/anthropic_cowork.py new file mode 100644 index 000000000..ea37ef5f8 --- /dev/null +++ b/src/ucode/agents/desktop/anthropic_cowork.py @@ -0,0 +1,461 @@ +"""Relay-only Anthropic account connectors and skills.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import re +import shutil +import stat +import tempfile +import zipfile +from collections import Counter +from collections.abc import Callable, Iterator +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from urllib.parse import quote + +import httpx + +from ucode import config_io +from ucode.desktop_auth import get_claude_subscription_token +from ucode.os_compatibility import subprocess_cross_os +from ucode.os_compatibility.file_lock_cross_os import ( + acquire_exclusive_file_lock, + release_file_lock, +) +from ucode.ui import print_warning + +PLUGIN_NAME = "ug-account-docs" +PLUGIN_ID = f"{PLUGIN_NAME}@unity-gateway-cowork" +MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 +SKILL_MANIFEST = ".account-skills.json" + + +@dataclass +class SkillFile: + content: bytes + executable: bool = False + + +@dataclass +class AccountSkill: + id: str + name: str + files: dict[str, SkillFile] | None + + +@dataclass +class AccountResources: + connectors: dict[str, str] + skills: list[AccountSkill] + org: str + claude_binary: str + config_dir: str + + +@contextmanager +def credential_source(config_dir: str) -> Iterator[None]: + keys = ("CLAUDE_CONFIG_DIR", "CLAUDE_HELPER_CONTEXT") + previous = {key: os.environ.get(key) for key in keys} + if config_dir: + os.environ["CLAUDE_CONFIG_DIR"] = config_dir + else: + os.environ.pop("CLAUDE_CONFIG_DIR", None) + os.environ["CLAUDE_HELPER_CONTEXT"] = "background" + try: + yield + finally: + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +class AccountClient: + def __init__( + self, + claude_binary: str, + *, + token_provider: Callable[[], str] | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.token_provider = token_provider or ( + lambda: get_claude_subscription_token(claude_binary) + ) + self.client = httpx.Client( + base_url="https://api.anthropic.com", + timeout=10, + follow_redirects=False, + transport=transport, + headers={ + "anthropic-version": "2023-06-01", + "User-Agent": "unity-gateway-cowork", + }, + ) + + def __enter__(self) -> AccountClient: + return self + + def __exit__(self, *args: object) -> None: + self.client.close() + + def get(self, path: str, *, org: str | None = None, connectors: bool = False) -> bytes: + if not path.startswith("/") or path.startswith("//"): + raise RuntimeError("Anthropic account requests require a relative API path.") + headers = {"Authorization": f"Bearer {self.token_provider()}"} + if org: + headers["x-organization-uuid"] = org + if connectors: + headers["anthropic-beta"] = "mcp-servers-2025-12-04" + try: + with self.client.stream("GET", path, headers=headers) as response: + if response.status_code != 200: + raise RuntimeError( + f"Anthropic account API returned HTTP {response.status_code}." + ) + body = bytearray() + for chunk in response.iter_bytes(): + body.extend(chunk) + if len(body) > MAX_ARCHIVE_BYTES: + raise RuntimeError("Anthropic account response exceeded 32 MiB.") + return bytes(body) + except httpx.HTTPError: + raise RuntimeError("Could not reach the Anthropic account API.") from None + + def json(self, path: str, **kwargs) -> dict: + try: + result = json.loads(self.get(path, **kwargs)) + except (ValueError, TypeError): + raise RuntimeError("Anthropic account API returned invalid JSON.") from None + if not isinstance(result, dict): + raise RuntimeError("Anthropic account API returned an unexpected response.") + return result + + def skills(self) -> tuple[str, list[AccountSkill]]: + profile = self.json("/api/oauth/profile") + organization = profile.get("organization") + org = organization.get("uuid") if isinstance(organization, dict) else None + if not isinstance(org, str) or not org: + raise RuntimeError("Anthropic account profile has no organization UUID.") + path = f"/api/oauth/organizations/{quote(org, safe='')}/skills" + result = self.json(f"{path}/list-skills?include_wiggle_skills=true", org=org) + catalog = _catalog(result, "skills") + enabled = [s for s in catalog if s.get("enabled") is True] + aliases = _aliases(enabled, "name") + + def download(skill: dict) -> AccountSkill: + skill_id = skill["id"] + name = aliases[skill_id] + try: + files = unpack_skill( + self.get(f"{path}/{quote(skill_id, safe='')}/download", org=org) + ) + except RuntimeError as exc: + print_warning(f"Could not download Anthropic skill {name}: {exc}") + files = None + return AccountSkill(skill_id, name, files) + + with ThreadPoolExecutor(max_workers=4) as pool: + return org, list(pool.map(download, enabled)) + + def connectors(self) -> dict[str, str]: + result = self.json( + "/v1/mcp_servers?limit=1000&include_additional_installs=true", connectors=True + ) + servers = _catalog(result, "data") + eligible = [server for server in servers if server.get("eligible") is True] + return {name: server_id for server_id, name in _aliases(eligible, "display_name").items()} + + +def _catalog(result: dict, key: str) -> list[dict]: + items = result.get(key) + if not isinstance(items, list) or not all(isinstance(item, dict) for item in items): + raise RuntimeError(f"Anthropic account API returned an invalid {key} array.") + if result.get("has_more") or result.get("next_cursor") or result.get("nextCursor"): + raise RuntimeError("Anthropic account listing was incomplete; keeping existing resources.") + return items + + +def _aliases(items: list[dict], name_key: str) -> dict[str, str]: + names: dict[str, str] = {} + for item in items: + item_id = item.get("id") + if not isinstance(item_id, str) or not item_id: + raise RuntimeError("Anthropic account resource has no valid ID.") + label = item.get(name_key) or item_id + name = re.sub(r"[^a-z0-9]+", "-", str(label).lower()).strip("-")[:48] or "resource" + names[item_id] = name + counts = Counter(names.values()) + used: set[str] = set() + for item_id, name in sorted(names.items()): + if counts[name] > 1: + name += "-" + hashlib.sha256(item_id.encode()).hexdigest()[:12] + if name in used: + raise RuntimeError("Anthropic account resources have conflicting names.") + names[item_id] = name + used.add(name) + return names + + +def unpack_skill(body: bytes) -> dict[str, SkillFile]: + try: + with zipfile.ZipFile(io.BytesIO(body)) as archive: + files: dict[str, SkillFile] = {} + paths: set[str] = set() + total = 0 + for entry in archive.infolist(): + path = PurePosixPath(entry.filename) + if ( + path.is_absolute() + or ".." in path.parts + or "\\" in entry.filename + or ":" in entry.filename + or stat.S_ISLNK(entry.external_attr >> 16) + ): + raise RuntimeError("Anthropic skill archive contains an unsafe path.") + if entry.is_dir(): + continue + parts = path.parts + if not parts or any(part.startswith(".") for part in parts): + raise RuntimeError("Anthropic skill archive contains an unsafe path.") + relative = PurePosixPath(*parts).as_posix() + total += entry.file_size + folded = relative.casefold() + if total > MAX_ARCHIVE_BYTES or len(files) >= 512 or folded in paths: + raise RuntimeError( + "Anthropic skill archive exceeds limits or has duplicate files." + ) + paths.add(folded) + files[relative] = SkillFile( + archive.read(entry), bool((entry.external_attr >> 16) & 0o111) + ) + if any( + parent.as_posix().casefold() in paths + for name in files + for parent in PurePosixPath(name).parents + if parent.as_posix() != "." + ): + raise RuntimeError("Anthropic skill archive has conflicting file paths.") + if "SKILL.md" not in files: + roots = {name.split("/", 1)[0] for name in files} + if len(roots) != 1: + raise RuntimeError("Anthropic archive has no single skill root.") + prefix = next(iter(roots)) + "/" + files = {name.removeprefix(prefix): content for name, content in files.items()} + markdown = files.get("SKILL.md", SkillFile(b"")).content.decode("utf-8") + if not markdown.startswith("---"): + raise RuntimeError("Anthropic archive has no valid SKILL.md.") + return files + except (zipfile.BadZipFile, UnicodeError, OSError, ValueError, NotImplementedError): + raise RuntimeError("Anthropic download was not a readable skill archive.") from None + + +def load_resources(claude_binary: str) -> AccountResources: + with AccountClient(claude_binary) as client: + connectors = client.connectors() + org, skills = client.skills() + return AccountResources( + connectors, skills, org, claude_binary, os.environ.get("CLAUDE_CONFIG_DIR", "") + ) + + +def write_skill(destination: Path, files: dict[str, SkillFile]) -> None: + destination.mkdir(parents=True, exist_ok=True) + for name, file in files.items(): + path = destination / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(file.content) + if file.executable: + path.chmod(0o755) + + +def write_plugin( + root: Path, + resources: AccountResources, + executable: str, + workspace: str, + *, + previous_root: Path | None = None, +) -> None: + def write_json(relative: str, value: object) -> None: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + auth_args = [ + "--claude-binary", + resources.claude_binary, + "--claude-config-dir", + resources.config_dir, + ] + write_json( + ".claude-plugin/plugin.json", + { + "name": PLUGIN_NAME, + "author": {"name": "Databricks"}, + "description": "Anthropic account connectors and enabled skills through relay authentication", + "mcpServers": "./.mcp.json", + "skills": "./skills", + }, + ) + write_json( + ".mcp.json", + { + "mcpServers": { + name: { + "type": "stdio", + "command": executable, + "args": [ + "-m", + "ucode.cli", + "anthropic-mcp-proxy", + "--host", + workspace, + "--server-id", + server_id, + *auth_args, + ], + } + for name, server_id in resources.connectors.items() + } + }, + ) + argv = [ + executable, + "-m", + "ucode.cli", + "cowork-hook", + "--host", + workspace, + "--anthropic", + *auth_args, + ] + write_json( + "hooks/hooks.json", + { + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": subprocess_cross_os.shell_command(argv), + "timeout": 180, + } + ] + } + ] + } + }, + ) + _write_skills(root, resources.org, resources.skills, previous_root or root) + + +def sync_hook(payload: object, root: Path, claude_binary: str) -> dict: + if not isinstance(payload, dict) or payload.get("hook_event_name") != "SessionStart": + return {} + manifest = json.loads((root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) + if manifest.get("name") != PLUGIN_NAME: + raise RuntimeError("Anthropic hook received another plugin's directory.") + with (root / ".account-sync.lock").open("a+b") as lock: + try: + acquire_exclusive_file_lock(lock, timeout=5) + except TimeoutError: + raise RuntimeError("Another Cowork session is refreshing account skills.") from None + try: + return _sync_skills(root, claude_binary) + finally: + release_file_lock(lock) + + +def _sync_skills(root: Path, claude_binary: str) -> dict: + with AccountClient(claude_binary) as client: + org, skills = client.skills() + staging = Path(tempfile.mkdtemp(prefix=".account-", dir=root)) + destination = root / "skills" + backup = root / ".account-previous" + try: + _write_skills(staging, org, skills, root) + if backup.exists(): + shutil.rmtree(backup) + if destination.exists(): + os.replace(destination, backup) + try: + os.replace(staging / "skills", destination) + os.replace(staging / SKILL_MANIFEST, root / SKILL_MANIFEST) + except OSError: + if destination.exists(): + shutil.rmtree(destination) + if backup.exists(): + os.replace(backup, destination) + raise + if backup.exists(): + shutil.rmtree(backup) + finally: + if staging.exists(): + shutil.rmtree(staging) + connectors = config_io.read_json_safe(root / ".mcp.json").get("mcpServers", {}) + failed = [skill.name for skill in skills if skill.files is None] + return { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "reloadSkills": True, + "additionalContext": ( + "Anthropic account resources use the ug-account-docs plugin namespace; " + "workspace system.ai connectors use the separate ug plugin. " + "Load relevant account skills before using their connectors. " + f"Configured account connector aliases: {', '.join(connectors) or 'none'}. " + "Imported skills do not provide missing native browser, artifact, or computer tools. " + "Connector installation changes require ug configure and a new Cowork task." + + (f" Account skill downloads unavailable: {', '.join(failed)}." if failed else "") + ), + } + } + + +def _write_skills(root: Path, org: str, skills: list[AccountSkill], previous_root: Path) -> None: + previous = config_io.read_json_safe(previous_root / SKILL_MANIFEST) + owned = previous.get("skills", {}) + if not isinstance(owned, dict): + owned = {} + destination = root / "skills" + destination.mkdir(parents=True, exist_ok=True) + if root != previous_root and (previous_root / "skills").is_dir(): + shutil.copytree(previous_root / "skills", destination, dirs_exist_ok=True) + retained = set() + for skill in skills: + target = destination / skill.name + if skill.files is not None: + if target.exists(): + shutil.rmtree(target) + write_skill(target, skill.files) + retained.add(skill.name) + elif previous.get("org") == org: + old_name = next((name for name, skill_id in owned.items() if skill_id == skill.id), "") + if re.fullmatch(r"[a-z0-9-]{1,64}", old_name): + source = destination / old_name + if source.is_dir(): + if source != target: + shutil.copytree(source, target, dirs_exist_ok=True) + retained.add(skill.name) + for name in owned: + if name not in retained and re.fullmatch(r"[a-z0-9-]{1,64}", name): + path = destination / name + if path.is_dir(): + shutil.rmtree(path) + # The first Docs-only plugin had no ownership manifest. + if not previous and "docs" not in retained and (destination / "docs").is_dir(): + shutil.rmtree(destination / "docs") + config_io.atomic_write_json( + root / SKILL_MANIFEST, + { + "org": org, + "skills": {skill.name: skill.id for skill in skills if skill.name in retained}, + }, + ) diff --git a/src/ucode/agents/desktop/cowork.py b/src/ucode/agents/desktop/cowork.py index 42e54d8f8..ab5d53395 100644 --- a/src/ucode/agents/desktop/cowork.py +++ b/src/ucode/agents/desktop/cowork.py @@ -5,12 +5,14 @@ import json import os import shutil +import sys import tempfile from collections import Counter from pathlib import Path from urllib.parse import unquote from ucode import config_io +from ucode.agents.desktop import anthropic_cowork from ucode.agents.desktop.claude import claude_desktop_root_directory from ucode.databricks import build_mcp_proxy_argv from ucode.mcp_connection_login import connection_from_url @@ -46,7 +48,8 @@ def configure_cowork(state: dict, mcp_servers: list[dict]) -> bool: previous_state = config_io.read_json_safe(_state_path()) skill_records = _workspace_skill_records(workspace) - if not previous_state and not skill_records and not mcp_servers: + relayed = bool(state.get("claude_relayed")) + if not previous_state and not skill_records and not mcp_servers and not relayed: return False if not previous_state and not _cowork_sessions_root_exists(): return False @@ -62,7 +65,7 @@ def configure_cowork(state: dict, mcp_servers: list[dict]) -> bool: use_pat=bool(state.get("use_pat")), custom_oauth=bool(state.get("custom_oauth")), ) - if not previous_state and not skill_records and not mcp_config["mcpServers"]: + if not previous_state and not skill_records and not mcp_config["mcpServers"] and not relayed: return False cowork_root = discover_cowork_root() @@ -72,7 +75,25 @@ def configure_cowork(state: dict, mcp_servers: list[dict]) -> bool: marketplace_root = _marketplace_root() plugin_root = marketplace_root / "plugins" / PLUGIN_NAME hooks = _connector_hooks(mcp_config, workspace, profile, use_pat=bool(state.get("use_pat"))) - _write_marketplace(marketplace_root, plugin_root, skill_records, mcp_config, hooks) + resources = None + if relayed: + try: + resources = anthropic_cowork.load_resources(_claude_binary()) + except RuntimeError as exc: + print_warning(f"Could not sync Anthropic Cowork resources: {exc}") + if resources is None and not relayed: + _write_marketplace(marketplace_root, plugin_root, skill_records, mcp_config, hooks) + else: + _write_marketplace( + marketplace_root, + plugin_root, + skill_records, + mcp_config, + hooks, + anthropic_resources=resources, + workspace=workspace, + preserve_anthropic=relayed, + ) config_io.atomic_write_json( _state_path(), {"version": 1, "cowork_root": str(cowork_root)}, @@ -123,7 +144,14 @@ def _remove_plugin_registration(cowork_root: Path) -> bool: env = os.environ.copy() env["CLAUDE_CONFIG_DIR"] = str(cowork_root) plugin_result = False - for plugin_id in (PLUGIN_ID, LEGACY_PLUGIN_ID): + plugin_ids = [PLUGIN_ID, LEGACY_PLUGIN_ID] + installed = config_io.read_json_safe(cowork_root / "cowork_plugins/installed_plugins.json") + if ( + _contains_plugin_id(installed, plugin_id=anthropic_cowork.PLUGIN_ID) + or (_marketplace_root() / "plugins" / anthropic_cowork.PLUGIN_NAME).is_dir() + ): + plugin_ids.append(anthropic_cowork.PLUGIN_ID) + for plugin_id in plugin_ids: result = _run_claude_json( claude, ["plugin", "uninstall", plugin_id, "--cowork", "--json"], @@ -298,6 +326,10 @@ def _write_marketplace( skill_records: list[dict], mcp_config: dict, hooks: dict | None = None, + *, + anthropic_resources: anthropic_cowork.AccountResources | None = None, + workspace: str = "", + preserve_anthropic: bool = False, ) -> None: marketplace_root.parent.mkdir(parents=True, exist_ok=True) staging = Path( @@ -331,6 +363,26 @@ def _write_marketplace( _write_json(staging_plugin / ".mcp.json", mcp_config) if hooks: _write_json(staging_plugin / "hooks" / "hooks.json", hooks) + name = anthropic_cowork.PLUGIN_NAME + previous_account = marketplace_root / "plugins" / name + if anthropic_resources is not None or (preserve_anthropic and previous_account.is_dir()): + marketplace["plugins"].append( + { + "name": name, + "description": "Anthropic relay account resources", + "source": f"./plugins/{name}", + } + ) + if anthropic_resources is None: + shutil.copytree(previous_account, staging / "plugins" / name) + else: + anthropic_cowork.write_plugin( + staging / "plugins" / name, + anthropic_resources, + os.path.abspath(sys.executable), + workspace, + previous_root=previous_account, + ) _write_json(staging / ".claude-plugin" / "marketplace.json", marketplace) _write_json(staging_plugin / ".claude-plugin" / "plugin.json", plugin) _copy_skills(staging_plugin / "skills", skill_records) @@ -521,6 +573,18 @@ def _install_plugin(cowork_root: Path, marketplace_root: Path) -> None: ["plugin", action, PLUGIN_ID, "--cowork", "--json"], env, ) + anthropic_id = anthropic_cowork.PLUGIN_ID + anthropic_source = marketplace_root / "plugins" / anthropic_cowork.PLUGIN_NAME + if anthropic_source.is_dir(): + action = "update" if _contains_plugin_id(installed, plugin_id=anthropic_id) else "install" + _run_claude_json(claude, ["plugin", action, anthropic_id, "--cowork", "--json"], env) + elif _contains_plugin_id(installed, plugin_id=anthropic_id): + _run_claude_json( + claude, + ["plugin", "uninstall", anthropic_id, "--cowork", "--json"], + env, + accepted_failures=("not installed", "not_installed", "not found"), + ) if _contains_plugin_id(installed, plugin_id=LEGACY_PLUGIN_ID): _run_claude_json( claude, diff --git a/src/ucode/anthropic_mcp_proxy.py b/src/ucode/anthropic_mcp_proxy.py new file mode 100644 index 000000000..af351395d --- /dev/null +++ b/src/ucode/anthropic_mcp_proxy.py @@ -0,0 +1,79 @@ +"""Transparent stdio bridge to an authenticated Anthropic account connector.""" + +from __future__ import annotations + +import sys +import uuid +from urllib.parse import quote + +import anyio +from anyio import to_thread +from mcp.client.streamable_http import streamable_http_client +from mcp.server.stdio import stdio_server + +from ucode.agents.desktop.anthropic_cowork import AccountClient +from ucode.desktop_auth import ClaudeDesktopAuthError, get_claude_subscription_token +from ucode.mcp_proxy import _httpx + + +def token_auth(claude_binary: str): + httpx = _httpx() + + class SubscriptionAuth(httpx.Auth): + async def async_auth_flow(self, request): + token = await to_thread.run_sync(get_claude_subscription_token, claude_binary) + request.headers["Authorization"] = f"Bearer {token}" + yield request + + return SubscriptionAuth() + + +async def _pump(source, destination, upstream: bool = False) -> None: + async with source, destination: + async for message in source: + if isinstance(message, Exception): + raise RuntimeError("Anthropic MCP transport failed.") from None + await destination.send(message) + if upstream: + raise RuntimeError("Anthropic MCP transport closed unexpectedly.") + + +async def _run(server_id: str, claude_binary: str) -> None: + httpx = _httpx() + url = "https://mcp-proxy.anthropic.com/v1/mcp/" + quote(server_id, safe="") + async with httpx.AsyncClient( + auth=token_auth(claude_binary), + follow_redirects=False, + headers={ + "User-Agent": "unity-gateway-cowork", + "Accept-Encoding": "identity", + "X-Mcp-Client-Session-Id": str(uuid.uuid4()), + }, + timeout=httpx.Timeout(connect=30, read=300, write=30, pool=30), + ) as client: + async with streamable_http_client(url, http_client=client) as streams: + async with stdio_server() as (stdio_read, stdio_write): + async with anyio.create_task_group() as group: + group.start_soon(_pump, streams[0], stdio_write, True) + await _pump(stdio_read, streams[1]) + group.cancel_scope.cancel() + + +def serve(server_id: str, claude_binary: str) -> None: + try: + with AccountClient(claude_binary) as client: + if server_id not in client.connectors().values(): + raise RuntimeError( + "Anthropic connector is no longer eligible. Run `ug configure` again." + ) + anyio.run(_run, server_id, claude_binary) + except ClaudeDesktopAuthError as exc: + print(f"ug anthropic-mcp-proxy: {exc}", file=sys.stderr) + raise SystemExit(2) from None + except Exception: + print( + "ug anthropic-mcp-proxy: connector unavailable. Check Claude sign-in, " + "run `ug configure`, then start a new Cowork task.", + file=sys.stderr, + ) + raise SystemExit(2) from None diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 96e84088a..4bad05ac9 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1203,6 +1203,11 @@ def _configure_workspace_command( if last is None or tool_name in last: configured_tools.append(tool_name) if tool_name == "claude": + for key in ("claude_relayed", "relayed_proxy_port"): + if key in configured: + state[key] = configured[key] + else: + state.pop(key, None) from ucode.agents.desktop.setup import configure_claude_desktop configure_claude_desktop(configured, parent_schema=parent_schema) @@ -2511,18 +2516,50 @@ def mcp_proxy_cmd( serve(url, workspace, profile, use_pat=use_pat or bool(state.get("use_pat"))) +@app.command("anthropic-mcp-proxy", hidden=True) +def anthropic_mcp_proxy_cmd( + host: Annotated[str, typer.Option("--host")], + server_id: Annotated[str, typer.Option("--server-id")], + claude_binary: Annotated[str, typer.Option("--claude-binary")], + claude_config_dir: Annotated[str, typer.Option("--claude-config-dir")] = "", +) -> None: + """Bridge a relay-only account connector without storing its bearer.""" + import sys + + from ucode.agents.desktop.anthropic_cowork import credential_source + from ucode.anthropic_mcp_proxy import serve + + state = load_state() + if not state.get("claude_relayed") or state.get("workspace", "").rstrip("/") != host.rstrip( + "/" + ): + print("Anthropic Cowork resources require the configured relay workspace.", file=sys.stderr) + raise typer.Exit(1) + if not os.path.isabs(claude_binary): + print("An absolute Claude Code executable path is required.", file=sys.stderr) + raise typer.Exit(1) + with credential_source(claude_config_dir): + serve(server_id, claude_binary) + + @app.command("cowork-hook", hidden=True) def cowork_hook_cmd( host: Annotated[str, typer.Option("--host", help="Configured workspace URL.")], - server: Annotated[list[str], typer.Option("--server", help="Connector as `=`.")], + server: Annotated[ + list[str] | None, typer.Option("--server", help="Connector as `=`.") + ] = None, profile: Annotated[ str | None, typer.Option("--profile", help="Databricks CLI profile.") ] = None, use_pat: Annotated[bool, typer.Option("--use-pat")] = False, + anthropic: Annotated[bool, typer.Option("--anthropic")] = False, + claude_binary: Annotated[str | None, typer.Option("--claude-binary")] = None, + claude_config_dir: Annotated[str, typer.Option("--claude-config-dir")] = "", ) -> None: """Run the Cowork plugin's SessionStart and UserPromptSubmit hooks.""" import json import sys + from pathlib import Path from ucode.agents.desktop.cowork_hooks import hook_output @@ -2530,9 +2567,28 @@ def cowork_hook_cmd( payload = json.loads(sys.stdin.read() or "{}") except ValueError: return - servers = dict(entry.split("=", 1) for entry in server if "=" in entry) + servers = dict(entry.split("=", 1) for entry in (server or []) if "=" in entry) with redirect_stdout(sys.stderr): # stdout is reserved for the hook's JSON - output = hook_output(payload, servers, host, profile, use_pat=use_pat) + if anthropic: + from ucode.agents.desktop.anthropic_cowork import credential_source, sync_hook + + state = load_state() + if not state.get("claude_relayed") or state.get("workspace", "").rstrip( + "/" + ) != host.rstrip("/"): + return + root = os.environ.get("CLAUDE_PLUGIN_ROOT") + if not root or not claude_binary or not os.path.isabs(claude_binary): + print_warning("Anthropic Cowork hook needs its plugin root and Claude executable.") + return + try: + with credential_source(claude_config_dir): + output = sync_hook(payload, Path(root), claude_binary) + except (RuntimeError, OSError) as exc: + print_warning(f"Could not refresh Anthropic Cowork skills: {exc}") + return + else: + output = hook_output(payload, servers, host, profile, use_pat=use_pat) if output: sys.stdout.write(json.dumps(output)) diff --git a/src/ucode/desktop_auth.py b/src/ucode/desktop_auth.py index 3c4f0b552..08dda7426 100644 --- a/src/ucode/desktop_auth.py +++ b/src/ucode/desktop_auth.py @@ -564,6 +564,11 @@ def _get_claude_auth_token(claude_binary: str, *, deadline: float | None = None) return _claude_subscription_token() +def get_claude_subscription_token(claude_binary: str) -> str: + """Read or silently refresh the subscription credential without Databricks auth.""" + return _get_claude_auth_token(claude_binary, deadline=time.monotonic() + _HELPER_BUDGET_SECONDS) + + def get_claude_desktop_relay_credential( host: str, profile: str, @@ -589,6 +594,7 @@ def get_claude_desktop_relay_credential( "CLAUDE_CODE_OAUTH_TOKEN_ENV", "CLAUDE_CONFIG_DIR_ENV", "ClaudeDesktopAuthError", + "get_claude_subscription_token", "get_claude_desktop_relay_credential", "get_claude_desktop_databricks_token", ] diff --git a/tests/README.md b/tests/README.md index a78179707..86b0da114 100644 --- a/tests/README.md +++ b/tests/README.md @@ -343,6 +343,16 @@ nothing without a readable transcript or previous reply. Other events are ignore stdout holds only the hook JSON. Probes and token minting are faked; native Cowork hook execution remains unverified. +`test_anthropic_cowork.py` covers relay-only multi-connector/account-skill discovery, +credential-source binding, per-request token rotation, HTTP error redaction and redirect +refusal, custom archive roots and supporting-file executable modes, alias collisions, +partial-download retention, organization isolation, disabled/deleted-skill cleanup, +discovery-failure preservation, eligibility checks, a separate generated plugin alongside +workspace resources, relay-mode cleanup, and a real MCP SDK exchange over isolated HTTP +and stdio transports. It checks forwarding of instructions, tool schemas and call results. +HTTP, credential storage, and plugin CLI boundaries are replaced; these are component +tests, not native Cowork or live Anthropic coverage. + `test_cowork_plugin.py` covers active Cowork-root discovery, ambiguous-profile refusal, generated marketplace/plugin/MCP manifests, the generated connector skill naming Claude-only MCP servers with its two 5-second retries and one sign-in recovery attempt for access/IP @@ -356,7 +366,10 @@ the `ug` name and display name in both manifests. Legacy-registration checks cov replacement after successful install/update, preservation on install failure, scoped cleanup, and unrelated-plugin preservation. CLI component tests cover managed setup after successful skill/MCP reconciliation, preservation after transient -reconciliation failures, and cleanup for unmanaged or non-Claude configurations. These tests +reconciliation failures, and cleanup for unmanaged or non-Claude configurations. +Managed configure checks also cover enabling, disabling, and retaining Claude relay mode +across agent ordering, so Cowork and later agents use the newly configured relay state. +These tests mock the Claude plugin CLI and do not establish native Desktop policy, account-feature, late MCP connection, tool discovery, or inference behavior. diff --git a/tests/integration/README.md b/tests/integration/README.md index e249c9e07..816fddd41 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -559,6 +559,14 @@ emits one warning and a failed Desktop summary, and preserves profile and select files. Retry restores the previous selection without workspace state and returns 0; a repeated revert also returns 0. This does not establish native Desktop coverage. +`../test_anthropic_cowork.py` adds component checks for relay-only multi-connector and +enabled/custom-skill import, stable aliases, supporting-file permissions, per-skill failure +retention, catalog-failure preservation, organization isolation, disabled/deleted-skill +removal, account API/credential boundaries, skill refresh, mode cleanup, and +MCP instruction/schema/result forwarding. HTTP and stdio transports are isolated in these +checks. Native Cowork hook execution, `reloadSkills`, gateway inference, and coexisting +Anthropic/workspace tool calls remain unverified by the automated suite. + `../test_cowork_plugin.py` covers Cowork-root discovery, generated plugin contents, scoped Claude plugin commands, install/update selection, PAT/custom-OAuth behavior, and owned-file cleanup. Alias checks cover short UC names, unchanged proxy routing, custom-name @@ -566,8 +574,9 @@ preservation, order-independent collisions, and repeated replacement of old keys Manifest checks require the `ug` name and display name in both manifests. Legacy-registration component checks cover replacement after successful install/update, preservation on install failure, scoped cleanup, and unrelated-plugin preservation. -`../test_desktop_configure_cli.py` covers managed orchestration and -cleanup. Both are mocked component coverage; the integration suite does not launch Cowork, +`../test_desktop_configure_cli.py` covers managed orchestration, relay-state transitions +and retention across agent ordering, and cleanup. Both are mocked component coverage; +the integration suite does not launch Cowork, connect a plugin MCP server, discover its deferred tool, or run a task through it. `../test_mcp_proxy.py` covers persistent OAuth U2M authentication-tool discovery, explicit diff --git a/tests/test_anthropic_cowork.py b/tests/test_anthropic_cowork.py new file mode 100644 index 000000000..a09d10d4c --- /dev/null +++ b/tests/test_anthropic_cowork.py @@ -0,0 +1,721 @@ +"""Account APIs and relay plugin generation with external HTTP/auth boundaries isolated.""" + +import io +import json +import os +import stat +import time +import zipfile +from pathlib import Path + +import anyio +import httpx +import pytest +from typer.testing import CliRunner + +from ucode import anthropic_mcp_proxy, desktop_auth, state +from ucode.agents.desktop import anthropic_cowork as account +from ucode.agents.desktop import cowork +from ucode.cli import app + +WORKSPACE = "https://workspace.example" +MARKDOWN = b"---\nname: docs\ndescription: Edit docs\n---\nUse Claude Docs.\n" +SKILL_FILES = {"SKILL.md": account.SkillFile(MARKDOWN)} + + +def skill_zip(name="docs/SKILL.md", content=MARKDOWN, mode=0): + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + info = zipfile.ZipInfo(name) + info.external_attr = mode << 16 + archive.writestr(info, content) + return output.getvalue() + + +def api_response( + request, + *, + enabled=True, + skills=None, + connectors=None, + failed=(), + archives=None, + catalog_status=200, +): + assert request.url.host == "api.anthropic.com" + assert request.headers["Authorization"] == "Bearer fixture-token" + if request.url.path == "/api/oauth/profile": + return httpx.Response(200, json={"organization": {"uuid": "org-id"}}) + if request.url.path == "/v1/mcp_servers": + assert request.headers["anthropic-beta"] == "mcp-servers-2025-12-04" + return httpx.Response( + 200, + json={ + "data": connectors + if connectors is not None + else [ + { + "display_name": "Claude Docs", + "id": "server-id", + "eligible": True, + } + ] + }, + ) + assert request.headers["x-organization-uuid"] == "org-id" + if request.url.path.endswith("/list-skills"): + return httpx.Response( + catalog_status, + json={"skills": skills if skills is not None else [{"id": "docs", "enabled": enabled}]}, + ) + assert request.url.path.endswith("/download") + skill_id = request.url.path.split("/")[-2] + if skill_id in failed: + return httpx.Response(503) + content = (archives or {}).get(skill_id, skill_zip(f"{skill_id}/SKILL.md")) + return httpx.Response(200, content=content) + + +@pytest.fixture +def account_http(monkeypatch): + # Only the remote API and credential store are replaced; the client and writers remain real. + original = httpx.Client + monkeypatch.setattr(account, "get_claude_subscription_token", lambda _: "fixture-token") + handler = {"enabled": True} + monkeypatch.setattr( + account.httpx, + "Client", + lambda **kwargs: original( + **{ + **kwargs, + "transport": httpx.MockTransport(lambda request: api_response(request, **handler)), + } + ), + ) + return handler + + +def test_account_loads_enabled_docs_and_connector_from_same_credential(account_http): + resources = account.load_resources("/opt/claude") + assert resources.connectors == {"claude-docs": "server-id"} + assert resources.skills == [account.AccountSkill("docs", "docs", SKILL_FILES)] + assert resources.claude_binary == "/opt/claude" + + +def test_disabled_docs_is_not_downloaded(account_http): + account_http["enabled"] = False + assert account.load_resources("/opt/claude").skills == [] + + +def test_imports_all_eligible_connectors_and_enabled_skills(account_http, tmp_path): + account_http["connectors"] = [ + {"id": "server-id", "display_name": "Claude Docs", "eligible": True}, + {"id": "drive-id", "display_name": "Google Drive", "eligible": True}, + {"id": "blocked", "display_name": "Blocked", "eligible": False}, + {"id": "unknown", "display_name": "Unknown"}, + ] + account_http["skills"] = [ + {"id": "docs", "name": "docs", "enabled": True}, + {"id": "google-workspace", "name": "google-workspace", "enabled": True}, + {"id": "custom-id", "name": "my-team", "enabled": True}, + {"id": "disabled", "enabled": False}, + {"id": "unknown"}, + ] + account_http["archives"] = {"custom-id": skill_zip("custom-name/SKILL.md")} + account_http["failed"] = ("disabled", "unknown") + resources = account.load_resources("/opt/claude") + assert resources.connectors == {"claude-docs": "server-id", "google-drive": "drive-id"} + assert {skill.id for skill in resources.skills} == {"docs", "google-workspace", "custom-id"} + root = tmp_path / "plugin" + account.write_plugin(root, resources, "/opt/python", WORKSPACE) + config = json.loads((root / ".mcp.json").read_text())["mcpServers"] + assert set(config) == {"claude-docs", "google-drive"} + assert config["google-drive"]["args"][6] == "drive-id" + assert {p.name for p in (root / "skills").iterdir()} == {"docs", "google-workspace", "my-team"} + assert (root / "skills/my-team/SKILL.md").read_bytes() == MARKDOWN + + +def test_skills_import_does_not_require_claude_docs_connector(account_http): + account_http["connectors"] = [] + resources = account.load_resources("/opt/claude") + assert resources.connectors == {} + assert resources.skills[0].name == "docs" + + +def test_resource_aliases_handle_duplicate_and_unsafe_names_without_order_dependence(): + catalog = [ + {"id": "a", "name": "../same name"}, + {"id": "b", "name": "Same Name"}, + {"id": "c", "name": "../../"}, + {"id": "d", "name": "x" * 200}, + ] + aliases = account._aliases(catalog, "name") + assert aliases == account._aliases(list(reversed(catalog)), "name") + assert len(set(aliases.values())) == 4 + assert aliases["a"].startswith("same-name-") + assert aliases["c"] == "resource" + assert all(len(name) <= 64 and "/" not in name for name in aliases.values()) + + +def test_hook_refreshes_all_skills_retains_failed_and_removes_disabled(account_http, tmp_path): + account_http["skills"] = [ + {"id": name, "name": name, "enabled": True} + for name in ("docs", "google-workspace", "custom", "deleted") + ] + root = tmp_path / "plugin" + account.write_plugin(root, account.load_resources("/opt/claude"), "/opt/python", WORKSPACE) + custom = root / "skills/custom/SKILL.md" + custom.write_text("last good skill") + account_http["skills"] = [ + {"id": "docs", "enabled": False}, + {"id": "google-workspace", "enabled": True}, + {"id": "custom", "enabled": True}, + {"id": "new", "enabled": True}, + ] + account_http["failed"] = ("custom",) + account.sync_hook({"hook_event_name": "SessionStart"}, root, "/opt/claude") + assert custom.read_text() == "last good skill" + assert not (root / "skills/docs").exists() + assert not (root / "skills/deleted").exists() + assert (root / "skills/new/SKILL.md").read_bytes() == MARKDOWN + + +def test_skill_failure_does_not_block_connectors_or_other_skills(account_http, tmp_path): + account_http["skills"] = [{"id": name, "enabled": True} for name in ("docs", "bad")] + account_http["failed"] = ("bad",) + root = tmp_path / "plugin" + resources = account.load_resources("/opt/claude") + account.write_plugin(root, resources, "/opt/python", WORKSPACE) + assert (root / "skills/docs/SKILL.md").exists() + assert not (root / "skills/bad").exists() + assert "claude-docs" in json.loads((root / ".mcp.json").read_text())["mcpServers"] + + +def test_failed_skill_is_not_retained_across_organizations(tmp_path): + root = tmp_path / "plugin" + resources = account.AccountResources( + {}, [account.AccountSkill("id", "custom", SKILL_FILES)], "old", "/opt/claude", "" + ) + account.write_plugin(root, resources, "/opt/python", WORKSPACE) + resources.org = "new" + resources.skills[0].files = None + account.write_plugin(root, resources, "/opt/python", WORKSPACE) + assert not (root / "skills/custom").exists() + + +def test_failed_skill_retains_previous_copy_when_alias_changes(tmp_path): + root = tmp_path / "plugin" + resources = account.AccountResources( + {}, [account.AccountSkill("id", "old-name", SKILL_FILES)], "org", "/opt/claude", "" + ) + account.write_plugin(root, resources, "/opt/python", WORKSPACE) + resources.skills = [account.AccountSkill("id", "new-name", None)] + account.write_plugin(root, resources, "/opt/python", WORKSPACE) + assert (root / "skills/new-name/SKILL.md").read_bytes() == MARKDOWN + assert not (root / "skills/old-name").exists() + + +def test_discovery_failure_preserves_existing_account_plugin(tmp_path, account_http): + marketplace = tmp_path / "marketplace" + workspace_plugin = marketplace / "plugins" / cowork.PLUGIN_NAME + config = {"mcpServers": {}} + cowork._write_marketplace( + marketplace, + workspace_plugin, + [], + config, + anthropic_resources=account.load_resources("/opt/claude"), + workspace=WORKSPACE, + ) + account_root = marketplace / "plugins" / account.PLUGIN_NAME + original = (account_root / ".mcp.json").read_bytes() + cowork._write_marketplace(marketplace, workspace_plugin, [], config, preserve_anthropic=True) + assert (account_root / ".mcp.json").read_bytes() == original + assert (account_root / "skills/docs/SKILL.md").read_bytes() == MARKDOWN + manifest = json.loads((marketplace / ".claude-plugin/marketplace.json").read_text()) + assert account.PLUGIN_NAME in {plugin["name"] for plugin in manifest["plugins"]} + + +def test_hook_catalog_failure_preserves_all_installed_skills(tmp_path, account_http): + root = tmp_path / "plugin" + account.write_plugin(root, account.load_resources("/opt/claude"), "/opt/python", WORKSPACE) + account_http["catalog_status"] = 503 + with pytest.raises(RuntimeError, match="HTTP 503"): + account.sync_hook({"hook_event_name": "SessionStart"}, root, "/opt/claude") + assert (root / "skills/docs/SKILL.md").read_bytes() == MARKDOWN + + +def test_upgrades_docs_only_layout_and_removes_disabled_docs(tmp_path, account_http): + root = tmp_path / "plugin" + account.write_plugin(root, account.load_resources("/opt/claude"), "/opt/python", WORKSPACE) + (root / account.SKILL_MANIFEST).unlink() + sibling = root / "skills/unowned" + sibling.mkdir() + (sibling / "SKILL.md").write_text("keep") + account_http["enabled"] = False + account.sync_hook({"hook_event_name": "SessionStart"}, root, "/opt/claude") + assert not (root / "skills/docs").exists() + assert (sibling / "SKILL.md").read_text() == "keep" + + +def test_hook_rolls_back_skills_if_manifest_replacement_fails(tmp_path, account_http, monkeypatch): + root = tmp_path / "plugin" + account.write_plugin(root, account.load_resources("/opt/claude"), "/opt/python", WORKSPACE) + (root / "skills/docs/SKILL.md").write_text("last good skill") + previous_manifest = (root / account.SKILL_MANIFEST).read_bytes() + replace = os.replace + + def fail_manifest(source, destination): + if Path(destination) == root / account.SKILL_MANIFEST: + raise OSError("disk unavailable") + return replace(source, destination) + + monkeypatch.setattr(account.os, "replace", fail_manifest) + with pytest.raises(OSError, match="disk unavailable"): + account.sync_hook({"hook_event_name": "SessionStart"}, root, "/opt/claude") + assert (root / "skills/docs/SKILL.md").read_text() == "last good skill" + assert (root / account.SKILL_MANIFEST).read_bytes() == previous_manifest + + +@pytest.mark.parametrize( + "result", [{"skills": [{}], "has_more": True}, {"skills": "bad"}, {"skills": [None]}] +) +def test_incomplete_or_malformed_catalog_is_not_treated_as_deletions(result): + with pytest.raises(RuntimeError): + account._catalog(result, "skills") + + +def test_api_reads_fresh_token_per_request_and_does_not_follow_redirects(): + tokens = iter(["first", "second"]) + seen = [] + + def handle(request): + seen.append(request.headers["Authorization"]) + return httpx.Response(302, headers={"location": "https://external.example/"}) + + with account.AccountClient( + "/opt/claude", + token_provider=lambda: next(tokens), + transport=httpx.MockTransport(handle), + ) as client: + for _ in range(2): + with pytest.raises(RuntimeError, match="HTTP 302"): + client.get("/api/oauth/profile") + with pytest.raises(RuntimeError, match="relative API path"): + client.get("https://external.example/") + assert seen == ["Bearer first", "Bearer second"] + + +def test_api_error_does_not_include_response_body_or_credential(): + transport = httpx.MockTransport( + lambda request: httpx.Response(403, json={"error": "fixture-secret"}) + ) + with account.AccountClient( + "/opt/claude", + token_provider=lambda: "fixture-token", + transport=transport, + ) as client: + with pytest.raises(RuntimeError) as error: + client.connectors() + assert str(error.value) == "Anthropic account API returned HTTP 403." + + +@pytest.mark.parametrize( + "name", + [ + "../outside", + "/outside", + "docs/../../outside", + "docs/..\\outside", + "C:/outside", + "docs/.hidden/SKILL.md", + ], +) +def test_archive_rejects_paths_outside_owned_skill_files(name): + with pytest.raises(RuntimeError, match="unsafe path"): + account.unpack_skill(skill_zip(name)) + + +def test_archive_rejects_symlinks_and_invalid_markdown(): + with pytest.raises(RuntimeError, match="unsafe path"): + account.unpack_skill(skill_zip(mode=stat.S_IFLNK | 0o777)) + with pytest.raises(RuntimeError, match="valid SKILL.md"): + account.unpack_skill(skill_zip(content=b"not a skill")) + + +def test_archive_preserves_support_files_and_executable_permissions(tmp_path): + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("team-skill/SKILL.md", MARKDOWN) + script = zipfile.ZipInfo("team-skill/scripts/run.sh") + script.external_attr = (stat.S_IFREG | 0o755) << 16 + archive.writestr(script, b"#!/bin/sh\ntrue\n") + archive.writestr("team-skill/references/guide.md", b"reference") + account.write_skill(tmp_path, account.unpack_skill(output.getvalue())) + assert (tmp_path / "references/guide.md").read_bytes() == b"reference" + assert (tmp_path / "scripts/run.sh").stat().st_mode & 0o111 + + +@pytest.mark.parametrize( + "names", [("SKILL.md", "skill.md"), ("scripts", "scripts/run.sh"), ("docs",)] +) +def test_archive_rejects_conflicting_paths_and_missing_skill_root(names): + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for name in names: + archive.writestr(name, MARKDOWN) + with pytest.raises(RuntimeError): + account.unpack_skill(output.getvalue()) + + +def test_relay_plugin_coexists_with_workspace_docs_and_mcp(tmp_path): + marketplace = tmp_path / "marketplace" + workspace_skill = tmp_path / ".claude" / "skills" / "docs" + workspace_skill.mkdir(parents=True) + (workspace_skill / "SKILL.md").write_text("workspace docs") + config = {"mcpServers": {"system-ai": {"type": "stdio", "command": "/opt/ug"}}} + resources = account.AccountResources( + {"claude-docs": "server-id"}, + [account.AccountSkill("docs", "docs", SKILL_FILES)], + "org-id", + "/opt/claude", + "", + ) + cowork._write_marketplace( + marketplace, + marketplace / "plugins" / cowork.PLUGIN_NAME, + [{"bundle_name": "docs", "dirs": [str(workspace_skill)]}], + config, + anthropic_resources=resources, + workspace=WORKSPACE, + ) + regular = marketplace / "plugins" / cowork.PLUGIN_NAME + relay = marketplace / "plugins" / account.PLUGIN_NAME + assert (regular / "skills/docs/SKILL.md").read_text() == "workspace docs" + assert json.loads((regular / ".mcp.json").read_text()) == config + assert (relay / "skills/docs/SKILL.md").read_bytes() == MARKDOWN + entry = json.loads((relay / ".mcp.json").read_text())["mcpServers"]["claude-docs"] + assert Path(entry["command"]).is_absolute() + assert entry["args"][:3] == ["-m", "ucode.cli", "anthropic-mcp-proxy"] + assert entry["args"][-1] == "" + hooks = json.loads((relay / "hooks/hooks.json").read_text()) + assert "--anthropic" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] + manifest = json.loads((marketplace / ".claude-plugin/marketplace.json").read_text()) + assert {p["name"] for p in manifest["plugins"]} == {cowork.PLUGIN_NAME, account.PLUGIN_NAME} + assert "fixture-token" not in (relay / ".mcp.json").read_text() + + +def test_start_hook_refreshes_and_removes_only_owned_docs(tmp_path, account_http): + resources = account.load_resources("/opt/claude") + root = tmp_path / "plugin" + account.write_plugin(root, resources, "/opt/python", WORKSPACE) + sibling = root / "skills" / "other" + sibling.mkdir() + (sibling / "SKILL.md").write_text("keep") + docs = root / "skills/docs/SKILL.md" + docs.write_text("stale") + payload = {"hook_event_name": "SessionStart"} + output = account.sync_hook(payload, root, "/opt/claude") + assert docs.read_bytes() == MARKDOWN + assert output["hookSpecificOutput"]["reloadSkills"] is True + account_http["enabled"] = False + account.sync_hook(payload, root, "/opt/claude") + assert not docs.exists() + assert (sibling / "SKILL.md").read_text() == "keep" + assert account.sync_hook({"hook_event_name": "UserPromptSubmit"}, root, "/opt/claude") == {} + + +def test_credential_source_does_not_use_inherited_cowork_keychain(monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/cowork/account/org") + monkeypatch.setenv("CLAUDE_HELPER_CONTEXT", "interactive") + with account.credential_source(""): + assert desktop_auth._claude_keychain_service() == "Claude Code-credentials" + assert os.environ["CLAUDE_HELPER_CONTEXT"] == "background" + assert os.environ["CLAUDE_CONFIG_DIR"] == "/cowork/account/org" + assert os.environ["CLAUDE_HELPER_CONTEXT"] == "interactive" + + +def test_subscription_token_does_not_require_databricks_auth(monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr( + desktop_auth, + "_read_claude_credentials", + lambda: { + "claudeAiOauth": { + "accessToken": "fixture-token", + "expiresAt": (time.time() + 7200) * 1000, + } + }, + ) + assert desktop_auth.get_claude_subscription_token("/opt/claude") == "fixture-token" + + +def test_proxy_reloads_subscription_credential_for_each_request(monkeypatch): + tokens = iter(["first", "second"]) + monkeypatch.setattr( + anthropic_mcp_proxy, "get_claude_subscription_token", lambda _: next(tokens) + ) + auth = anthropic_mcp_proxy.token_auth("/opt/claude") + + async def exercise(): + seen = [] + for _ in range(2): + request = httpx.Request("POST", "https://mcp-proxy.anthropic.com/v1/mcp/id") + flow = auth.async_auth_flow(request) + sent = await anext(flow) + seen.append(sent.headers["Authorization"]) + await flow.aclose() + assert seen == ["Bearer first", "Bearer second"] + + anyio.run(exercise) + + +def test_non_relay_workspace_cannot_start_anthropic_proxy(): + state.save_state({"workspace": WORKSPACE, "claude_relayed": False}) + result = CliRunner().invoke( + app, + [ + "anthropic-mcp-proxy", + "--host", + WORKSPACE, + "--server-id", + "server-id", + "--claude-binary", + "/opt/claude", + ], + ) + assert result.exit_code == 1 + assert "configured relay workspace" in result.output + + +def test_proxy_allows_other_eligible_connectors(account_http, monkeypatch): + account_http["connectors"] = [ + {"id": "drive-id", "display_name": "Google Drive", "eligible": True} + ] + calls = [] + # The stdio process boundary is not started in this eligibility check. + monkeypatch.setattr( + anthropic_mcp_proxy.anyio, "run", lambda function, *args: calls.append(args) + ) + anthropic_mcp_proxy.serve("drive-id", "/opt/claude") + assert calls == [("drive-id", "/opt/claude")] + + +def test_proxy_rejects_removed_or_ineligible_connector(account_http): + account_http["connectors"] = [ + {"id": "server-id", "display_name": "Claude Docs", "eligible": False} + ] + with pytest.raises(SystemExit) as error: + anthropic_mcp_proxy.serve("server-id", "/opt/claude") + assert error.value.code == 2 + + +def test_stdio_bridge_preserves_instructions_schemas_and_tool_results(monkeypatch): + from contextlib import asynccontextmanager + + from mcp.shared.message import SessionMessage + from mcp.types import JSONRPCMessage + from pydantic import TypeAdapter + + adapter = TypeAdapter(JSONRPCMessage) + http = anthropic_mcp_proxy._httpx() + original_client = http.AsyncClient + requests = [] + monkeypatch.setattr( + anthropic_mcp_proxy, "get_claude_subscription_token", lambda _: "fixture-token" + ) + + def respond(request): + assert request.url.host == "mcp-proxy.anthropic.com" + assert request.url.path == "/v1/mcp/server-id" + assert request.headers["Authorization"] == "Bearer fixture-token" + if request.method == "GET": + return http.Response(405) + if request.method == "DELETE": + return http.Response(200) + payload = json.loads(request.content) + requests.append(payload) + if payload["method"] == "notifications/initialized": + return http.Response(202) + if payload["method"] == "initialize": + result = { + "protocolVersion": "2025-03-26", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "docs", "version": "1"}, + "instructions": "Connector-specific instructions", + } + elif payload["method"] == "tools/list": + result = { + "tools": [ + { + "name": "guide", + "description": "Read docs instructions", + "inputSchema": { + "type": "object", + "properties": { + "items": {"type": "array", "items": {"type": "string"}}, + }, + }, + "annotations": {"readOnlyHint": True}, + } + ] + } + else: + assert payload["method"] == "tools/call" + assert payload["params"] == { + "name": "guide", + "arguments": {"items": ["topic.instructions"]}, + } + result = {"content": [{"type": "text", "text": "Guide text"}], "isError": False} + return http.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": payload["id"], + "result": result, + }, + headers={"Mcp-Session-Id": "fixture-session"}, + ) + + # In-memory transport boundaries exercise the real MCP SDK and both proxy pumps. + monkeypatch.setattr( + http, + "AsyncClient", + lambda **kwargs: original_client(**kwargs, transport=http.MockTransport(respond)), + ) + + async def exercise(): + client_send, proxy_read = anyio.create_memory_object_stream(10) + proxy_write, client_read = anyio.create_memory_object_stream(10) + + @asynccontextmanager + async def stdio(): + yield proxy_read, proxy_write + + monkeypatch.setattr(anthropic_mcp_proxy, "stdio_server", stdio) + + async def exchange(request_id, method, params): + await client_send.send( + SessionMessage( + adapter.validate_python( + { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + ) + ) + ) + message = (await client_read.receive()).message + return getattr(message, "root", message).result + + with anyio.fail_after(10): + async with anyio.create_task_group() as group: + group.start_soon(anthropic_mcp_proxy._run, "server-id", "/opt/claude") + result = await exchange( + 1, + "initialize", + { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "unit-test", "version": "1"}, + }, + ) + assert result["instructions"] == "Connector-specific instructions" + await client_send.send( + SessionMessage( + adapter.validate_python( + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + } + ) + ) + ) + tools = (await exchange(2, "tools/list", {}))["tools"] + assert tools[0]["inputSchema"]["properties"]["items"]["type"] == "array" + assert tools[0]["annotations"]["readOnlyHint"] is True + result = await exchange( + 3, + "tools/call", + { + "name": "guide", + "arguments": {"items": ["topic.instructions"]}, + }, + ) + assert result == { + "content": [{"type": "text", "text": "Guide text"}], + "isError": False, + } + await client_send.aclose() + assert [r["method"] for r in requests] == [ + "initialize", + "notifications/initialized", + "tools/list", + "tools/call", + ] + + anyio.run(exercise) + + +def test_configure_installs_relay_plugin_and_removes_it_after_switching_modes( + tmp_path, + monkeypatch, + account_http, +): + desktop = tmp_path / "Claude-3p" + root = desktop / "local-agent-mode-sessions/account/org" + root.mkdir(parents=True) + monkeypatch.setattr(cowork, "claude_desktop_root_directory", lambda: desktop) + monkeypatch.setattr(cowork, "records_for_scope", lambda *_: []) + monkeypatch.setattr(cowork.shutil, "which", lambda name: f"/opt/{name}") + calls = [] + from types import SimpleNamespace + + installed = [] + + def command(argv, **kwargs): + calls.append(argv) + payload = installed if argv[1:4] == ["plugin", "list", "--cowork"] else {"outcome": "ok"} + return SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="") + + monkeypatch.setattr(cowork.subprocess_cross_os, "run", command) + configured = {"workspace": WORKSPACE, "profile": "chosen", "claude_relayed": True} + assert cowork.configure_cowork(configured, []) + marketplace = cowork._marketplace_root() + assert (marketplace / "plugins" / account.PLUGIN_NAME / "skills/docs/SKILL.md").exists() + assert ["plugin", "install", account.PLUGIN_ID] in [c[1:4] for c in calls] + installed[:] = [{"pluginId": cowork.PLUGIN_ID}, {"pluginId": account.PLUGIN_ID}] + configured["claude_relayed"] = False + assert cowork.configure_cowork(configured, []) + assert not (marketplace / "plugins" / account.PLUGIN_NAME).exists() + assert ["plugin", "uninstall", account.PLUGIN_ID] in [c[1:4] for c in calls] + + +def test_hidden_hook_emits_reload_json_for_relay_only(tmp_path, monkeypatch, account_http): + root = tmp_path / "plugin" + resources = account.load_resources("/opt/claude") + account.write_plugin(root, resources, "/opt/python", WORKSPACE) + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(root)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/cowork/account/org") + state.save_state({"workspace": WORKSPACE, "claude_relayed": True}) + args = [ + "cowork-hook", + "--host", + WORKSPACE, + "--anthropic", + "--claude-binary", + "/opt/claude", + "--claude-config-dir", + "", + ] + result = CliRunner().invoke(app, args, input='{"hook_event_name":"SessionStart"}') + assert result.exit_code == 0, result.output + output = json.loads(result.stdout) + assert output["hookSpecificOutput"]["reloadSkills"] is True + assert "fixture-token" not in result.output + state.save_state({"workspace": WORKSPACE, "claude_relayed": False}) + result = CliRunner().invoke(app, args, input='{"hook_event_name":"SessionStart"}') + assert result.exit_code == 0 + assert result.stdout == "" diff --git a/tests/test_desktop_configure_cli.py b/tests/test_desktop_configure_cli.py index cd09cc671..1c755e82b 100644 --- a/tests/test_desktop_configure_cli.py +++ b/tests/test_desktop_configure_cli.py @@ -228,6 +228,56 @@ def test_managed_claude_configures_cowork_after_mcp_and_skill_sync(configuration configure_cowork.assert_called_once_with(configuration, servers) +@pytest.mark.parametrize("agents", [("claude",), ("claude", "codex"), ("codex", "claude")]) +@pytest.mark.parametrize( + "was_relayed,relayed", [(False, True), (True, False), (True, True), (False, False)] +) +def test_managed_cowork_uses_configured_claude_relay_state( + configuration, monkeypatch, agents, was_relayed, relayed +): + if was_relayed: + configuration["claude_relayed"] = True + configuration["relayed_proxy_port"] = 43210 + configured_inputs = {} + + def configure_agent(state, tools, **kwargs): + tool = tools[0] + configured_inputs[tool] = dict(state) + configured = dict(state) + configured["last_configured_tools"] = [tool] + if tool == "claude": + if relayed: + configured["claude_relayed"] = True + else: + configured.pop("claude_relayed", None) + configured.pop("relayed_proxy_port", None) + return configured + + configure_cowork = Mock() + monkeypatch.setattr(cli, "configure_selected_tools", configure_agent) + monkeypatch.setattr(desktop_setup, "configure_claude_desktop", lambda *a, **k: None) + monkeypatch.setattr(desktop_setup, "configure_claude_cowork", configure_cowork) + monkeypatch.setattr( + cli, + "refresh_managed_config", + lambda *a, **k: ({"enabled_agents": {tool: {} for tool in agents}}, False), + ) + + assert ( + cli._configure_workspace_command( + workspaces=[("https://workspace.example", "chosen-profile")] + ) + == 0 + ) + synced_state = configure_cowork.call_args.args[0] + expected_port = 43210 if was_relayed and relayed else None + assert bool(synced_state.get("claude_relayed")) is relayed + assert synced_state.get("relayed_proxy_port") == expected_port + if agents == ("claude", "codex"): + assert bool(configured_inputs["codex"].get("claude_relayed")) is relayed + assert configured_inputs["codex"].get("relayed_proxy_port") == expected_port + + @pytest.mark.parametrize("failed_sync", ["mcp", "skills"]) def test_managed_sync_failure_preserves_existing_cowork(configuration, monkeypatch, failed_sync): configure_cowork = Mock()