From 4ccb0169b9fe6430779a2cc0c292e78cd07005c4 Mon Sep 17 00:00:00 2001 From: Mason Cao Date: Thu, 29 Oct 2026 19:43:57 +0000 Subject: [PATCH 1/3] [AIGTWY-5288] Import Claude Docs account resources into relayed Cowork --- AGENTS.md | 6 + docs/claude-desktop.md | 37 ++ src/ucode/agents/desktop/anthropic_cowork.py | 337 +++++++++++++ src/ucode/agents/desktop/cowork.py | 65 ++- src/ucode/anthropic_mcp_proxy.py | 77 +++ src/ucode/cli.py | 57 ++- src/ucode/desktop_auth.py | 6 + tests/README.md | 8 + tests/integration/README.md | 6 + tests/test_anthropic_cowork.py | 474 +++++++++++++++++++ 10 files changed, 1066 insertions(+), 7 deletions(-) create mode 100644 src/ucode/agents/desktop/anthropic_cowork.py create mode 100644 src/ucode/anthropic_mcp_proxy.py create mode 100644 tests/test_anthropic_cowork.py diff --git a/AGENTS.md b/AGENTS.md index 374dcf7bd..6f3789098 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,12 @@ 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 initial account integration is limited to Claude Docs and the enabled docs skill. +The existing hidden `cowork-hook` command refreshes that plugin's skill on SessionStart; +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..91391c0de 100644 --- a/docs/claude-desktop.md +++ b/docs/claude-desktop.md @@ -183,6 +183,43 @@ 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`. This first integration includes only the account's +eligible **Claude Docs** connector and enabled **docs** skill. Other Anthropic +connectors and skills are not imported yet. The ordinary `ug` workspace plugin and its +`system.ai` MCPs remain available, and Desktop stays in gateway/third-party mode. + +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. It accepts only the connector selected at configure time; changing +accounts or connector installation requires reconfigure. Account API and proxy requests +do not follow redirects. These APIs are undocumented and can change. + +Configure downloads `docs/SKILL.md` and its supporting files into the separate plugin. +Its SessionStart handler uses the existing hidden `ug cowork-hook` command to refresh +that skill and request `reloadSkills` before the first turn. Confirmed disabled skills +are removed from that plugin. Refresh failures warn and retain the last downloaded +skill. 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. Component tests cover +the new plugin and bridge with isolated HTTP/authentication boundaries. **The generated +plugin's hook execution, skill reload, and MCP use inside a real third-party Cowork +session still require native validation.** Start a fresh task and ask it to load the +`ug-account-docs` docs skill and call Claude Docs' read-only `guide` tool, then verify a +workspace `system.ai` tool is available in the same task. Do not create or edit a document +for this check. Importing a skill does not provide any missing native browser, artifact, +or computer-use capabilities. + ## 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..6f2b266ae --- /dev/null +++ b/src/ucode/agents/desktop/anthropic_cowork.py @@ -0,0 +1,337 @@ +"""Relay-only Claude Docs connector and account-skill integration.""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import stat +import tempfile +import zipfile +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from urllib.parse import quote + +import httpx + +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, +) + +PLUGIN_NAME = "ug-account-docs" +PLUGIN_ID = f"{PLUGIN_NAME}@unity-gateway-cowork" +CONNECTOR_NAME = "claude-docs" +MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 + + +@dataclass +class DocsResources: + server_id: str + files: dict[str, bytes] + 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 docs_skill(self) -> dict[str, bytes] | None: + profile = self.json("/api/oauth/profile") + org = (profile.get("organization") or {}).get("uuid") + 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" + skills = self.json(f"{path}/list-skills?include_wiggle_skills=true", org=org) + if not isinstance(skills.get("skills"), list): + raise RuntimeError("Anthropic account API returned no skills array.") + if not any( + s.get("id") == "docs" and s.get("enabled") is not False for s in skills["skills"] + ): + return None + return unpack_docs_skill(self.get(f"{path}/docs/download", org=org)) + + def docs_connector(self) -> str: + result = self.json( + "/v1/mcp_servers?limit=1000&include_additional_installs=true", connectors=True + ) + if not isinstance(result.get("data"), list): + raise RuntimeError("Anthropic account API returned no connector array.") + for server in result["data"]: + if str(server.get("display_name", "")).casefold() != "claude docs": + continue + if server.get("eligible") is not True: + raise RuntimeError("Claude Docs connector is not eligible for this account.") + server_id = server.get("id") + if isinstance(server_id, str) and server_id: + return server_id + raise RuntimeError("Claude Docs connector is not connected to this account.") + + +def unpack_docs_skill(body: bytes) -> dict[str, bytes]: + try: + with zipfile.ZipFile(io.BytesIO(body)) as archive: + files: dict[str, bytes] = {} + 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[1:] if path.parts[:1] == ("docs",) else 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 + if total > MAX_ARCHIVE_BYTES or len(files) >= 512 or relative in files: + raise RuntimeError( + "Anthropic skill archive exceeds limits or has duplicate files." + ) + files[relative] = archive.read(entry) + markdown = files.get("SKILL.md", b"").decode("utf-8") + if not markdown.startswith("---"): + raise RuntimeError("Anthropic docs archive has no valid SKILL.md.") + return files + except (zipfile.BadZipFile, UnicodeError, OSError): + raise RuntimeError("Anthropic docs download was not a readable skill archive.") from None + + +def load_resources(claude_binary: str) -> DocsResources: + with AccountClient(claude_binary) as client: + server_id = client.docs_connector() + files = client.docs_skill() + return DocsResources( + server_id, files or {}, claude_binary, os.environ.get("CLAUDE_CONFIG_DIR", "") + ) + + +def write_skill(destination: Path, files: dict[str, bytes]) -> None: + destination.mkdir(parents=True, exist_ok=True) + for name, content in files.items(): + path = destination / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + +def write_plugin(root: Path, resources: DocsResources, executable: str, workspace: str) -> 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": "Claude Docs and its account skill through Anthropic relay authentication", + "mcpServers": "./.mcp.json", + "skills": "./skills", + }, + ) + write_json( + ".mcp.json", + { + "mcpServers": { + CONNECTOR_NAME: { + "type": "stdio", + "command": executable, + "args": [ + "-m", + "ucode.cli", + "anthropic-mcp-proxy", + "--host", + workspace, + "--server-id", + resources.server_id, + *auth_args, + ], + } + } + }, + ) + 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": 60, + } + ] + } + ] + } + }, + ) + (root / "skills").mkdir(parents=True, exist_ok=True) + if resources.files: + write_skill(root / "skills" / "docs", resources.files) + + +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 / ".docs-sync.lock").open("a+b") as lock: + try: + acquire_exclusive_file_lock(lock, timeout=5) + except TimeoutError: + raise RuntimeError("Another Cowork session is refreshing the docs skill.") from None + try: + return _sync_docs_skill(root, claude_binary) + finally: + release_file_lock(lock) + + +def _sync_docs_skill(root: Path, claude_binary: str) -> dict: + with AccountClient(claude_binary) as client: + files = client.docs_skill() + skills = root / "skills" + skills.mkdir(parents=True, exist_ok=True) + destination = skills / "docs" + staging = Path(tempfile.mkdtemp(prefix=".docs-", dir=root)) + backup = root / ".docs-previous" + try: + write_skill(staging, files or {}) + if backup.exists(): + shutil.rmtree(backup) + if destination.exists(): + os.replace(destination, backup) + try: + if files: + os.replace(staging, destination) + except OSError: + if backup.exists(): + os.replace(backup, destination) + raise + if backup.exists(): + shutil.rmtree(backup) + finally: + if staging.exists(): + shutil.rmtree(staging) + return { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "reloadSkills": True, + "additionalContext": ( + "Anthropic Claude Docs is configured through the ug-account-docs plugin. " + "Use its exposed MCP tools; tool names have the plugin_ug-account-docs prefix. " + + ( + "Load its docs skill before document operations." + if files + else "The account docs skill is disabled or unavailable." + ) + ), + } + } diff --git a/src/ucode/agents/desktop/cowork.py b/src/ucode/agents/desktop/cowork.py index 42e54d8f8..bcbca856e 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,24 @@ 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: + _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, + ) config_io.atomic_write_json( _state_path(), {"version": 1, "cowork_root": str(cowork_root)}, @@ -123,7 +143,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 +325,9 @@ def _write_marketplace( skill_records: list[dict], mcp_config: dict, hooks: dict | None = None, + *, + anthropic_resources: anthropic_cowork.DocsResources | None = None, + workspace: str = "", ) -> None: marketplace_root.parent.mkdir(parents=True, exist_ok=True) staging = Path( @@ -331,6 +361,21 @@ def _write_marketplace( _write_json(staging_plugin / ".mcp.json", mcp_config) if hooks: _write_json(staging_plugin / "hooks" / "hooks.json", hooks) + if anthropic_resources is not None: + name = anthropic_cowork.PLUGIN_NAME + marketplace["plugins"].append( + { + "name": name, + "description": "Anthropic relay account resources", + "source": f"./plugins/{name}", + } + ) + anthropic_cowork.write_plugin( + staging / "plugins" / name, + anthropic_resources, + os.path.abspath(sys.executable), + workspace, + ) _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 +566,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..12db4195e --- /dev/null +++ b/src/ucode/anthropic_mcp_proxy.py @@ -0,0 +1,77 @@ +"""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 client.docs_connector() != server_id: + raise RuntimeError("Claude Docs account changed. 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..70f60a31f 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2511,18 +2511,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 the relay-only Claude Docs 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 +2562,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 skill: {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..17b73cd5f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -343,6 +343,14 @@ 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 Claude Docs/account-skill discovery, +credential-source binding, per-request token rotation, HTTP error redaction and redirect +refusal, archive validation, disabled-skill cleanup, 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 diff --git a/tests/integration/README.md b/tests/integration/README.md index e249c9e07..91efd7678 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -559,6 +559,12 @@ 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 the relay-only Claude Docs +plugin, account API/credential boundaries, skill archives and 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 diff --git a/tests/test_anthropic_cowork.py b/tests/test_anthropic_cowork.py new file mode 100644 index 000000000..2ebdd6ef9 --- /dev/null +++ b/tests/test_anthropic_cowork.py @@ -0,0 +1,474 @@ +"""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" + + +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): + 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": [ + { + "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(200, json={"skills": [{"id": "docs", "enabled": enabled}]}) + assert request.url.path.endswith("/skills/docs/download") + return httpx.Response(200, content=skill_zip()) + + +@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, enabled=handler["enabled"]) + ), + } + ), + ) + return handler + + +def test_account_loads_enabled_docs_and_connector_from_same_credential(account_http): + resources = account.load_resources("/opt/claude") + assert resources.server_id == "server-id" + assert resources.files == {"SKILL.md": MARKDOWN} + 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").files == {} + + +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.docs_connector() + 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", + "docs", + ], +) +def test_archive_rejects_paths_outside_owned_skill_files(name): + with pytest.raises(RuntimeError, match="unsafe path"): + account.unpack_docs_skill(skill_zip(name)) + + +def test_archive_rejects_symlinks_and_invalid_markdown(): + with pytest.raises(RuntimeError, match="unsafe path"): + account.unpack_docs_skill(skill_zip(mode=stat.S_IFLNK | 0o777)) + with pytest.raises(RuntimeError, match="valid SKILL.md"): + account.unpack_docs_skill(skill_zip(content=b"not a skill")) + + +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.DocsResources("server-id", {"SKILL.md": MARKDOWN}, "/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_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 == "" From 337b5ee9f9afc98d61e992fa2c12a0b2af61f8ba Mon Sep 17 00:00:00 2001 From: Mason Cao Date: Thu, 29 Oct 2026 20:01:41 +0000 Subject: [PATCH 2/3] [AIGTWY-5288] Import enabled Anthropic skills and eligible account connectors --- AGENTS.md | 11 +- docs/claude-desktop.md | 45 +-- src/ucode/agents/desktop/anthropic_cowork.py | 274 +++++++++++++----- src/ucode/agents/desktop/cowork.py | 27 +- src/ucode/anthropic_mcp_proxy.py | 6 +- src/ucode/cli.py | 4 +- tests/README.md | 6 +- tests/integration/README.md | 6 +- tests/test_anthropic_cowork.py | 281 +++++++++++++++++-- 9 files changed, 530 insertions(+), 130 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6f3789098..e4bcc106b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,9 +172,14 @@ 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 initial account integration is limited to Claude Docs and the enabled docs skill. -The existing hidden `cowork-hook` command refreshes that plugin's skill on SessionStart; -its dedicated stdio proxy uses the subscription token refresh helper without Databricks +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 91391c0de..4526a6caa 100644 --- a/docs/claude-desktop.md +++ b/docs/claude-desktop.md @@ -186,9 +186,9 @@ below. ## Anthropic account resources in relay mode When `claude_relayed` is enabled, Cowork setup additionally installs -`ug-account-docs@unity-gateway-cowork`. This first integration includes only the account's -eligible **Claude Docs** connector and enabled **docs** skill. Other Anthropic -connectors and skills are not imported yet. The ordinary `ug` workspace plugin and its +`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. The additional plugin uses Anthropic's internal account APIs with the same Claude @@ -196,29 +196,40 @@ subscription credential as relay authentication. Connector discovery needs the r `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. It accepts only the connector selected at configure time; changing -accounts or connector installation requires reconfigure. Account API and proxy requests +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 `docs/SKILL.md` and its supporting files into the separate plugin. +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 -that skill and request `reloadSkills` before the first turn. Confirmed disabled skills -are removed from that plugin. Refresh failures warn and retain the last downloaded -skill. Both proxy and hook bind to configure's original Claude configuration directory; +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. Component tests cover -the new plugin and bridge with isolated HTTP/authentication boundaries. **The generated -plugin's hook execution, skill reload, and MCP use inside a real third-party Cowork -session still require native validation.** Start a fresh task and ask it to load the -`ug-account-docs` docs skill and call Claude Docs' read-only `guide` tool, then verify a -workspace `system.ai` tool is available in the same task. Do not create or edit a document -for this check. Importing a skill does not provide any missing native browser, artifact, -or computer-use capabilities. +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 diff --git a/src/ucode/agents/desktop/anthropic_cowork.py b/src/ucode/agents/desktop/anthropic_cowork.py index 6f2b266ae..ea37ef5f8 100644 --- a/src/ucode/agents/desktop/anthropic_cowork.py +++ b/src/ucode/agents/desktop/anthropic_cowork.py @@ -1,15 +1,19 @@ -"""Relay-only Claude Docs connector and account-skill integration.""" +"""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 @@ -17,23 +21,39 @@ 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" -CONNECTOR_NAME = "claude-docs" MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 +SKILL_MANIFEST = ".account-skills.json" @dataclass -class DocsResources: - server_id: str - files: dict[str, bytes] +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 @@ -117,42 +137,77 @@ def json(self, path: str, **kwargs) -> dict: raise RuntimeError("Anthropic account API returned an unexpected response.") return result - def docs_skill(self) -> dict[str, bytes] | None: + def skills(self) -> tuple[str, list[AccountSkill]]: profile = self.json("/api/oauth/profile") - org = (profile.get("organization") or {}).get("uuid") + 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" - skills = self.json(f"{path}/list-skills?include_wiggle_skills=true", org=org) - if not isinstance(skills.get("skills"), list): - raise RuntimeError("Anthropic account API returned no skills array.") - if not any( - s.get("id") == "docs" and s.get("enabled") is not False for s in skills["skills"] - ): - return None - return unpack_docs_skill(self.get(f"{path}/docs/download", org=org)) - - def docs_connector(self) -> str: + 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 ) - if not isinstance(result.get("data"), list): - raise RuntimeError("Anthropic account API returned no connector array.") - for server in result["data"]: - if str(server.get("display_name", "")).casefold() != "claude docs": - continue - if server.get("eligible") is not True: - raise RuntimeError("Claude Docs connector is not eligible for this account.") - server_id = server.get("id") - if isinstance(server_id, str) and server_id: - return server_id - raise RuntimeError("Claude Docs connector is not connected to this account.") - - -def unpack_docs_skill(body: bytes) -> dict[str, bytes]: + 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, bytes] = {} + files: dict[str, SkillFile] = {} + paths: set[str] = set() total = 0 for entry in archive.infolist(): path = PurePosixPath(entry.filename) @@ -166,42 +221,68 @@ def unpack_docs_skill(body: bytes) -> dict[str, bytes]: raise RuntimeError("Anthropic skill archive contains an unsafe path.") if entry.is_dir(): continue - parts = path.parts[1:] if path.parts[:1] == ("docs",) else path.parts + 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 - if total > MAX_ARCHIVE_BYTES or len(files) >= 512 or relative in files: + 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." ) - files[relative] = archive.read(entry) - markdown = files.get("SKILL.md", b"").decode("utf-8") + 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 docs archive has no valid SKILL.md.") + raise RuntimeError("Anthropic archive has no valid SKILL.md.") return files - except (zipfile.BadZipFile, UnicodeError, OSError): - raise RuntimeError("Anthropic docs download was not a readable skill archive.") from None + 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) -> DocsResources: +def load_resources(claude_binary: str) -> AccountResources: with AccountClient(claude_binary) as client: - server_id = client.docs_connector() - files = client.docs_skill() - return DocsResources( - server_id, files or {}, claude_binary, os.environ.get("CLAUDE_CONFIG_DIR", "") + 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, bytes]) -> None: +def write_skill(destination: Path, files: dict[str, SkillFile]) -> None: destination.mkdir(parents=True, exist_ok=True) - for name, content in files.items(): + for name, file in files.items(): path = destination / name path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(content) + path.write_bytes(file.content) + if file.executable: + path.chmod(0o755) -def write_plugin(root: Path, resources: DocsResources, executable: str, workspace: str) -> None: +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) @@ -218,7 +299,7 @@ def write_json(relative: str, value: object) -> None: { "name": PLUGIN_NAME, "author": {"name": "Databricks"}, - "description": "Claude Docs and its account skill through Anthropic relay authentication", + "description": "Anthropic account connectors and enabled skills through relay authentication", "mcpServers": "./.mcp.json", "skills": "./skills", }, @@ -227,7 +308,7 @@ def write_json(relative: str, value: object) -> None: ".mcp.json", { "mcpServers": { - CONNECTOR_NAME: { + name: { "type": "stdio", "command": executable, "args": [ @@ -237,10 +318,11 @@ def write_json(relative: str, value: object) -> None: "--host", workspace, "--server-id", - resources.server_id, + server_id, *auth_args, ], } + for name, server_id in resources.connectors.items() } }, ) @@ -264,7 +346,7 @@ def write_json(relative: str, value: object) -> None: { "type": "command", "command": subprocess_cross_os.shell_command(argv), - "timeout": 60, + "timeout": 180, } ] } @@ -272,9 +354,7 @@ def write_json(relative: str, value: object) -> None: } }, ) - (root / "skills").mkdir(parents=True, exist_ok=True) - if resources.files: - write_skill(root / "skills" / "docs", resources.files) + _write_skills(root, resources.org, resources.skills, previous_root or root) def sync_hook(payload: object, root: Path, claude_binary: str) -> dict: @@ -283,35 +363,35 @@ def sync_hook(payload: object, root: Path, claude_binary: str) -> dict: 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 / ".docs-sync.lock").open("a+b") as lock: + 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 the docs skill.") from None + raise RuntimeError("Another Cowork session is refreshing account skills.") from None try: - return _sync_docs_skill(root, claude_binary) + return _sync_skills(root, claude_binary) finally: release_file_lock(lock) -def _sync_docs_skill(root: Path, claude_binary: str) -> dict: +def _sync_skills(root: Path, claude_binary: str) -> dict: with AccountClient(claude_binary) as client: - files = client.docs_skill() - skills = root / "skills" - skills.mkdir(parents=True, exist_ok=True) - destination = skills / "docs" - staging = Path(tempfile.mkdtemp(prefix=".docs-", dir=root)) - backup = root / ".docs-previous" + org, skills = client.skills() + staging = Path(tempfile.mkdtemp(prefix=".account-", dir=root)) + destination = root / "skills" + backup = root / ".account-previous" try: - write_skill(staging, files or {}) + _write_skills(staging, org, skills, root) if backup.exists(): shutil.rmtree(backup) if destination.exists(): os.replace(destination, backup) try: - if files: - os.replace(staging, destination) + 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 @@ -320,18 +400,62 @@ def _sync_docs_skill(root: Path, claude_binary: str) -> dict: 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 Claude Docs is configured through the ug-account-docs plugin. " - "Use its exposed MCP tools; tool names have the plugin_ug-account-docs prefix. " - + ( - "Load its docs skill before document operations." - if files - else "The account docs skill is disabled or unavailable." - ) + "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 bcbca856e..ab5d53395 100644 --- a/src/ucode/agents/desktop/cowork.py +++ b/src/ucode/agents/desktop/cowork.py @@ -81,7 +81,7 @@ def configure_cowork(state: dict, mcp_servers: list[dict]) -> bool: 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: + if resources is None and not relayed: _write_marketplace(marketplace_root, plugin_root, skill_records, mcp_config, hooks) else: _write_marketplace( @@ -92,6 +92,7 @@ def configure_cowork(state: dict, mcp_servers: list[dict]) -> bool: hooks, anthropic_resources=resources, workspace=workspace, + preserve_anthropic=relayed, ) config_io.atomic_write_json( _state_path(), @@ -326,8 +327,9 @@ def _write_marketplace( mcp_config: dict, hooks: dict | None = None, *, - anthropic_resources: anthropic_cowork.DocsResources | 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( @@ -361,8 +363,9 @@ def _write_marketplace( _write_json(staging_plugin / ".mcp.json", mcp_config) if hooks: _write_json(staging_plugin / "hooks" / "hooks.json", hooks) - if anthropic_resources is not None: - name = anthropic_cowork.PLUGIN_NAME + 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, @@ -370,12 +373,16 @@ def _write_marketplace( "source": f"./plugins/{name}", } ) - anthropic_cowork.write_plugin( - staging / "plugins" / name, - anthropic_resources, - os.path.abspath(sys.executable), - workspace, - ) + 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) diff --git a/src/ucode/anthropic_mcp_proxy.py b/src/ucode/anthropic_mcp_proxy.py index 12db4195e..af351395d 100644 --- a/src/ucode/anthropic_mcp_proxy.py +++ b/src/ucode/anthropic_mcp_proxy.py @@ -62,8 +62,10 @@ async def _run(server_id: str, claude_binary: str) -> None: def serve(server_id: str, claude_binary: str) -> None: try: with AccountClient(claude_binary) as client: - if client.docs_connector() != server_id: - raise RuntimeError("Claude Docs account changed. Run `ug configure` again.") + 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) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 70f60a31f..b8b27d6b2 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2518,7 +2518,7 @@ def anthropic_mcp_proxy_cmd( claude_binary: Annotated[str, typer.Option("--claude-binary")], claude_config_dir: Annotated[str, typer.Option("--claude-config-dir")] = "", ) -> None: - """Bridge the relay-only Claude Docs connector without storing its bearer.""" + """Bridge a relay-only account connector without storing its bearer.""" import sys from ucode.agents.desktop.anthropic_cowork import credential_source @@ -2580,7 +2580,7 @@ def cowork_hook_cmd( 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 skill: {exc}") + print_warning(f"Could not refresh Anthropic Cowork skills: {exc}") return else: output = hook_output(payload, servers, host, profile, use_pat=use_pat) diff --git a/tests/README.md b/tests/README.md index 17b73cd5f..fa7705cde 100644 --- a/tests/README.md +++ b/tests/README.md @@ -343,9 +343,11 @@ 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 Claude Docs/account-skill discovery, +`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, archive validation, disabled-skill cleanup, a separate generated plugin alongside +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 diff --git a/tests/integration/README.md b/tests/integration/README.md index 91efd7678..34db16a2d 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -559,8 +559,10 @@ 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 the relay-only Claude Docs -plugin, account API/credential boundaries, skill archives and refresh, mode cleanup, and +`../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. diff --git a/tests/test_anthropic_cowork.py b/tests/test_anthropic_cowork.py index 2ebdd6ef9..a09d10d4c 100644 --- a/tests/test_anthropic_cowork.py +++ b/tests/test_anthropic_cowork.py @@ -20,6 +20,7 @@ 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): @@ -31,7 +32,16 @@ def skill_zip(name="docs/SKILL.md", content=MARKDOWN, mode=0): return output.getvalue() -def api_response(request, *, enabled=True): +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": @@ -41,7 +51,9 @@ def api_response(request, *, enabled=True): return httpx.Response( 200, json={ - "data": [ + "data": connectors + if connectors is not None + else [ { "display_name": "Claude Docs", "id": "server-id", @@ -52,9 +64,16 @@ def api_response(request, *, enabled=True): ) assert request.headers["x-organization-uuid"] == "org-id" if request.url.path.endswith("/list-skills"): - return httpx.Response(200, json={"skills": [{"id": "docs", "enabled": enabled}]}) - assert request.url.path.endswith("/skills/docs/download") - return httpx.Response(200, content=skill_zip()) + 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 @@ -69,9 +88,7 @@ def account_http(monkeypatch): lambda **kwargs: original( **{ **kwargs, - "transport": httpx.MockTransport( - lambda request: api_response(request, enabled=handler["enabled"]) - ), + "transport": httpx.MockTransport(lambda request: api_response(request, **handler)), } ), ) @@ -80,14 +97,192 @@ def account_http(monkeypatch): def test_account_loads_enabled_docs_and_connector_from_same_credential(account_http): resources = account.load_resources("/opt/claude") - assert resources.server_id == "server-id" - assert resources.files == {"SKILL.md": MARKDOWN} + 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").files == {} + 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(): @@ -121,7 +316,7 @@ def test_api_error_does_not_include_response_body_or_credential(): transport=transport, ) as client: with pytest.raises(RuntimeError) as error: - client.docs_connector() + client.connectors() assert str(error.value) == "Anthropic account API returned HTTP 403." @@ -134,19 +329,43 @@ def test_api_error_does_not_include_response_body_or_credential(): "docs/..\\outside", "C:/outside", "docs/.hidden/SKILL.md", - "docs", ], ) def test_archive_rejects_paths_outside_owned_skill_files(name): with pytest.raises(RuntimeError, match="unsafe path"): - account.unpack_docs_skill(skill_zip(name)) + account.unpack_skill(skill_zip(name)) def test_archive_rejects_symlinks_and_invalid_markdown(): with pytest.raises(RuntimeError, match="unsafe path"): - account.unpack_docs_skill(skill_zip(mode=stat.S_IFLNK | 0o777)) + account.unpack_skill(skill_zip(mode=stat.S_IFLNK | 0o777)) with pytest.raises(RuntimeError, match="valid SKILL.md"): - account.unpack_docs_skill(skill_zip(content=b"not a skill")) + 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): @@ -155,7 +374,13 @@ def test_relay_plugin_coexists_with_workspace_docs_and_mcp(tmp_path): workspace_skill.mkdir(parents=True) (workspace_skill / "SKILL.md").write_text("workspace docs") config = {"mcpServers": {"system-ai": {"type": "stdio", "command": "/opt/ug"}}} - resources = account.DocsResources("server-id", {"SKILL.md": MARKDOWN}, "/opt/claude", "") + 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, @@ -263,6 +488,28 @@ def test_non_relay_workspace_cannot_start_anthropic_proxy(): 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 867318044aa006eefcaf7e48b508857214724873 Mon Sep 17 00:00:00 2001 From: Mason Cao Date: Thu, 29 Oct 2026 21:14:58 +0000 Subject: [PATCH 3/3] [AIGTWY-5288] Preserve configured relay mode during Cowork sync --- docs/claude-desktop.md | 2 ++ src/ucode/cli.py | 5 +++ tests/README.md | 5 ++- tests/integration/README.md | 5 +-- tests/test_desktop_configure_cli.py | 50 +++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/claude-desktop.md b/docs/claude-desktop.md index 4526a6caa..ad4b46107 100644 --- a/docs/claude-desktop.md +++ b/docs/claude-desktop.md @@ -190,6 +190,8 @@ When `claude_relayed` is enabled, Cowork setup additionally installs 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 diff --git a/src/ucode/cli.py b/src/ucode/cli.py index b8b27d6b2..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) diff --git a/tests/README.md b/tests/README.md index fa7705cde..86b0da114 100644 --- a/tests/README.md +++ b/tests/README.md @@ -366,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 34db16a2d..816fddd41 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -574,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_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()