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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ Desktop setup is configure-only and best-effort. Writes retain atomic replacemen
failure rollback, and the Desktop-only five-second lock timeout.
Desktop filters both static and discovered model IDs with case-insensitive `\bclaude-`;
shared discovery and Claude Code lists are unchanged. If no IDs match, setup warns without writes.
When `claude_otel_tracing` is enabled, Desktop's reserved profile also owns
`otlpEndpoint`, `otlpProtocol`, `otlpHeadersHelper`, and `otlpTracesEnabled`. A generated
`.sh`/`.cmd` wrapper pins the configuring Python installation, workspace, and profile
for the existing `otel-headers` command. Native sessions rerun it as tokens rotate;
no bearer is persisted. Disable/revert removes the wrapper, with atomic writes and
rollback shared with the profile. Disabled tracing omits these fields on replacement.
See `docs/claude-desktop.md` for native validation limits and revert behavior.

<details>
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ selects a dedicated Claude Desktop gateway profile. Its model list includes only
Claude-named IDs; Claude Code's catalog is unchanged. Desktop setup errors warn
without failing Claude Code configuration. See [Claude Desktop setup](docs/claude-desktop.md)
for authentication, profile preservation, and native validation limits.
When managed Claude tracing is enabled, the same profile configures Desktop's native
OTLP trace export with a refreshing headers helper; no background service is needed.

## MCP Servers

Expand Down
58 changes: 57 additions & 1 deletion docs/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,60 @@ If authentication is missing, it opens browser login only when Desktop supplies
a new Cowork task. Browser login has a bounded timeout and concurrent sign-ins are
serialized. If the original message failed during login, resend it.

## OpenTelemetry tracing

When the managed coding-agent configuration enables Claude tracing, `ug configure`
also enables Desktop's [native OTLP export](https://claude.com/docs/third-party/claude-desktop/configuration#otlp).
The profile sets:

```json
{
"otlpEndpoint": "https://<workspace>/ai-gateway/otel",
"otlpProtocol": "http/protobuf",
"otlpHeadersHelper": "<absolute-path-to-UG-headers-helper>",
"otlpTracesEnabled": true
}
```

Desktop appends `/v1/traces` to the collector base URL. UG creates
`~/.ucode/claude-desktop-otel-headers.sh` on macOS/Linux or
`%USERPROFILE%\.ucode\claude-desktop-otel-headers.cmd` on Windows. This small wrapper
runs the configuring Python installation's `ug otel-headers` implementation with
the explicit workspace and CLI profile. It prints only a JSON `Authorization`
header, using the existing cached-token/silent-refresh mechanism. Neither the
profile nor wrapper stores a token. No proxy or background service is needed;
Codex's Linux-only OTEL service is unchanged.
The Windows wrapper selects UTF-8 and preserves literal arguments, including
non-ASCII profile names and trailing backslashes.

Requirements: Desktop must support `otlpTracesEnabled` (added in 1.22209.0) and
`otlpHeadersHelper` (added in 1.30096.1), Claude tracing must be enabled in the managed
configuration, and the workspace must accept OTLP trace ingestion with the user's
credentials. Disable Claude tracing and rerun configure to remove the OTLP keys
and helper; `ug revert` also deletes the helper and UG profile. Writes and rollback
cover the wrapper alongside the profile and selection files. Other profiles remain
untouched. If Desktop's application-data directory has already been removed,
revert still deletes the helper and previous-selection state without recreating
Desktop directories.

Restart Desktop and start a new Cowork task or Code session after configuring.
Code and Cowork sessions receive the headers helper and rerun it as tokens rotate,
rather than retaining an inference token copied at session start. Desktop caches
helper output for a few minutes, so rotation is not immediate. The telemetry helper
does not open browser login; if silent refresh fails, reauthenticate through Desktop's
inference sign-in or the Databricks CLI. Claude documents that helper failures can
send telemetry without authentication; check Desktop's logs. UG does not enable
raw-content capture. Desktop can also attempt logs and metrics exports to the collector base;
the gateway currently accepts only `/v1/traces`, not `/v1/logs` or `/v1/metrics`.

Component tests verify the generated configuration and lifecycle, not native export
or ingestion. To validate natively, configure a tracing-enabled workspace, restart
Desktop, complete a small Cowork task, then confirm that its session spans reached
the workspace's configured trace table. Check Desktop logs for authentication or
trace-export errors, and verify further spans from the same open Code session after
token refresh and the helper cache interval. Do not share
unredacted credentials or diagnostic bundles.

## Existing profiles and rollback

Profiles use Anthropic's [documented locations](https://claude.com/docs/third-party/claude-desktop/data-storage#where-data-lives):
Expand Down Expand Up @@ -119,7 +173,9 @@ orchestration; they do not prove GUI inference on every app version.
models without renaming them; that name filter does not reproduce every native
validation rule.
- Anthropic subscription relay, custom OAuth/PAT profiles, Desktop Smart Routing,
connector/plugin compatibility, and native OTEL are not implemented here.
and connector/plugin compatibility are not implemented here. Native OTEL settings
are generated, but native export and ingestion remain unverified; session auth
limits are described above.
- Observed inference applies to Cowork. Ordinary Chat is not claimed to use the
same gateway path.

Expand Down
80 changes: 68 additions & 12 deletions src/ucode/agents/desktop/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import os
import sys
from collections.abc import Mapping, Sequence
Expand Down Expand Up @@ -66,6 +67,7 @@ class _Paths:
metadata: Path
state: Path
lock: Path
otel_helper: Path


def claude_desktop_root_directory(
Expand Down Expand Up @@ -104,20 +106,31 @@ def configure_claude_desktop(
helper_args: Sequence[str],
model_id_to_display_name: Mapping[str, str] | None = None,
custom_headers: Mapping[str, str] | None = None,
otel_endpoint: str | None = None,
otel_headers_command: Sequence[str] = (),
state_path: Path | str | None = None,
profile_key: str | None = None,
) -> ConfigureResult:
"""Replace UG's reserved profile, register it, and select it."""

paths = _resolve_paths(state_path=state_path)
rendered = render_profile(
model_ids=model_ids,
model_id_to_display_name=model_id_to_display_name,
base_url=base_url,
helper_command=helper_command,
helper_args=helper_args,
custom_headers=custom_headers,
otel_endpoint=otel_endpoint,
otel_headers_helper=paths.otel_helper,
)
otel_helper = (
desktop_config.render_otel_helper(
otel_headers_command, windows=sys.platform == "win32"
).encode("utf-8")
if otel_endpoint
else None
)
paths = _resolve_paths(state_path=state_path)
profile_path = paths.directory / f"{PROFILE_ID}.json"
with desktop_config.locked(paths.lock):
profile_before = desktop_config.read_optional_bytes(profile_path)
Expand All @@ -136,13 +149,28 @@ def configure_claude_desktop(
)
changed = desktop_config.write_configuration(
[
(profile_path, profile_before, rendered),
(
paths.otel_helper,
desktop_config.read_optional_bytes(paths.otel_helper),
otel_helper,
0o700,
),
(profile_path, profile_before, _serialize_json(rendered, profile_before), 0o600),
(
paths.metadata,
metadata_before,
desktop_state.apply_metadata(metadata, profile_key=profile_key),
_serialize_json(
desktop_state.apply_metadata(metadata, profile_key=profile_key),
metadata_before,
),
0o600,
),
(
paths.state,
state_before,
_serialize_json({"prior_applied": prior_applied}, state_before),
0o600,
),
(paths.state, state_before, {"prior_applied": prior_applied}),
]
)
return ConfigureResult(
Expand All @@ -157,9 +185,11 @@ def revert_claude_desktop(*, state_path: Path | str | None = None) -> RevertResu
"""Delete UG's reserved profile and registration, preserving unrelated profiles."""

directory = claude_desktop_directory()
if directory is None or not directory.exists():
if directory is None:
return RevertResult(profile_id=None, changed=False)
paths = _resolve_paths(state_path=state_path, initialize=False)
if not any(path.exists() for path in (paths.directory, paths.state, paths.otel_helper)):
return RevertResult(profile_id=None, changed=False)
paths = _resolve_paths(state_path=state_path)
profile_path = paths.directory / f"{PROFILE_ID}.json"
with desktop_config.locked(paths.lock):
profile_before = desktop_config.read_optional_bytes(profile_path)
Expand All @@ -180,21 +210,44 @@ def revert_claude_desktop(*, state_path: Path | str | None = None) -> RevertResu
)
changed = desktop_config.write_configuration(
[
(profile_path, profile_before, None),
(paths.metadata, metadata_before, metadata_after),
(paths.state, state_before, None),
(profile_path, profile_before, None, 0o600),
(
paths.metadata,
metadata_before,
_serialize_json(metadata_after, metadata_before),
0o600,
),
(paths.state, state_before, None, 0o600),
(
paths.otel_helper,
desktop_config.read_optional_bytes(paths.otel_helper),
None,
0o700,
),
]
)
return RevertResult(profile_id=PROFILE_ID if changed else None, changed=changed)


def _resolve_paths(*, state_path: Path | str | None) -> _Paths:
def _serialize_json(payload: Mapping[str, object] | None, before: bytes | None) -> bytes | None:
if payload is None:
return None
if before is not None:
try:
if json.loads(before) == payload:
return before
except (UnicodeError, json.JSONDecodeError):
pass
return json.dumps(payload, indent=2).encode("utf-8")


def _resolve_paths(*, state_path: Path | str | None, initialize: bool = True) -> _Paths:
resolved_directory = claude_desktop_directory()
if resolved_directory is None:
raise ClaudeDesktopUnsupportedError(
"Claude Desktop profile configuration is unavailable on this platform."
)
if not resolved_directory.exists():
if initialize and not resolved_directory.exists():
can_initialize = resolved_directory.parent.is_dir()
if sys.platform == "darwin":
can_initialize = _claude_desktop_installed()
Expand All @@ -210,7 +263,7 @@ def _resolve_paths(*, state_path: Path | str | None) -> _Paths:
raise ClaudeDesktopUnavailableError(
f"Could not initialize Claude Desktop profile directory at {resolved_directory}."
) from exc
if not resolved_directory.is_dir():
if resolved_directory.exists() and not resolved_directory.is_dir():
raise ClaudeDesktopUnavailableError(
f"Claude Desktop profile path is not a directory: {resolved_directory}."
)
Expand All @@ -220,6 +273,9 @@ def _resolve_paths(*, state_path: Path | str | None) -> _Paths:
metadata=resolved_directory / META_FILENAME,
state=resolved_state,
lock=resolved_state.with_name(f".{resolved_state.name}.lock"),
otel_helper=resolved_state.with_name(
"claude-desktop-otel-headers" + (".cmd" if sys.platform == "win32" else ".sh")
),
)


Expand Down
Loading
Loading