Update to Microsoft Dataverse
Snapshot Oct 24, 2026 · 13:01 UTC · version 1.16.0
Package or technical metadata updated
Package or technical metadata updated
Observed in package metadata. These changes alone do not establish a new customer-facing feature.
Up to 3 plugins free · no card required.
All fields and original values.
These are saved file metadata. Open a changed file to read its text edits.
Package file
Sep 30, 2026 · 23:13 UTC
6e40c3dfa58513854e5ec8e5db8da85ddf561dc41b2ccbdfa149b44c9f4cfa28
Oct 24, 2026 · 13:01 UTC
5677ada5e3f5f6af333915498383bfeb0c7afbd40bcd1d0b4915a11b606411e5
Package file
Sep 30, 2026 · 23:13 UTC
533
Oct 24, 2026 · 13:01 UTC
544
Package file
Sep 30, 2026 · 23:13 UTC
{"sha256":"5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79","size":5127}
Oct 24, 2026 · 13:01 UTC
Not present
Package file
Sep 30, 2026 · 23:13 UTC
{"sha256":"5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79","size":5127}
Oct 24, 2026 · 13:01 UTC
Not present
Compare saved observations
Download comparison JSONChanged files
Choose a file to see its text changes. Binary or large files show metadata only.
.codex-plugin/assets/composer-icon.svg →
.codex-plugin/assets/logo.svg →
scripts/__pycache__/auth.cpython-314.pyc →
scripts/__pycache__/enable-mcp-client.cpython-314.pyc →
scripts/__pycache__/mcp_remote.cpython-314.pyc →
scripts/__pycache__/test_auth.cpython-314.pyc →
scripts/enable-mcp-client.py →
skills/dv-admin/references/erp-batch.md →
skills/dv-admin/references/orgdb-settings.md →
skills/dv-admin/references/recycle-bin.md →
skills/dv-admin/references/settings-overrides.md →
skills/dv-connect/references/erp-detection.md →
skills/dv-connect/references/headless-hosts.md →
skills/dv-connect/references/helper-scripts.md →
skills/dv-connect/references/mcp-configuration.md →
skills/dv-connect/references/tools-setup.md →
skills/dv-data/references/erp-writes.md →
skills/dv-data/references/multi-table-fk-import.md →
skills/dv-data/references/sample-data-generation.md →
skills/dv-metadata/references/alternate-keys.md →
skills/dv-metadata/references/forms-and-views.md →
skills/dv-metadata/references/metadata-propagation.md →
skills/dv-overview/references/erp-target.md →
skills/dv-overview/references/windows-scripting.md →
skills/dv-query/references/erp-reads.md →
skills/dv-query/references/jupyter-setup.md →
skills/dv-query/references/querybuilder.md →
skills/dv-query/references/web-api-advanced.md →
skills/erp-xpp/references/authoring.md →
skills/erp-xpp/references/commands.md →
skills/erp-xpp/references/validation.md →
scripts/auth.py
− removed · + added
--- before +++ after @@ -1,1156 +1,1274 @@ -""" -auth.py — Acquire Dataverse tokens via Azure Identity. - -Auth chain (silent tiers first; interactive only when every silent tier is -unavailable, so a working path wins without stranding the user -- issue #108): - 1. Service principal (CLIENT_ID + CLIENT_SECRET in .env) -- non-interactive, - terminal (CI fails fast; no interactive fallback when SP is configured). - 2. Shared Dataverse CLI token cache -- silent, no prompt, populated by - `dataverse auth create` (see dv-connect Step 2). Uses the same MSAL v3 - cache the `@microsoft/dataverse` stdio MCP proxy reads. Probed at build - time against both the tenant-specific and `organizations` authority so an - authority mismatch falls through instead of hard-failing. - 3. Azure CLI (`az login`) -- silent, scoped to TENANT_ID; skipped when az is - absent or not logged in. - 4. Interactive (host-gated, last): workspace-cache device-code when - DATAVERSE_TOKEN_CACHE_DIR is set (ephemeral hosts); else a system-browser - InteractiveBrowserCredential on a desktop host (beats the CA device-code - block, and uses the browser not the MSAL broker so it sidesteps the macOS - broker bug); else DeviceCodeCredential on a headless host. Each persists an - AuthenticationRecord so later processes refresh silently. - -The shared cache uses the Dataverse CLI app registration -(``0c412cc3-0dd6-449b-987f-05b053db9457``) so every Dataverse-skills tool -authenticates as the same OAuth client and AAD treats it as one sign-in. - -Token caching layout (path 2): - Windows: %LocalAppData%\\Microsoft\\DataverseCli\\tokencache_msalv3.dat (DPAPI) - macOS: Keychain service ``dataverse_cli_service`` / account ``dataverse_cli_account`` - Linux: libsecret schema ``com.microsoft.dataversecli`` (desktop), or a plaintext - ``tokencache_msalv3.dat`` under ``$XDG_DATA_HOME/Microsoft/DataverseCli`` - on headless hosts with no keyring (matches the CLI's WithLinuxUnprotectedFile) - -Functions: - load_env() — loads .env into os.environ - get_client(skill) — returns a DataverseClient with plugin attribution - get_token(scope=None) — returns a raw access token string - get_plugin_headers(skill, token) — returns headers dict for raw Web API calls - -Usage: - # PREFERRED — SDK with plugin attribution: - from auth import get_client - client = get_client("dv-data") - - # Raw Web API only (forms, views, $ref, $apply): - from auth import get_token, get_plugin_headers - headers = get_plugin_headers("dv-metadata", get_token()) - -Reads from .env in the repo root (parent of scripts/) or current working directory: - DATAVERSE_URL — required - TENANT_ID — required - CLIENT_ID — optional, enables service principal auth - CLIENT_SECRET — optional, enables service principal auth - DATAVERSE_TOKEN_CACHE_DIR — optional; on ephemeral hosts (ChatGPT web / Codex - sandbox) where $HOME is wiped between turns, set this to a - workspace-relative dir (e.g. .dataverse) so the device-code - token cache persists and refreshes silently within a session -""" - -import os -import re -import sys -import time -from pathlib import Path - -# Dataverse CLI app registration. Must match McpOAuth.Config.ClientId in -# DataverseCli/Auth/AuthClientConfig.cs so that tokens minted by -# `dataverse auth create` and the @microsoft/dataverse stdio MCP proxy can -# be silently reused by Python scripts (no second device-code prompt). -_DATAVERSE_CLI_CLIENT_ID = "0c412cc3-0dd6-449b-987f-05b053db9457" - -# Legacy AuthenticationRecord path for the device-code fallback (path 3). -# Kept for backward compatibility with workspaces that authenticated via the -# previous auth.py before the shared-cache change. -_AUTH_RECORD_PATH = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".IdentityService" / "dataverse_cli_auth_record.json" - -# Cross-workspace config so returning users skip the full dv-connect dance. -_USER_CONFIG_DIR = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".dataverse-skills" -_USER_CONFIG_PATH = _USER_CONFIG_DIR / "config.json" - - -def _load_user_config(): - """Load DATAVERSE_URL and TENANT_ID from user-level config when .env is absent.""" - if not _USER_CONFIG_PATH.exists(): - return - try: - import json - cfg = json.loads(_USER_CONFIG_PATH.read_text(encoding="utf-8")) - loaded = [] - for key in ("DATAVERSE_URL", "TENANT_ID"): - val = cfg.get(key) - if val and not os.environ.get(key): - os.environ.setdefault(key, val) - loaded.append(key) - if loaded: - print( - f"NOTE: loaded {', '.join(loaded)} from {_USER_CONFIG_PATH} " - f"(no .env found). Target: {cfg.get('DATAVERSE_URL', '?')}", - flush=True, - ) - except Exception: - pass - - -def _save_user_config(): - """Persist URL and tenant to user-level config for cross-workspace reuse.""" - import json - url = os.environ.get("DATAVERSE_URL") - tenant = os.environ.get("TENANT_ID") - if not url or not tenant: - return - try: - _USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True) - try: - os.chmod(_USER_CONFIG_DIR, 0o700) - except Exception: - pass - _USER_CONFIG_PATH.write_text( - json.dumps({"DATAVERSE_URL": url, "TENANT_ID": tenant}, indent=2), - encoding="utf-8", - ) - except Exception: - pass - - -def load_env(): - """Load key=value pairs from .env into os.environ (does not overwrite existing vars). - - Searches for .env in two locations (first match wins): - 1. The repo root (parent of the directory containing this script) - 2. The current working directory - This ensures ``cd scripts && python auth.py`` works the same as - ``python scripts/auth.py`` from the repo root. - - Falls back to user-level config (~/.dataverse-skills/config.json) when .env - is absent, so returning users on ephemeral-workspace hosts (ChatGPT desktop - app, Codex) skip the full dv-connect setup. - """ - script_dir = Path(__file__).resolve().parent - candidates = [script_dir.parent / ".env", Path(".env")] - env_path = next((p for p in candidates if p.exists()), None) - if env_path is not None: - for line in env_path.read_text().splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, _, value = line.partition("=") - os.environ.setdefault(key.strip(), value.strip()) - if not os.environ.get("DATAVERSE_URL") or not os.environ.get("TENANT_ID"): - _load_user_config() - - -_credential = None - - -def _dataverse_scope(): - """Return the ``{DATAVERSE_URL}/.default`` OAuth scope, or None if unset.""" - url = os.environ.get("DATAVERSE_URL", "").rstrip("/") - if not url: - return None - return f"{url}/.default" - - -def _shared_cache_persistences(): - """Return the ordered list of MSAL cache persistences to try for the shared - DataverseCLI cache (platform-specific). Never raises -- returns [] on any issue. - On Linux this is libsecret-first (desktop) then the plaintext MSAL v3 file - (headless), matching the CLI's WithLinuxUnprotectedFile fallback. - """ - persistences = [] - try: - if sys.platform == "win32": - from msal_extensions import FilePersistenceWithDataProtection - cache_path = ( - Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) - / "Microsoft" / "DataverseCli" / "tokencache_msalv3.dat" - ) - if cache_path.exists(): - persistences.append(FilePersistenceWithDataProtection(str(cache_path))) - elif sys.platform == "darwin": - from msal_extensions import KeychainPersistence - # Fallback file path is required by msal-extensions but unused on - # macOS -- the Keychain service/account match DataverseCLI's - # PacAuthApplicationFactory constants exactly. - fallback = str(Path.home() / ".dataverse_cli_msal_cache") - persistences.append( - KeychainPersistence(fallback, "dataverse_cli_service", "dataverse_cli_account") - ) - else: - fallback = str(Path.home() / ".dataverse_cli_msal_cache") - try: - from msal_extensions import LibsecretPersistence - persistences.append( - LibsecretPersistence( - fallback, - schema_name="com.microsoft.dataversecli", - attributes={"Version": "1", "ProductGroup": "DataverseCli"}, - ) - ) - except Exception: - pass # libsecret backend unavailable on this host -- skip it. - from msal_extensions import FilePersistence - xdg_data = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share") - plaintext_path = Path(xdg_data) / "Microsoft" / "DataverseCli" / "tokencache_msalv3.dat" - if plaintext_path.exists(): - persistences.append(FilePersistence(str(plaintext_path))) - except Exception: - return [] - return persistences - - -def _build_shared_msal_cache(): - """Open the DataverseCLI MSAL cache for silent reuse, probing it at build time. - - Returns ``(msal.PublicClientApplication, list[account])`` only when a silent - token actually comes back -- probed against BOTH the tenant-specific and the - ``organizations`` authority, because ``dataverse auth create`` may have written - the cache under a different authority than TENANT_ID (issue #108, defect 2). - Returns ``None`` on any miss so the caller falls through to the next tier -- - the shared-cache path can never break auth. - """ - try: - import msal - from msal_extensions import PersistedTokenCache - except ImportError: - return None - - tenant_id = os.environ.get("TENANT_ID") - if not tenant_id: - return None - - scope = _dataverse_scope() - authorities = [ - f"https://login.microsoftonline.com/{tenant_id}", - "https://login.microsoftonline.com/organizations", - ] - for persistence in _shared_cache_persistences(): - for authority in authorities: - try: - app = msal.PublicClientApplication( - client_id=_DATAVERSE_CLI_CLIENT_ID, - authority=authority, - token_cache=PersistedTokenCache(persistence), - ) - accounts = app.get_accounts() - if not accounts: - continue - if scope is None: - # No DATAVERSE_URL to probe with -- trust account presence. - return app, accounts - probe = app.acquire_token_silent([scope], account=accounts[0]) - if probe and "access_token" in probe: - return app, accounts # this authority actually yields a token - except Exception: - continue # never let the shared-cache path break auth - return None - - -class _MsalSharedCacheCredential: - """TokenCredential adapter over an msal PublicClientApplication. - - Implements just enough of the azure-core TokenCredential protocol - (`get_token(*scopes, **kwargs)` returning AccessToken) to satisfy - DataverseClient and direct urllib callers. - """ - - def __init__(self, app, accounts): - self._app = app - self._accounts = accounts - - def get_token(self, *scopes, **kwargs): - from azure.core.credentials import AccessToken - from azure.identity import CredentialUnavailableError - # Single-account is the common case. If the shared cache happens to - # contain multiple accounts, the first one wins -- deterministic and - # matches what `dataverse auth select` would surface as active. - # Forward a CAE / Conditional-Access claims challenge when azure-core - # passes one -- CA-hardened tenants are this plugin's target scenario. - # A None claims_challenge is a harmless no-op. - result = self._app.acquire_token_silent( - list(scopes), account=self._accounts[0], - claims_challenge=kwargs.get("claims"), - ) - if not result or "access_token" not in result: - # Fall through, do NOT hard-fail: a cached account can exist while - # silent acquisition fails (authority mismatch, expired refresh - # token). Raising CredentialUnavailableError lets the chain try the - # next tier instead of stranding the user. (Issue #108, defect 1.) - raise CredentialUnavailableError( - "Shared DataverseCLI cache present but silent token acquisition " - "failed (often an authority mismatch or expired refresh token)." - ) - expires_on = int(time.time()) + int(result.get("expires_in", 3600)) - return AccessToken(result["access_token"], expires_on) - - def close(self): # pragma: no cover — parity with azure-identity credentials - pass - - -# Default workspace cache dir used on headless/ephemeral hosts when the user has not -# set DATAVERSE_TOKEN_CACHE_DIR (see _workspace_token_cache_path). -_DEFAULT_WORKSPACE_CACHE_DIRNAME = ".dataverse" - -# Explicit opt-out values for DATAVERSE_TOKEN_CACHE_DIR: keep the OS default cache -# (do not write a plaintext refresh token into the workspace). -_CACHE_DIR_OPT_OUT = frozenset({"off", "0", "false", "no", "none", "disable", "disabled"}) - - -def _should_use_workspace_cache(): - """Pure decision (no I/O): whether the persisted workspace token cache is used, - and via which source. This is the SINGLE source of truth so - _workspace_token_cache_path (which builds the path) and _run_diagnose (which - reports the tier) can never drift. Returns: - "explicit" -- DATAVERSE_TOKEN_CACHE_DIR is set to a usable path. - "default" -- unset on a HEADLESS, non-CI host -> auto-default to <workspace>/.dataverse. - None -- keep the OS default cache (desktop, CI, or an explicit opt-out). - """ - cache_dir = os.environ.get("DATAVERSE_TOKEN_CACHE_DIR") - if cache_dir is not None and cache_dir.strip().lower() in _CACHE_DIR_OPT_OUT: - return None # explicit opt-out -- keep the per-process OS default cache - if cache_dir: - return "explicit" - # No explicit setting: auto-default ONLY where the OS cache will not survive -- - # a headless, non-CI host. Desktop hosts keep their secure default cache; CI - # never reaches an interactive tier (service principal is terminal earlier). - if _host_has_browser() or _is_ci(): - return None - return "default" - - -def _workspace_token_cache_path(): - """Return the MSAL v3 cache file path used to persist the device-code refresh - token across separate Python processes, or None to keep the OS default cache. - The use/skip decision is _should_use_workspace_cache(); this function only turns - a positive decision into a concrete, git-ignored, owner-only file path. - - Resolution (see _should_use_workspace_cache): - - DATAVERSE_TOKEN_CACHE_DIR set to a path -> use it (a relative dir is anchored - to the workspace root, matching how load_env finds .env). - - opt-out value (off/false/0/no/none) -> None. - - unset on a HEADLESS, non-CI host (ChatGPT web / Codex sandbox / SSH / container) - -> DEFAULT to ``<workspace>/.dataverse`` (the OS cache lives under $HOME, which - the sandbox wipes between Python processes, so device code would re-prompt every - process; the persisted workspace cache makes sign-in once per conversation). - - unset on a desktop host -> None (keep the secure OS cache: Windows DPAPI / - macOS Keychain / Linux desktop keyring -- no behavior change). - - Security: on non-Windows hosts (Linux, and macOS over SSH) the cache holds a - PLAINTEXT refresh token (no keyring; Windows uses DPAPI). The directory is created - owner-only (0700 on POSIX) with a self-contained `.gitignore` (`*`) so the token is - excluded from version control, and an ephemeral sandbox is torn down after the - session. To keep the OS default cache instead, set DATAVERSE_TOKEN_CACHE_DIR=off. - """ - decision = _should_use_workspace_cache() - if decision is None: - return None - cache_dir = ( - os.environ.get("DATAVERSE_TOKEN_CACHE_DIR") - if decision == "explicit" - else _DEFAULT_WORKSPACE_CACHE_DIRNAME - ) - try: - path = Path(cache_dir) - if not path.is_absolute(): - # Anchor a relative dir to the workspace root (this file's parent dir's - # parent -- the deployed <project>/scripts/auth.py layout), NOT the - # current working directory, so the cache location is stable no matter - # where the process is launched from (matches how load_env finds .env). - path = Path(__file__).resolve().parent.parent / path - path.mkdir(parents=True, exist_ok=True) - # Defense in depth: exclude the token cache from git even if the repo-root - # .gitignore does not cover this dir. - gitignore = path / ".gitignore" - if not gitignore.exists(): - gitignore.write_text("*\n", encoding="utf-8") - try: - os.chmod(path, 0o700) # owner-only on POSIX; benign on Windows - except Exception: - pass - return path / "tokencache_msalv3.dat" - except Exception: - return None - - -class _MsalDeviceCodeCredential: - """TokenCredential over an msal app that persists at an explicit cache path. - - Silent-refresh from the cache when possible; device-code sign-in on a cache miss. - Used only when DATAVERSE_TOKEN_CACHE_DIR is set (opt-in, ephemeral hosts) so the - refresh token lives in the persisted workspace cache and is reused across turns. - Implements the azure-core TokenCredential protocol (get_token). - """ - - def __init__(self, app): - self._app = app - - def get_token(self, *scopes, **kwargs): - from azure.core.credentials import AccessToken - scope_list = list(scopes) - claims = kwargs.get("claims") # CAE / Conditional-Access challenge, if any - result = None - accounts = self._app.get_accounts() - if accounts: - result = self._app.acquire_token_silent( - scope_list, account=accounts[0], claims_challenge=claims, - ) - if not result or "access_token" not in result: - flow = self._app.initiate_device_flow(scopes=scope_list) - if "user_code" not in flow: - raise RuntimeError( - "Failed to start device-code flow: " - f"{flow.get('error_description', flow)}" - ) - print( - f"\nTo sign in, visit {flow['verification_uri']} and enter code: " - f"{flow['user_code']}", - flush=True, - ) - _expiry_min = max(1, int(flow.get("expires_in", 900)) // 60) - print( - f"(Waiting for you to complete the login in your browser -- " - f"this code expires in ~{_expiry_min} min...)\n", - flush=True, - ) - result = self._app.acquire_token_by_device_flow( - flow, claims_challenge=claims, - ) # blocks until complete - if not result or "access_token" not in result: - detail = result.get("error_description", result) if result else "no response" - raise RuntimeError(f"Device-code authentication failed: {detail}") - expires_on = int(time.time()) + int(result.get("expires_in", 3600)) - return AccessToken(result["access_token"], expires_on) - - def close(self): # pragma: no cover - pass - - -def _is_ci(): - """True on CI / build agents (no interactive user), even on win32/darwin -- - GitHub Actions, ADO agents, containerized Windows. Treats only genuinely- - truthy values as set (avoids the CI="false" trap). - """ - def _flag(name): - return os.environ.get(name, "").strip().lower() not in ("", "0", "false", "no") - - return _flag("CI") or _flag("GITHUB_ACTIONS") or _flag("TF_BUILD") or _flag("BUILD_BUILDID") - - -def _host_has_browser(): - """True if an interactive system browser is likely available. - - CI / build agents are headless even when sys.platform is win32/darwin -- - launching a browser there crashes with no user session, so they route to the - device-code tier instead. After the CI gate: a Windows console or RDP session - has a browser, but a Windows service / headless container has an empty or - "Services" SESSIONNAME; macOS (non-CI) always has a session browser; Linux - needs a display server (DISPLAY / WAYLAND_DISPLAY) -- headless Linux (SSH / - container / ChatGPT web) has neither and uses device-code. - """ - if _is_ci(): - return False - if sys.platform == "win32": - session = os.environ.get("SESSIONNAME", "").strip().lower() - return session not in ("", "services") - if sys.platform == "darwin": - # An SSH / remote macOS session has no local browser -- use device-code. - if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"): - return False - return True - return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) - - -class _SilentChain: - """Try each silent tier in order; skip any that is unavailable OR errors, so a - convenience tier (e.g. az CLI logged into another tenant) can never strand the - fall-through to the interactive tier. Raises CredentialUnavailableError only - when every tier is exhausted. - """ - - def __init__(self, tiers): - self._tiers = tiers # list[(name, credential)] - self.last_reasons = [] # why each tier was skipped -- surfaced before a prompt - - def get_token(self, *scopes, **kwargs): - from azure.identity import CredentialUnavailableError - reasons = [] - for tier_name, cred in self._tiers: - try: - return cred.get_token(*scopes, **kwargs) - except CredentialUnavailableError: - reasons.append(f"{tier_name}: unavailable") - except Exception as e: # noqa: BLE001 -- a silent tier must never strand the chain - reasons.append(f"{tier_name}: {type(e).__name__}") - self.last_reasons = reasons - raise CredentialUnavailableError( - "no silent credential available (" + "; ".join(reasons) + ")" - ) - - def close(self): # pragma: no cover - pass - - -class _FallbackCredential: - """Silent tiers first; if all are unavailable, build and use the single - host-appropriate interactive tier ON DEMAND -- so an interactive prompt - happens ONLY when no silent path works. (Issue #108: never strand, never - prompt when a silent tier would do.) - """ - - def __init__(self, silent, interactive_builder): - self._silent = silent - self._interactive_builder = interactive_builder - self._interactive = None - - def get_token(self, *scopes, **kwargs): - from azure.identity import CredentialUnavailableError - if self._silent is not None: - try: - return self._silent.get_token(*scopes, **kwargs) - except CredentialUnavailableError as e: - # Surface WHY every silent tier was skipped so a sudden interactive - # prompt is explicable ("it just prompted me and I don't know why"). - reasons = getattr(self._silent, "last_reasons", None) - detail = "; ".join(reasons) if reasons else str(e) - print( - f"No silent credential available ({detail}); " - f"falling back to interactive sign-in.", - flush=True, - ) - if self._interactive is None: - self._interactive = self._interactive_builder() - return self._interactive.get_token(*scopes, **kwargs) - - def close(self): # pragma: no cover - pass - - -def _build_workspace_device_code_credential(cache_path, tenant_id): - """Build the opt-in workspace-cache device-code credential, or None if msal - is unavailable. The MSAL cache (incl. refresh token) lives at cache_path in - the persisted workspace, so device code is once per conversation, not per turn. - """ - try: - import msal - from msal_extensions import PersistedTokenCache - if sys.platform == "win32": - from msal_extensions import FilePersistenceWithDataProtection - persistence = FilePersistenceWithDataProtection(str(cache_path)) - else: - from msal_extensions import FilePersistence - persistence = FilePersistence(str(cache_path)) - app = msal.PublicClientApplication( - client_id=_DATAVERSE_CLI_CLIENT_ID, - authority=f"https://login.microsoftonline.com/{tenant_id}", - token_cache=PersistedTokenCache(persistence), - ) - return _MsalDeviceCodeCredential(app) - except ImportError: - return None - - -def _build_interactive_tier(tenant_id): - """Build ``(credential, kind)`` for the single interactive tier this host uses. - - kind is one of ``workspace-device-code`` / ``interactive-browser`` / - ``device-code``. Selection: - - workspace cache available (DATAVERSE_TOKEN_CACHE_DIR set, or auto-defaulted on - a headless non-CI host) -> workspace-cache device-code (self-persisting). - - desktop host with a browser -> InteractiveBrowserCredential (system browser). - - headless host with no buildable workspace cache -> DeviceCodeCredential (legacy). - The azure-identity interactive tiers persist an AuthenticationRecord on first - login so a later process refreshes silently. Called ONLY after every silent - tier is exhausted, so the eager first-login prompt here is never premature. - """ - # CI / unattended host: no interactive tier can succeed (no browser; a device - # code just blocks ~15 min then expires into a log nobody reads). Service - # principal is a terminal tier BEFORE we reach here, so being here under CI - # means no SP is configured -- fail fast with an actionable message instead of - # a guaranteed hang. - if _is_ci(): - raise RuntimeError( - "Unattended/CI host detected with no working silent credential " - "(service principal, shared cache, or az login). Interactive sign-in " - "cannot succeed here -- set CLIENT_ID + CLIENT_SECRET for a service " - "principal, or run `python scripts/auth.py --diagnose` to see which " - "tier failed." - ) - workspace_cache = _workspace_token_cache_path() - if workspace_cache is not None: - cred = _build_workspace_device_code_credential(workspace_cache, tenant_id) - if cred is not None: - return cred, "workspace-device-code" - - from azure.identity import ( - AuthenticationRecord, - DeviceCodeCredential, - InteractiveBrowserCredential, - TokenCachePersistenceOptions, - ) - - cache_opts = TokenCachePersistenceOptions( - name="dataverse_cli", allow_unencrypted_storage=True - ) - record = None - if _AUTH_RECORD_PATH.exists(): - try: - record = AuthenticationRecord.deserialize( - _AUTH_RECORD_PATH.read_text(encoding="utf-8") - ) - except Exception: - record = None # corrupt/stale record -- re-authenticate - - if _host_has_browser(): - cred = InteractiveBrowserCredential( - tenant_id=tenant_id, - client_id=_DATAVERSE_CLI_CLIENT_ID, - cache_persistence_options=cache_opts, - authentication_record=record, - ) - kind = "interactive-browser" - else: - def _prompt_callback(verification_uri, user_code, _expires_on): - print(f"\nTo sign in, visit {verification_uri} and enter code: {user_code}", flush=True) - print("(Waiting for you to complete the login in your browser...)\n", flush=True) - - cred = DeviceCodeCredential( - tenant_id=tenant_id, - client_id=_DATAVERSE_CLI_CLIENT_ID, - prompt_callback=_prompt_callback, - cache_persistence_options=cache_opts, - authentication_record=record, - ) - kind = "device-code" - - # First login: capture + persist the AuthenticationRecord so later processes - # refresh silently. authenticate() performs the one interactive flow; because - # we are here only after the silent tiers failed, it is never premature. - if record is None: - try: - scope = _dataverse_scope() - new_record = cred.authenticate(scopes=[scope] if scope else None) - _AUTH_RECORD_PATH.parent.mkdir(parents=True, exist_ok=True) - _AUTH_RECORD_PATH.write_text(new_record.serialize(), encoding="utf-8") - except KeyboardInterrupt: - raise # user cancelled sign-in -- honor it, don't fall through to a 2nd prompt - except Exception as e: # noqa: BLE001 -- non-fatal: get_token still works, may reprompt - print( - f"NOTE: first interactive sign-in did not persist a record " - f"({type(e).__name__}); a later token request may reprompt.", - flush=True, - ) - - return cred, kind - - -def _get_credential(): - """ - Return a TokenCredential, creating one on first call. - - The credential is cached for the lifetime of the process. Resolution - order: service principal (terminal) -> silent chain (shared DataverseCLI - cache, then az CLI) -> single host-gated interactive tier, used only when - every silent tier is unavailable (issue #108). - """ - global _credential - if _credential is not None: - return _credential - - load_env() - - tenant_id = os.environ.get("TENANT_ID") - dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/") - client_id = os.environ.get("CLIENT_ID") - client_secret = os.environ.get("CLIENT_SECRET") - - if not tenant_id or not dataverse_url: - missing = [k for k, v in [("TENANT_ID", tenant_id), ("DATAVERSE_URL", dataverse_url)] if not v] - print(f"ERROR: .env is missing required values: {', '.join(missing)}", flush=True) - print(" Run the init sequence (/dataverse:init) to create .env.", flush=True) - sys.exit(1) - - try: - from azure.identity import AzureCliCredential, ClientSecretCredential - except ImportError: - print("ERROR: azure-identity not installed. Run: pip install --upgrade azure-identity", flush=True) - sys.exit(1) - - # Warn if only one of CLIENT_ID / CLIENT_SECRET is set - if bool(client_id) != bool(client_secret): - print("WARNING: Only one of CLIENT_ID / CLIENT_SECRET is set. Both are required for", flush=True) - print(" service principal auth. Falling back to shared cache / az / interactive.", flush=True) - - # Tier 1: Service principal. Terminal -- CI must fail fast, never fall to an - # interactive prompt that would hang an unattended run. - if client_id and client_secret: - _credential = ClientSecretCredential( - tenant_id=tenant_id, - client_id=client_id, - client_secret=client_secret, - ) - return _credential - - # No service principal -> silent tiers, then a single host-gated interactive - # tier used ONLY if every silent tier is unavailable (issue #108: never - # strand, and never prompt when a silent path works). - silent_tiers = [] - - # Tier 2: Shared DataverseCLI MSAL cache (populated by `dataverse auth - # create`; same client ID as the @microsoft/dataverse MCP proxy). Probed at - # build time so it is only selected when it actually yields a token. - shared = _build_shared_msal_cache() - if shared is not None: - app, accounts = shared - silent_tiers.append(("shared-cache", _MsalSharedCacheCredential(app, accounts))) - - # Tier 3: Azure CLI (`az login`). Scoped to TENANT_ID; AzureCliCredential - # raises CredentialUnavailableError when az is absent / not logged in, so it - # is free when unused and a high-value silent win when present. - silent_tiers.append(("azure-cli", AzureCliCredential(tenant_id=tenant_id))) - - silent = _SilentChain(silent_tiers) - - def _interactive_builder(): - cred, _kind = _build_interactive_tier(tenant_id) - return cred - - _credential = _FallbackCredential(silent, _interactive_builder) - return _credential - - -_auth_record_saved = False - - -def get_token(scope=None): - """ - Acquire a raw access token string for the Dataverse environment. - - Resolution order is set by ``_get_credential()``: service principal, then - the silent chain (shared DataverseCLI cache, then az CLI), then a single - host-gated interactive tier used only if every silent tier is unavailable. - The interactive tier persists an AuthenticationRecord on first login so - subsequent processes refresh silently. - - :param scope: OAuth2 scope. Defaults to "{DATAVERSE_URL}/.default". - :returns: Access token string suitable for a Bearer Authorization header. - """ - load_env() - dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/") - if not scope: - scope = f"{dataverse_url}/.default" - - credential = _get_credential() - - try: - token = credential.get_token(scope) - except Exception as e: - print(f"ERROR: Failed to acquire access token: {e}", flush=True) - print(" Check your network connection, credentials, and .env configuration.", flush=True) - print(" Tip: run `python scripts/auth.py --diagnose` to see which credential", flush=True) - print(" tiers are available, or `dataverse auth create --environment " - f"{dataverse_url}` to populate the shared token cache.", flush=True) - sys.exit(1) - - return token.token - - -_ALLOWED_SKILLS = frozenset({ - "dv-overview", "dv-connect", "dv-data", "dv-query", - "dv-metadata", "dv-solution", "dv-admin", "dv-security", - "unknown", -}) -_ALLOWED_AGENTS = frozenset({ - "claude-code", "copilot", "cursor", "codex", "unknown", -}) -# Strict format: key=value pairs, semicolon-separated. No spaces, no PII. -_CONTEXT_RE = re.compile( - r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$" -) - - -def _plugin_version(): - """Return the plugin version for telemetry attribution. - - Sourced from DATAVERSE_PLUGIN_VERSION, which dv-connect writes into .env from - the live plugin manifest on every connect (Step 0 / Step 3). The deployed - layout copies this file to <project>/scripts/auth.py -- away from the plugin - manifest -- so a manifest path relative to __file__ is unreliable here; the - env var, refreshed at connect, is the source of truth. - """ - return os.environ.get("DATAVERSE_PLUGIN_VERSION", "unknown") - - -def _current_agent(): - agent = os.environ.get("DATAVERSE_PLUGIN_AGENT", "unknown") - if agent not in _ALLOWED_AGENTS: - raise ValueError(f"Unknown agent '{agent}'; allowed: {_ALLOWED_AGENTS}") - return agent - - -def _validate_skill(skill): - if skill not in _ALLOWED_SKILLS: - raise ValueError(f"Unknown skill '{skill}'; allowed: {_ALLOWED_SKILLS}") - return skill - - -def _build_operation_context(skill): - """Build and validate the operation_context string. - - Returns an OperationContext object for the SDK. The string is validated - both here (via allowlists) and inside OperationContext.__post_init__ - (via regex + control-char check). - - SECURITY: Only closed-schema values from _ALLOWED_SKILLS and - _ALLOWED_AGENTS are used. Never pass user-provided or free-form - strings into operation_context — it is written to HTTP headers and - server-side telemetry logs. - """ - ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}" - if not _CONTEXT_RE.match(ctx_str): - raise ValueError( - f"operation_context failed format validation: {ctx_str!r}. " - "Must be semicolon-separated key=value pairs with no spaces or special characters." - ) - from PowerPlatform.Dataverse.core.config import OperationContext - return OperationContext(user_agent_context=ctx_str) - - -def get_client(skill, **kwargs): - """Return a DataverseClient with plugin attribution baked in. - - The operation_context is appended to the User-Agent header as a - parenthesized comment for server-side traffic attribution. - - IMPORTANT: Do not modify the operation_context — it uses a closed - schema (app/skill/agent) for safe server-side attribution. Never - include secrets, PII, or free-form text. - - :param skill: Skill name (e.g. "dv-data", "dv-query"). - :param kwargs: Extra keyword arguments forwarded to DataverseClient. - :returns: Configured DataverseClient instance. - """ - load_env() - _validate_skill(skill) - from PowerPlatform.Dataverse.client import DataverseClient - return DataverseClient( - base_url=os.environ["DATAVERSE_URL"], - credential=_get_credential(), - context=_build_operation_context(skill), - **kwargs, - ) - - -def get_plugin_headers(skill, token=None): - """Return HTTP headers for raw Web API calls, with plugin attribution. - - Use this for operations the SDK does not support (forms, views, $apply, - N:N $expand, unbound actions). - - IMPORTANT: Do not modify the User-Agent context — it uses a closed - schema (app/skill/agent) for safe server-side attribution. Never - include secrets, PII, or free-form text. - - :param skill: Skill name (e.g. "dv-metadata"). - :param token: Optional bearer token (from get_token()). - :returns: Headers dict with User-Agent and optional Authorization. - """ - _validate_skill(skill) - ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}" - if not _CONTEXT_RE.match(ctx_str): - raise ValueError( - f"operation_context failed format validation: {ctx_str!r}." - ) - headers = {"User-Agent": f"Python-urllib ({ctx_str})"} - if token: - headers["Authorization"] = f"Bearer {token}" - return headers - - -def _run_diagnose(): - """Print a per-tier credential availability matrix WITHOUT triggering any - interactive prompt. Turns an auth hang / bare exit-1 into a self-service - report of which tiers are available and which one would serve the next call. - (Issue #108: give stranded users a diagnosis, not a dead end.) - """ - load_env() - tenant_id = os.environ.get("TENANT_ID") - url = os.environ.get("DATAVERSE_URL", "").rstrip("/") - scope = _dataverse_scope() - client_id = os.environ.get("CLIENT_ID") - client_secret = os.environ.get("CLIENT_SECRET") - - print(f"Dataverse auth diagnosis for {url or '<DATAVERSE_URL unset>'}") - print( - f" tenant={tenant_id or '<TENANT_ID unset>'} host={sys.platform} " - f"browser={_host_has_browser()} " - f"workspace-cache={'on' if os.environ.get('DATAVERSE_TOKEN_CACHE_DIR') else 'off'}" - ) - print("") - - rows = [] # (tier, status, detail) - - if client_id and client_secret: - rows.append(("service-principal", "CONFIGURED", "terminal -- used exclusively, no interactive fallback")) - else: - rows.append(("service-principal", "not set", "set CLIENT_ID + CLIENT_SECRET for unattended auth")) - - try: - shared = _build_shared_msal_cache() - if shared is not None: - rows.append(("shared-cache", "AVAILABLE", "`dataverse auth create` cache yields a silent token")) - else: - rows.append(("shared-cache", "unavailable", "no cache / no account / silent miss -- run `dataverse auth create`")) - except Exception as e: # noqa: BLE001 - rows.append(("shared-cache", "error", type(e).__name__)) - - try: - from azure.identity import AzureCliCredential, CredentialUnavailableError - try: - cred = AzureCliCredential(tenant_id=tenant_id) - if scope: - cred.get_token(scope) - rows.append(("azure-cli", "AVAILABLE", "`az login` yields a silent token for this tenant")) - else: - rows.append(("azure-cli", "unknown", "DATAVERSE_URL unset -- cannot probe")) - except CredentialUnavailableError: - rows.append(("azure-cli", "unavailable", "az not installed or not logged in -- run `az login`")) - except Exception as e: # noqa: BLE001 - rows.append(("azure-cli", "skipped", type(e).__name__)) - except ImportError: - rows.append(("azure-cli", "n/a", "azure-identity not installed")) - - if _should_use_workspace_cache() is not None: - kind = "workspace-device-code" - elif _host_has_browser(): - kind = "interactive-browser" - else: - kind = "device-code" - rows.append(("interactive", kind, "used only if every silent tier above is unavailable")) - - width = max(len(t) for t, _, _ in rows) - for tier, status, detail in rows: - print(f" {tier.ljust(width)} {status.ljust(12)} {detail}") - - silent_ok = any(s in ("CONFIGURED", "AVAILABLE") for _, s, _ in rows[:3]) - print("") - if silent_ok: - print("Result: a silent tier is available -- normal calls will not prompt.") - else: - print(f"Result: no silent tier -- the next call uses the '{kind}' interactive tier.") - - -def _run_ping(): - """Lightweight reachability check: token + one HTTP call, no SDK import. - - Uses only stdlib (urllib) and the credential chain (which needs msal or - azure-identity -- at least one is typically installed globally even before - pip runs in a fresh workspace). Writes .env and copies auth.py to scripts/ - if missing, so a successful ping makes the workspace ready for SDK use - after pip install. - """ - import shutil - from urllib.error import HTTPError, URLError - from urllib.request import Request, urlopen - - load_env() - url = os.environ.get("DATAVERSE_URL", "").rstrip("/") - tenant_id = os.environ.get("TENANT_ID") - - if not url or not tenant_id: - print("PING FAILED: no DATAVERSE_URL or TENANT_ID (no .env, no user-level config).", flush=True) - sys.exit(1) - - # Acquire a token via the credential chain (shared CLI cache, az, interactive). - # _get_credential needs azure-identity or msal; if neither is installed, fail cleanly. - try: - token = get_token() - except SystemExit: - print("PING FAILED: could not acquire a token (azure-identity or msal may not be installed).", flush=True) - sys.exit(1) - - # One raw HTTP call to verify the org is reachable. - try: - req = Request(f"{url}/api/data/v9.2/WhoAmI") - req.add_header("Authorization", f"Bearer {token}") - req.add_header("Accept", "application/json") - resp = urlopen(req, timeout=20) - resp.read() - except HTTPError as e: - print(f"PING FAILED: {url} returned HTTP {e.code}.", flush=True) - sys.exit(2) - except (URLError, OSError) as e: - reason = getattr(e, "reason", e) - print(f"PING FAILED: {url} unreachable ({reason}).", flush=True) - sys.exit(2) - - # Workspace bootstrap: write .env and copy auth.py if missing. - workspace = Path.cwd() - env_path = workspace / ".env" - if not env_path.exists(): - env_path.write_text(f"DATAVERSE_URL={url}\nTENANT_ID={tenant_id}\n", encoding="utf-8") - dest = workspace / "scripts" / "auth.py" - if not dest.exists(): - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(Path(__file__).resolve(), dest) - gitignore = workspace / ".gitignore" - existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" - for entry in (".env", ".dataverse/"): - if entry not in existing: - with open(gitignore, "a", encoding="utf-8") as f: - f.write(f"\n{entry}\n") - - _save_user_config() - print(f"REACHABLE: {url}", flush=True) - - -def _run_bootstrap(url): - """One-command workspace setup from a Dataverse org URL. - - Probes the URL for tenant ID via WWW-Authenticate, writes .env, copies - auth.py to scripts/, and saves to user-level config. Uses only stdlib - (urllib) so it works before pip install. - """ - import shutil - from urllib.error import HTTPError, URLError - from urllib.request import Request, urlopen - - url = url.rstrip("/") - if not url.startswith("https://"): - print(f"ERROR: URL must start with https:// (got: {url})", flush=True) - sys.exit(1) - - print(f"Probing {url} for tenant ID...", flush=True) - www_auth = "" - try: - req = Request(f"{url}/api/data/v9.2/", method="GET") - req.add_header("Accept", "application/json") - urlopen(req, timeout=20) - print("ERROR: Unexpected 200 from unauthenticated probe.", flush=True) - sys.exit(1) - except HTTPError as e: - www_auth = e.headers.get("WWW-Authenticate", "") - except URLError as e: - print(f"ERROR: Cannot reach {url}: {e.reason}", flush=True) - sys.exit(1) - - match = re.search(r"login\.microsoftonline\.com/([^/,\s]+)", www_auth) - if not match: - print(f"ERROR: Could not discover tenant from {url}", flush=True) - print(f" WWW-Authenticate: {www_auth[:200]}", flush=True) - sys.exit(1) - tenant_id = match.group(1) - - workspace = Path.cwd() - env_path = workspace / ".env" - env_path.write_text( - f"DATAVERSE_URL={url}\nTENANT_ID={tenant_id}\n", encoding="utf-8", - ) - - scripts_dir = workspace / "scripts" - dest = scripts_dir / "auth.py" - if not dest.exists(): - scripts_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(Path(__file__).resolve(), dest) - - gitignore = workspace / ".gitignore" - existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" - missing = [e for e in (".env", ".dataverse/") if e not in existing] - if missing: - with open(gitignore, "a", encoding="utf-8") as f: - f.write("\n" + "\n".join(missing) + "\n") - - os.environ["DATAVERSE_URL"] = url - os.environ["TENANT_ID"] = tenant_id - _save_user_config() - - print(f"BOOTSTRAPPED: {url} (tenant {tenant_id})", flush=True) - print(f" .env: {env_path}", flush=True) - print(f" user config: {_USER_CONFIG_PATH}", flush=True) - print(" Next: pip install deps (if needed), then: python scripts/auth.py --check", flush=True) - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser( - description="Acquire a Dataverse token, or verify the environment is reachable." - ) - parser.add_argument( - "--check", - action="store_true", - help="Make a REAL data-plane call and report reachability instead of printing a token. " - "A token can be minted while the org domain is blocked (restricted-egress hosts), so " - "this is the only proof of an actual connection. Exit 0 = reachable, 2 = not reachable.", - ) - parser.add_argument( - "--ping", - action="store_true", - help="Lightweight reachability check using only stdlib + msal (no SDK import). " - "Loads user-level config or .env, acquires a token via the credential chain, makes " - "one raw HTTP call to verify the org is reachable. Exit 0 = reachable + prints the " - "org URL, 1 = no config or no token, 2 = token OK but org unreachable. Also writes " - ".env and copies auth.py to scripts/ if missing.", - ) - parser.add_argument( - "--diagnose", - action="store_true", - help="Print which credential tiers (service principal / shared cache / az CLI / " - "interactive) are available, WITHOUT prompting. Use when auth hangs or fails to see " - "why and which tier would serve the next call.", - ) - parser.add_argument( - "--bootstrap", metavar="URL", - help="One-shot setup: probe URL for tenant, write .env, copy auth.py to scripts/, " - "save to user-level config. Does not authenticate -- run --check after pip install.", - ) - args = parser.parse_args() - - if args.diagnose: - _run_diagnose() - sys.exit(0) - - if args.bootstrap: - _run_bootstrap(args.bootstrap) - sys.exit(0) - - if args.ping: - _run_ping() - sys.exit(0) - - if not args.check: - # Default (unchanged) behavior: print a bearer token. - print(get_token()) - sys.exit(0) - - # Reachability gate. A green token is NOT a connection: auth traffic goes to - # login.microsoftonline.com (often reachable) while the org's data plane - # (*.dynamics.com) may be blocked by a sandbox egress allowlist. Only a real - # call proves it. Bound the wait so a blocked domain fails fast instead of hanging. - import socket - - socket.setdefaulttimeout(30) - load_env() - url = os.environ.get("DATAVERSE_URL", "").rstrip("/") - try: - client = get_client("dv-connect") - tables = client.tables.list(select=["LogicalName"]) - print(f"REACHABLE: {url} -- {len(tables)} non-private tables") - _save_user_config() - sys.exit(0) - except Exception as e: - print(f"NOT REACHABLE: {url} -- {type(e).__name__}: {e}", flush=True) - print( - "If this is a connection/timeout error, the org domain is blocked by the host " - "network egress allowlist -- auth is fine; the data plane is unreachable. Do NOT " - "report a table count or query result: nothing was retrieved.", - flush=True, - ) - sys.exit(2) +""" +auth.py — Acquire Dataverse tokens via Azure Identity. + +Auth chain (silent tiers first; interactive only when every silent tier is +unavailable, so a working path wins without stranding the user -- issue #108): + 1. Service principal (CLIENT_ID + CLIENT_SECRET in .env) -- non-interactive, + terminal (CI fails fast; no interactive fallback when SP is configured). + 2. Certificate (CLIENT_ID + CLIENT_CERTIFICATE_PATH in .env) -- non-interactive, + terminal (CI fails fast; no interactive fallback when configured). + 3. Shared Dataverse CLI token cache -- silent, no prompt, populated by + `dataverse auth create` (see dv-connect Step 2). Uses the same MSAL v3 + cache the `@microsoft/dataverse` stdio MCP proxy reads. Probed at build + time against both the tenant-specific and `organizations` authority so an + authority mismatch falls through instead of hard-failing. + 4. Azure CLI (`az login`) -- silent, scoped to TENANT_ID; skipped when az is + absent or not logged in. + 5. Interactive (host-gated, last): workspace-cache device-code when + DATAVERSE_TOKEN_CACHE_DIR is set (ephemeral hosts); else a system-browser + InteractiveBrowserCredential on a desktop host (beats the CA device-code + block, and uses the browser not the MSAL broker so it sidesteps the macOS + broker bug); else DeviceCodeCredential on a headless host. Each persists an + AuthenticationRecord so later processes refresh silently. + +The shared cache uses the Dataverse CLI app registration +(``0c412cc3-0dd6-449b-987f-05b053db9457``) so every Dataverse-skills tool +authenticates as the same OAuth client and AAD treats it as one sign-in. + +Token caching layout (path 2): + Windows: %LocalAppData%\\Microsoft\\DataverseCli\\tokencache_msalv3.dat (DPAPI) + macOS: Keychain service ``dataverse_cli_service`` / account ``dataverse_cli_account`` + Linux: libsecret schema ``com.microsoft.dataversecli`` (desktop), or a plaintext + ``tokencache_msalv3.dat`` under ``$XDG_DATA_HOME/Microsoft/DataverseCli`` + on headless hosts with no keyring (matches the CLI's WithLinuxUnprotectedFile) + +Functions: + load_env() — loads .env into os.environ + get_client(skill) — returns a DataverseClient with plugin attribution + get_token(scope=None) — returns a raw access token string + get_plugin_headers(skill, token) — returns headers dict for raw Web API calls + +Usage: + # PREFERRED — SDK with plugin attribution: + from auth import get_client + client = get_client("dv-data") + + # Raw Web API only (forms, views, $ref, $apply): + from auth import get_token, get_plugin_headers + headers = get_plugin_headers("dv-metadata", get_token()) + +Reads from .env in the repo root (parent of scripts/) or current working directory: + DATAVERSE_URL — required + TENANT_ID — required + CLIENT_ID — optional, enables service principal auth + CLIENT_SECRET — optional, enables service principal auth + CLIENT_CERTIFICATE_PATH — optional, enables certificate auth (.pfx or .pem) + CLIENT_CERTIFICATE_PASSWORD — optional, decrypts a password-protected certificate + CLIENT_SEND_CERTIFICATE_CHAIN — optional, set to 1 for SNI authentication + DATAVERSE_TOKEN_CACHE_DIR — optional; on ephemeral hosts (ChatGPT web / Codex + sandbox) where $HOME is wiped between turns, set this to a + workspace-relative dir (e.g. .dataverse) so the device-code + token cache persists and refreshes silently within a session +""" + +import os +import re +import sys +import time +from pathlib import Path + +# Dataverse CLI app registration. Must match McpOAuth.Config.ClientId in +# DataverseCli/Auth/AuthClientConfig.cs so that tokens minted by +# `dataverse auth create` and the @microsoft/dataverse stdio MCP proxy can +# be silently reused by Python scripts (no second device-code prompt). +_DATAVERSE_CLI_CLIENT_ID = "0c412cc3-0dd6-449b-987f-05b053db9457" + +# Legacy AuthenticationRecord path for the device-code fallback (path 3). +# Kept for backward compatibility with workspaces that authenticated via the +# previous auth.py before the shared-cache change. +_AUTH_RECORD_PATH = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".IdentityService" / "dataverse_cli_auth_record.json" + +# Cross-workspace config so returning users skip the full dv-connect dance. +_USER_CONFIG_DIR = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".dataverse-skills" +_USER_CONFIG_PATH = _USER_CONFIG_DIR / "config.json" + + +def _load_user_config(): + """Load DATAVERSE_URL and TENANT_ID from user-level config when .env is absent.""" + if not _USER_CONFIG_PATH.exists(): + return + try: + import json + cfg = json.loads(_USER_CONFIG_PATH.read_text(encoding="utf-8")) + loaded = [] + for key in ("DATAVERSE_URL", "TENANT_ID"): + val = cfg.get(key) + if val and not os.environ.get(key): + os.environ.setdefault(key, val) + loaded.append(key) + if loaded: + print( + f"NOTE: loaded {', '.join(loaded)} from {_USER_CONFIG_PATH} " + f"(no .env found). Target: {cfg.get('DATAVERSE_URL', '?')}", + flush=True, + ) + except Exception: + pass + + +def _save_user_config(): + """Persist URL and tenant to user-level config for cross-workspace reuse.""" + import json + url = os.environ.get("DATAVERSE_URL") + tenant = os.environ.get("TENANT_ID") + if not url or not tenant: + return + try: + _USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + try: + os.chmod(_USER_CONFIG_DIR, 0o700) + except Exception: + pass + _USER_CONFIG_PATH.write_text( + json.dumps({"DATAVERSE_URL": url, "TENANT_ID": tenant}, indent=2), + encoding="utf-8", + ) + except Exception: + pass + + +def load_env(): + """Load key=value pairs from .env into os.environ (does not overwrite existing vars). + + Searches for .env in two locations (first match wins): + 1. The repo root (parent of the directory containing this script) + 2. The current working directory + This ensures ``cd scripts && python auth.py`` works the same as + ``python scripts/auth.py`` from the repo root. + + Falls back to user-level config (~/.dataverse-skills/config.json) when .env + is absent, so returning users on ephemeral-workspace hosts (ChatGPT desktop + app, Codex) skip the full dv-connect setup. + """ + script_dir = Path(__file__).resolve().parent + candidates = [script_dir.parent / ".env", Path(".env")] + env_path = next((p for p in candidates if p.exists()), None) + if env_path is not None: + for line in env_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + if not os.environ.get("DATAVERSE_URL") or not os.environ.get("TENANT_ID"): + _load_user_config() + + +_credential = None + + +def _dataverse_scope(): + """Return the ``{DATAVERSE_URL}/.default`` OAuth scope, or None if unset.""" + url = os.environ.get("DATAVERSE_URL", "").rstrip("/") + if not url: + return None + return f"{url}/.default" + + +def _shared_cache_persistences(): + """Return the ordered list of MSAL cache persistences to try for the shared + DataverseCLI cache (platform-specific). Never raises -- returns [] on any issue. + On Linux this is libsecret-first (desktop) then the plaintext MSAL v3 file + (headless), matching the CLI's WithLinuxUnprotectedFile fallback. + """ + persistences = [] + try: + if sys.platform == "win32": + from msal_extensions import FilePersistenceWithDataProtection + cache_path = ( + Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) + / "Microsoft" / "DataverseCli" / "tokencache_msalv3.dat" + ) + if cache_path.exists(): + persistences.append(FilePersistenceWithDataProtection(str(cache_path))) + elif sys.platform == "darwin": + from msal_extensions import KeychainPersistence + # Fallback file path is required by msal-extensions but unused on + # macOS -- the Keychain service/account match DataverseCLI's + # PacAuthApplicationFactory constants exactly. + fallback = str(Path.home() / ".dataverse_cli_msal_cache") + persistences.append( + KeychainPersistence(fallback, "dataverse_cli_service", "dataverse_cli_account") + ) + else: + fallback = str(Path.home() / ".dataverse_cli_msal_cache") + try: + from msal_extensions import LibsecretPersistence + persistences.append( + LibsecretPersistence( + fallback, + schema_name="com.microsoft.dataversecli", + attributes={"Version": "1", "ProductGroup": "DataverseCli"}, + ) + ) + except Exception: + pass # libsecret backend unavailable on this host -- skip it. + from msal_extensions import FilePersistence + xdg_data = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share") + plaintext_path = Path(xdg_data) / "Microsoft" / "DataverseCli" / "tokencache_msalv3.dat" + if plaintext_path.exists(): + persistences.append(FilePersistence(str(plaintext_path))) + except Exception: + return [] + return persistences + + +def _build_shared_msal_cache(): + """Open the DataverseCLI MSAL cache for silent reuse, probing it at build time. + + Returns ``(msal.PublicClientApplication, list[account])`` only when a silent + token actually comes back -- probed against BOTH the tenant-specific and the + ``organizations`` authority, because ``dataverse auth create`` may have written + the cache under a different authority than TENANT_ID (issue #108, defect 2). + Returns ``None`` on any miss so the caller falls through to the next tier -- + the shared-cache path can never break auth. + """ + try: + import msal + from msal_extensions import PersistedTokenCache + except ImportError: + return None + + tenant_id = os.environ.get("TENANT_ID") + if not tenant_id: + return None + + scope = _dataverse_scope() + authorities = [ + f"https://login.microsoftonline.com/{tenant_id}", + "https://login.microsoftonline.com/organizations", + ] + for persistence in _shared_cache_persistences(): + for authority in authorities: + try: + app = msal.PublicClientApplication( + client_id=_DATAVERSE_CLI_CLIENT_ID, + authority=authority, + token_cache=PersistedTokenCache(persistence), + ) + accounts = app.get_accounts() + if not accounts: + continue + if scope is None: + # No DATAVERSE_URL to probe with -- trust account presence. + return app, accounts + probe = app.acquire_token_silent([scope], account=accounts[0]) + if probe and "access_token" in probe: + return app, accounts # this authority actually yields a token + except Exception: + continue # never let the shared-cache path break auth + return None + + +class _MsalSharedCacheCredential: + """TokenCredential adapter over an msal PublicClientApplication. + + Implements just enough of the azure-core TokenCredential protocol + (`get_token(*scopes, **kwargs)` returning AccessToken) to satisfy + DataverseClient and direct urllib callers. + """ + + def __init__(self, app, accounts): + self._app = app + self._accounts = accounts + + def get_token(self, *scopes, **kwargs): + from azure.core.credentials import AccessToken + from azure.identity import CredentialUnavailableError + # Single-account is the common case. If the shared cache happens to + # contain multiple accounts, the first one wins -- deterministic and + # matches what `dataverse auth select` would surface as active. + # Forward a CAE / Conditional-Access claims challenge when azure-core + # passes one -- CA-hardened tenants are this plugin's target scenario. + # A None claims_challenge is a harmless no-op. + result = self._app.acquire_token_silent( + list(scopes), account=self._accounts[0], + claims_challenge=kwargs.get("claims"), + ) + if not result or "access_token" not in result: + # Fall through, do NOT hard-fail: a cached account can exist while + # silent acquisition fails (authority mismatch, expired refresh + # token). Raising CredentialUnavailableError lets the chain try the + # next tier instead of stranding the user. (Issue #108, defect 1.) + raise CredentialUnavailableError( + "Shared DataverseCLI cache present but silent token acquisition " + "failed (often an authority mismatch or expired refresh token)." + ) + expires_on = int(time.time()) + int(result.get("expires_in", 3600)) + return AccessToken(result["access_token"], expires_on) + + def close(self): # pragma: no cover — parity with azure-identity credentials + pass + + +# Default workspace cache dir used on headless/ephemeral hosts when the user has not +# set DATAVERSE_TOKEN_CACHE_DIR (see _workspace_token_cache_path). +_DEFAULT_WORKSPACE_CACHE_DIRNAME = ".dataverse" + +# Explicit opt-out values for DATAVERSE_TOKEN_CACHE_DIR: keep the OS default cache +# (do not write a plaintext refresh token into the workspace). +_CACHE_DIR_OPT_OUT = frozenset({"off", "0", "false", "no", "none", "disable", "disabled"}) + + +def _should_use_workspace_cache(): + """Pure decision (no I/O): whether the persisted workspace token cache is used, + and via which source. This is the SINGLE source of truth so + _workspace_token_cache_path (which builds the path) and _run_diagnose (which + reports the tier) can never drift. Returns: + "explicit" -- DATAVERSE_TOKEN_CACHE_DIR is set to a usable path. + "default" -- unset on a HEADLESS, non-CI host -> auto-default to <workspace>/.dataverse. + None -- keep the OS default cache (desktop, CI, or an explicit opt-out). + """ + cache_dir = os.environ.get("DATAVERSE_TOKEN_CACHE_DIR") + if cache_dir is not None and cache_dir.strip().lower() in _CACHE_DIR_OPT_OUT: + return None # explicit opt-out -- keep the per-process OS default cache + if cache_dir: + return "explicit" + # No explicit setting: auto-default ONLY where the OS cache will not survive -- + # a headless, non-CI host. Desktop hosts keep their secure default cache; CI + # never reaches an interactive tier (service principal is terminal earlier). + if _host_has_browser() or _is_ci(): + return None + return "default" + + +def _workspace_token_cache_path(): + """Return the MSAL v3 cache file path used to persist the device-code refresh + token across separate Python processes, or None to keep the OS default cache. + The use/skip decision is _should_use_workspace_cache(); this function only turns + a positive decision into a concrete, git-ignored, owner-only file path. + + Resolution (see _should_use_workspace_cache): + - DATAVERSE_TOKEN_CACHE_DIR set to a path -> use it (a relative dir is anchored + to the workspace root, matching how load_env finds .env). + - opt-out value (off/false/0/no/none) -> None. + - unset on a HEADLESS, non-CI host (ChatGPT web / Codex sandbox / SSH / container) + -> DEFAULT to ``<workspace>/.dataverse`` (the OS cache lives under $HOME, which + the sandbox wipes between Python processes, so device code would re-prompt every + process; the persisted workspace cache makes sign-in once per conversation). + - unset on a desktop host -> None (keep the secure OS cache: Windows DPAPI / + macOS Keychain / Linux desktop keyring -- no behavior change). + + Security: on non-Windows hosts (Linux, and macOS over SSH) the cache holds a + PLAINTEXT refresh token (no keyring; Windows uses DPAPI). The directory is created + owner-only (0700 on POSIX) with a self-contained `.gitignore` (`*`) so the token is + excluded from version control, and an ephemeral sandbox is torn down after the + session. To keep the OS default cache instead, set DATAVERSE_TOKEN_CACHE_DIR=off. + """ + decision = _should_use_workspace_cache() + if decision is None: + return None + cache_dir = ( + os.environ.get("DATAVERSE_TOKEN_CACHE_DIR") + if decision == "explicit" + else _DEFAULT_WORKSPACE_CACHE_DIRNAME + ) + try: + path = Path(cache_dir) + if not path.is_absolute(): + # Anchor a relative dir to the workspace root (this file's parent dir's + # parent -- the deployed <project>/scripts/auth.py layout), NOT the + # current working directory, so the cache location is stable no matter + # where the process is launched from (matches how load_env finds .env). + path = Path(__file__).resolve().parent.parent / path + path.mkdir(parents=True, exist_ok=True) + # Defense in depth: exclude the token cache from git even if the repo-root + # .gitignore does not cover this dir. + gitignore = path / ".gitignore" + if not gitignore.exists(): + gitignore.write_text("*\n", encoding="utf-8") + try: + os.chmod(path, 0o700) # owner-only on POSIX; benign on Windows + except Exception: + pass + return path / "tokencache_msalv3.dat" + except Exception: + return None + + +class _MsalDeviceCodeCredential: + """TokenCredential over an msal app that persists at an explicit cache path. + + Silent-refresh from the cache when possible; device-code sign-in on a cache miss. + Used only when DATAVERSE_TOKEN_CACHE_DIR is set (opt-in, ephemeral hosts) so the + refresh token lives in the persisted workspace cache and is reused across turns. + Implements the azure-core TokenCredential protocol (get_token). + """ + + def __init__(self, app): + self._app = app + + def get_token(self, *scopes, **kwargs): + from azure.core.credentials import AccessToken + scope_list = list(scopes) + claims = kwargs.get("claims") # CAE / Conditional-Access challenge, if any + result = None + accounts = self._app.get_accounts() + if accounts: + result = self._app.acquire_token_silent( + scope_list, account=accounts[0], claims_challenge=claims, + ) + if not result or "access_token" not in result: + flow = self._app.initiate_device_flow(scopes=scope_list) + if "user_code" not in flow: + raise RuntimeError( + "Failed to start device-code flow: " + f"{flow.get('error_description', flow)}" + ) + print( + f"\nTo sign in, visit {flow['verification_uri']} and enter code: " + f"{flow['user_code']}", + flush=True, + ) + _expiry_min = max(1, int(flow.get("expires_in", 900)) // 60) + print( + f"(Waiting for you to complete the login in your browser -- " + f"this code expires in ~{_expiry_min} min...)\n", + flush=True, + ) + result = self._app.acquire_token_by_device_flow( + flow, claims_challenge=claims, + ) # blocks until complete + if not result or "access_token" not in result: + detail = result.get("error_description", result) if result else "no response" + raise RuntimeError(f"Device-code authentication failed: {detail}") + expires_on = int(time.time()) + int(result.get("expires_in", 3600)) + return AccessToken(result["access_token"], expires_on) + + def close(self): # pragma: no cover + pass + + +def _is_ci(): + """True on CI / build agents (no interactive user), even on win32/darwin -- + GitHub Actions, ADO agents, containerized Windows. Treats only genuinely- + truthy values as set (avoids the CI="false" trap). + """ + def _flag(name): + return os.environ.get(name, "").strip().lower() not in ("", "0", "false", "no") + + return _flag("CI") or _flag("GITHUB_ACTIONS") or _flag("TF_BUILD") or _flag("BUILD_BUILDID") + + +def _host_has_browser(): + """True if an interactive system browser is likely available. + + CI / build agents are headless even when sys.platform is win32/darwin -- + launching a browser there crashes with no user session, so they route to the + device-code tier instead. After the CI gate: a Windows console or RDP session + has a browser, but a Windows service / headless container has an empty or + "Services" SESSIONNAME; macOS (non-CI) always has a session browser; Linux + needs a display server (DISPLAY / WAYLAND_DISPLAY) -- headless Linux (SSH / + container / ChatGPT web) has neither and uses device-code. + """ + if _is_ci(): + return False + if sys.platform == "win32": + session = os.environ.get("SESSIONNAME", "").strip().lower() + return session not in ("", "services") + if sys.platform == "darwin": + # An SSH / remote macOS session has no local browser -- use device-code. + if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"): + return False + return True + return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) + + +class _SilentChain: + """Try each silent tier in order; skip any that is unavailable OR errors, so a + convenience tier (e.g. az CLI logged into another tenant) can never strand the + fall-through to the interactive tier. Raises CredentialUnavailableError only + when every tier is exhausted. + """ + + def __init__(self, tiers): + self._tiers = tiers # list[(name, credential)] + self.last_reasons = [] # why each tier was skipped -- surfaced before a prompt + + def get_token(self, *scopes, **kwargs): + from azure.identity import CredentialUnavailableError + reasons = [] + for tier_name, cred in self._tiers: + try: + return cred.get_token(*scopes, **kwargs) + except CredentialUnavailableError: + reasons.append(f"{tier_name}: unavailable") + except Exception as e: # noqa: BLE001 -- a silent tier must never strand the chain + reasons.append(f"{tier_name}: {type(e).__name__}") + self.last_reasons = reasons + raise CredentialUnavailableError( + "no silent credential available (" + "; ".join(reasons) + ")" + ) + + def close(self): # pragma: no cover + pass + + +class _FallbackCredential: + """Silent tiers first; if all are unavailable, build and use the single + host-appropriate interactive tier ON DEMAND -- so an interactive prompt + happens ONLY when no silent path works. (Issue #108: never strand, never + prompt when a silent tier would do.) + """ + + def __init__(self, silent, interactive_builder): + self._silent = silent + self._interactive_builder = interactive_builder + self._interactive = None + + def get_token(self, *scopes, **kwargs): + from azure.identity import CredentialUnavailableError + if self._silent is not None: + try: + return self._silent.get_token(*scopes, **kwargs) + except CredentialUnavailableError as e: + # Surface WHY every silent tier was skipped so a sudden interactive + # prompt is explicable ("it just prompted me and I don't know why"). + reasons = getattr(self._silent, "last_reasons", None) + detail = "; ".join(reasons) if reasons else str(e) + print( + f"No silent credential available ({detail}); " + f"falling back to interactive sign-in.", + flush=True, + ) + if self._interactive is None: + self._interactive = self._interactive_builder() + return self._interactive.get_token(*scopes, **kwargs) + + def close(self): # pragma: no cover + pass + + +def _build_workspace_device_code_credential(cache_path, tenant_id): + """Build the opt-in workspace-cache device-code credential, or None if msal + is unavailable. The MSAL cache (incl. refresh token) lives at cache_path in + the persisted workspace, so device code is once per conversation, not per turn. + """ + try: + import msal + from msal_extensions import PersistedTokenCache + if sys.platform == "win32": + from msal_extensions import FilePersistenceWithDataProtection + persistence = FilePersistenceWithDataProtection(str(cache_path)) + else: + from msal_extensions import FilePersistence + persistence = FilePersistence(str(cache_path)) + app = msal.PublicClientApplication( + client_id=_DATAVERSE_CLI_CLIENT_ID, + authority=f"https://login.microsoftonline.com/{tenant_id}", + token_cache=PersistedTokenCache(persistence), + ) + return _MsalDeviceCodeCredential(app) + except ImportError: + return None + + +def _build_interactive_tier(tenant_id): + """Build ``(credential, kind)`` for the single interactive tier this host uses. + + kind is one of ``workspace-device-code`` / ``interactive-browser`` / + ``device-code``. Selection: + - workspace cache available (DATAVERSE_TOKEN_CACHE_DIR set, or auto-defaulted on + a headless non-CI host) -> workspace-cache device-code (self-persisting). + - desktop host with a browser -> InteractiveBrowserCredential (system browser). + - headless host with no buildable workspace cache -> DeviceCodeCredential (legacy). + The azure-identity interactive tiers persist an AuthenticationRecord on first + login so a later process refreshes silently. Called ONLY after every silent + tier is exhausted, so the eager first-login prompt here is never premature. + """ + # CI / unattended host: no interactive tier can succeed (no browser; a device + # code just blocks ~15 min then expires into a log nobody reads). Service + # principal is a terminal tier BEFORE we reach here, so being here under CI + # means no SP is configured -- fail fast with an actionable message instead of + # a guaranteed hang. + if _is_ci(): + raise RuntimeError( + "Unattended/CI host detected with no working silent credential " + "(service principal, shared cache, or az login). Interactive sign-in " + "cannot succeed here -- set CLIENT_ID + CLIENT_SECRET for a service " + "principal, or run `python scripts/auth.py --diagnose` to see which " + "tier failed." + ) + workspace_cache = _workspace_token_cache_path() + if workspace_cache is not None: + cred = _build_workspace_device_code_credential(workspace_cache, tenant_id) + if cred is not None: + return cred, "workspace-device-code" + + from azure.identity import ( + AuthenticationRecord, + DeviceCodeCredential, + InteractiveBrowserCredential, + TokenCachePersistenceOptions, + ) + + cache_opts = TokenCachePersistenceOptions( + name="dataverse_cli", allow_unencrypted_storage=True + ) + record = None + if _AUTH_RECORD_PATH.exists(): + try: + record = AuthenticationRecord.deserialize( + _AUTH_RECORD_PATH.read_text(encoding="utf-8") + ) + except Exception: + record = None # corrupt/stale record -- re-authenticate + + if _host_has_browser(): + cred = InteractiveBrowserCredential( + tenant_id=tenant_id, + client_id=_DATAVERSE_CLI_CLIENT_ID, + cache_persistence_options=cache_opts, + authentication_record=record, + ) + kind = "interactive-browser" + else: + def _prompt_callback(verification_uri, user_code, _expires_on): + print(f"\nTo sign in, visit {verification_uri} and enter code: {user_code}", flush=True) + print("(Waiting for you to complete the login in your browser...)\n", flush=True) + + cred = DeviceCodeCredential( + tenant_id=tenant_id, + client_id=_DATAVERSE_CLI_CLIENT_ID, + prompt_callback=_prompt_callback, + cache_persistence_options=cache_opts, + authentication_record=record, + ) + kind = "device-code" + + # First login: capture + persist the AuthenticationRecord so later processes + # refresh silently. authenticate() performs the one interactive flow; because + # we are here only after the silent tiers failed, it is never premature. + if record is None: + try: + scope = _dataverse_scope() + new_record = cred.authenticate(scopes=[scope] if scope else None) + _AUTH_RECORD_PATH.parent.mkdir(parents=True, exist_ok=True) + _AUTH_RECORD_PATH.write_text(new_record.serialize(), encoding="utf-8") + except KeyboardInterrupt: + raise # user cancelled sign-in -- honor it, don't fall through to a 2nd prompt + except Exception as e: # noqa: BLE001 -- non-fatal: get_token still works, may reprompt + print( + f"NOTE: first interactive sign-in did not persist a record " + f"({type(e).__name__}); a later token request may reprompt.", + flush=True, + ) + + return cred, kind + + +def _get_credential(): + """ + Return a TokenCredential, creating one on first call. + + The credential is cached for the lifetime of the process. Resolution + order: service principal or certificate (terminal) -> silent chain (shared + DataverseCLI cache, then az CLI) -> single host-gated interactive tier, + used only when every silent tier is unavailable (issue #108). + """ + global _credential + if _credential is not None: + return _credential + + load_env() + + tenant_id = os.environ.get("TENANT_ID") + dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/") + client_id = os.environ.get("CLIENT_ID") + client_secret = os.environ.get("CLIENT_SECRET") + certificate_path = os.environ.get("CLIENT_CERTIFICATE_PATH") + certificate_password = os.environ.get("CLIENT_CERTIFICATE_PASSWORD") + + if not tenant_id or not dataverse_url: + missing = [k for k, v in [("TENANT_ID", tenant_id), ("DATAVERSE_URL", dataverse_url)] if not v] + print(f"ERROR: .env is missing required values: {', '.join(missing)}", flush=True) + print(" Run the init sequence (/dataverse:init) to create .env.", flush=True) + sys.exit(1) + + try: + from azure.identity import ( + AzureCliCredential, + CertificateCredential, + ClientSecretCredential, + ) + except ImportError: + print("ERROR: azure-identity not installed. Run: pip install --upgrade azure-identity", flush=True) + sys.exit(1) + + # Tier 1: Service principal. Terminal -- CI must fail fast, never fall to an + # interactive prompt that would hang an unattended run. + if client_id and client_secret: + _credential = ClientSecretCredential( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + ) + return _credential + + # Tier 2: Certificate-based service principal. Terminal for the same reason. + if client_id and certificate_path: + path = Path(certificate_path) + if not path.exists(): + print( + f"ERROR: CLIENT_CERTIFICATE_PATH does not exist: {certificate_path}", + flush=True, + ) + sys.exit(1) + if not path.is_file() or not os.access(path, os.R_OK): + print( + f"ERROR: CLIENT_CERTIFICATE_PATH is not a readable file: {certificate_path}", + flush=True, + ) + sys.exit(1) + + try: + _credential = CertificateCredential( + tenant_id=tenant_id, + client_id=client_id, + certificate_path=str(path), + password=certificate_password, + send_certificate_chain=( + os.environ.get("CLIENT_SEND_CERTIFICATE_CHAIN") == "1" + ), + ) + except ValueError as exc: + print( + "ERROR: Could not load CLIENT_CERTIFICATE_PATH. Verify the " + "certificate format and CLIENT_CERTIFICATE_PASSWORD.", + flush=True, + ) + print(f" {exc}", flush=True) + sys.exit(1) + return _credential + + if client_id and not client_secret and not certificate_path: + print( + "WARNING: CLIENT_ID is set without CLIENT_SECRET or " + "CLIENT_CERTIFICATE_PATH.", + flush=True, + ) + print( + " Falling back to shared cache / device code flow.", + flush=True, + ) + + if not client_id and (client_secret or certificate_path): + configured = ( + "CLIENT_SECRET" if client_secret else "CLIENT_CERTIFICATE_PATH" + ) + print( + f"WARNING: {configured} is set without CLIENT_ID.", + flush=True, + ) + print( + " Falling back to shared cache / device code flow.", + flush=True, + ) + + # No service principal -> silent tiers, then a single host-gated interactive + # tier used ONLY if every silent tier is unavailable (issue #108: never + # strand, and never prompt when a silent path works). + silent_tiers = [] + + # Tier 3: Shared DataverseCLI MSAL cache (populated by `dataverse auth + # create`; same client ID as the @microsoft/dataverse MCP proxy). Probed at + # build time so it is only selected when it actually yields a token. + shared = _build_shared_msal_cache() + if shared is not None: + app, accounts = shared + silent_tiers.append(("shared-cache", _MsalSharedCacheCredential(app, accounts))) + + # Tier 4: Azure CLI (`az login`). Scoped to TENANT_ID; AzureCliCredential + # raises CredentialUnavailableError when az is absent / not logged in, so it + # is free when unused and a high-value silent win when present. + silent_tiers.append(("azure-cli", AzureCliCredential(tenant_id=tenant_id))) + + silent = _SilentChain(silent_tiers) + + def _interactive_builder(): + cred, _kind = _build_interactive_tier(tenant_id) + return cred + + _credential = _FallbackCredential(silent, _interactive_builder) + return _credential + + +_auth_record_saved = False + + +def get_token(scope=None): + """ + Acquire a raw access token string for the Dataverse environment. + + Resolution order is set by ``_get_credential()``: service principal, then + the silent chain (shared DataverseCLI cache, then az CLI), then a single + host-gated interactive tier used only if every silent tier is unavailable. + The interactive tier persists an AuthenticationRecord on first login so + subsequent processes refresh silently. + + :param scope: OAuth2 scope. Defaults to "{DATAVERSE_URL}/.default". + :returns: Access token string suitable for a Bearer Authorization header. + """ + load_env() + dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/") + if not scope: + scope = f"{dataverse_url}/.default" + + credential = _get_credential() + + try: + token = credential.get_token(scope) + except Exception as e: + print(f"ERROR: Failed to acquire access token: {e}", flush=True) + print(" Check your network connection, credentials, and .env configuration.", flush=True) + print(" Tip: run `python scripts/auth.py --diagnose` to see which credential", flush=True) + print(" tiers are available, or `dataverse auth create --environment " + f"{dataverse_url}` to populate the shared token cache.", flush=True) + sys.exit(1) + + return token.token + + +_ALLOWED_SKILLS = frozenset({ + "dv-overview", "dv-connect", "dv-data", "dv-query", + "dv-metadata", "dv-solution", "dv-admin", "dv-security", "erp-xpp", + "unknown", +}) +_ALLOWED_AGENTS = frozenset({ + "claude-code", "copilot", "cursor", "codex", "gemini-cli", + "antigravity-cli", "unknown", +}) +# Strict format: key=value pairs, semicolon-separated. No spaces, no PII. +_CONTEXT_RE = re.compile( + r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$" +) + + +_PLUGIN_VERSION_CACHE = None + + +def _plugin_version(): + """Return the plugin version for telemetry attribution. + + Primary source is DATAVERSE_PLUGIN_VERSION, which dv-connect writes into .env + from the live plugin manifest on marketplace / plugin installs. npx and the + JetBrains registry install skill folders only (no manifest), so the env var is + often "unknown" there; in that case resolve the version from the canonical + manifest (local if reachable, else fetched once and cached). + + Fail-open: a value that would break the closed-schema context string is + coerced to "unknown", so attribution can never block the operation. + """ + env_version = os.environ.get("DATAVERSE_PLUGIN_VERSION", "").strip() + if env_version and env_version != "unknown": + return env_version if re.fullmatch(r"[a-zA-Z0-9_./-]+", env_version) else "unknown" + ver = _resolve_manifest_version() + return ver if re.fullmatch(r"[a-zA-Z0-9_./-]+", ver or "") else "unknown" + + +def _resolve_manifest_version(): + """Resolve the plugin version from the canonical manifest, cached per process. + + Tries a local manifest (present on repo-clone / plugin installs), then fetches + it from GitHub (npx / registry installs ship no manifest). Network and parse + failures degrade to "unknown" -- this never raises. + """ + global _PLUGIN_VERSION_CACHE + if _PLUGIN_VERSION_CACHE is not None: + return _PLUGIN_VERSION_CACHE + import json + + version = "unknown" + for manifest in (".claude-plugin/plugin.json", + ".github/plugins/dataverse/.claude-plugin/plugin.json"): + try: + with open(manifest, encoding="utf-8") as fh: + version = json.load(fh).get("version") or version + break + except (OSError, ValueError): + continue + if version == "unknown": + try: + import urllib.request + url = ("https://raw.githubusercontent.com/microsoft/Dataverse-skills/" + "main/.github/plugins/dataverse/.claude-plugin/plugin.json") + with urllib.request.urlopen(url, timeout=5) as resp: + version = json.load(resp).get("version") or "unknown" + except Exception: + version = "unknown" + _PLUGIN_VERSION_CACHE = version + return version + + +def _current_agent(): + """Return the attribution agent, fail-open. + + An unrecognized host string (LLM host misdetection: 'github-copilot' vs + 'copilot', 'claude', 'windsurf', ...) is coerced to "unknown" rather than + raised -- telemetry attribution must never gate the user's data operation. + Strict validation stays on the .env WRITE path (dv-connect guidance). + """ + agent = os.environ.get("DATAVERSE_PLUGIN_AGENT", "unknown") + return agent if agent in _ALLOWED_AGENTS else "unknown" + + +def _validate_skill(skill): + if skill not in _ALLOWED_SKILLS: + raise ValueError(f"Unknown skill '{skill}'; allowed: {_ALLOWED_SKILLS}") + return skill + + +def _operation_context_str(skill): + """Assemble the closed-schema attribution string, fail-open. + + agent/version are already coerced to allow-listed / charset-safe values, so + the result satisfies _CONTEXT_RE. The final check is a defensive net: on any + mismatch it degrades to a minimal safe context instead of raising, so a bad + attribution value can never block the call. `skill` is a plugin-authored + literal validated strictly by the public entry points. + """ + ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}" + if not _CONTEXT_RE.match(ctx_str): + safe_skill = skill if skill in _ALLOWED_SKILLS else "unknown" + ctx_str = f"app=dataverse-skills/unknown;skill={safe_skill};agent=unknown" + return ctx_str + + +def _build_operation_context(skill): + """Build the operation_context for the SDK. + + SECURITY: Only closed-schema values from _ALLOWED_SKILLS and _ALLOWED_AGENTS + reach the string; agent/version are coerced (never passed through raw), so + nothing un-allow-listed is written to HTTP headers or server-side telemetry. + """ + ctx_str = _operation_context_str(skill) + from PowerPlatform.Dataverse.core.config import OperationContext + return OperationContext(user_agent_context=ctx_str) + + +def get_client(skill, **kwargs): + """Return a DataverseClient with plugin attribution baked in. + + The operation_context is appended to the User-Agent header as a + parenthesized comment for server-side traffic attribution. + + IMPORTANT: Do not modify the operation_context — it uses a closed + schema (app/skill/agent) for safe server-side attribution. Never + include secrets, PII, or free-form text. + + :param skill: Skill name (e.g. "dv-data", "dv-query"). + :param kwargs: Extra keyword arguments forwarded to DataverseClient. + :returns: Configured DataverseClient instance. + """ + load_env() + _validate_skill(skill) + from PowerPlatform.Dataverse.client import DataverseClient + return DataverseClient( + base_url=os.environ["DATAVERSE_URL"], + credential=_get_credential(), + context=_build_operation_context(skill), + **kwargs, + ) + + +def get_plugin_headers(skill, token=None): + """Return HTTP headers for raw Web API calls, with plugin attribution. + + Use this for operations the SDK does not support (forms, views, $apply, + N:N $expand, unbound actions). + + IMPORTANT: Do not modify the User-Agent context — it uses a closed + schema (app/skill/agent) for safe server-side attribution. Never + include secrets, PII, or free-form text. + + :param skill: Skill name (e.g. "dv-metadata"). + :param token: Optional bearer token (from get_token()). + :returns: Headers dict with User-Agent and optional Authorization. + """ + _validate_skill(skill) + ctx_str = _operation_context_str(skill) + headers = {"User-Agent": f"Python-urllib ({ctx_str})"} + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _run_diagnose(): + """Print a per-tier credential availability matrix WITHOUT triggering any + interactive prompt. Turns an auth hang / bare exit-1 into a self-service + report of which tiers are available and which one would serve the next call. + (Issue #108: give stranded users a diagnosis, not a dead end.) + """ + load_env() + tenant_id = os.environ.get("TENANT_ID") + url = os.environ.get("DATAVERSE_URL", "").rstrip("/") + scope = _dataverse_scope() + client_id = os.environ.get("CLIENT_ID") + client_secret = os.environ.get("CLIENT_SECRET") + + print(f"Dataverse auth diagnosis for {url or '<DATAVERSE_URL unset>'}") + print( + f" tenant={tenant_id or '<TENANT_ID unset>'} host={sys.platform} " + f"browser={_host_has_browser()} " + f"workspace-cache={'on' if os.environ.get('DATAVERSE_TOKEN_CACHE_DIR') else 'off'}" + ) + print("") + + rows = [] # (tier, status, detail) + + if client_id and client_secret: + rows.append(("service-principal", "CONFIGURED", "terminal -- used exclusively, no interactive fallback")) + else: + rows.append(("service-principal", "not set", "set CLIENT_ID + CLIENT_SECRET for unattended auth")) + + try: + shared = _build_shared_msal_cache() + if shared is not None: + rows.append(("shared-cache", "AVAILABLE", "`dataverse auth create` cache yields a silent token")) + else: + rows.append(("shared-cache", "unavailable", "no cache / no account / silent miss -- run `dataverse auth create`")) + except Exception as e: # noqa: BLE001 + rows.append(("shared-cache", "error", type(e).__name__)) + + try: + from azure.identity import AzureCliCredential, CredentialUnavailableError + try: + cred = AzureCliCredential(tenant_id=tenant_id) + if scope: + cred.get_token(scope) + rows.append(("azure-cli", "AVAILABLE", "`az login` yields a silent token for this tenant")) + else: + rows.append(("azure-cli", "unknown", "DATAVERSE_URL unset -- cannot probe")) + except CredentialUnavailableError: + rows.append(("azure-cli", "unavailable", "az not installed or not logged in -- run `az login`")) + except Exception as e: # noqa: BLE001 + rows.append(("azure-cli", "skipped", type(e).__name__)) + except ImportError: + rows.append(("azure-cli", "n/a", "azure-identity not installed")) + + if _should_use_workspace_cache() is not None: + kind = "workspace-device-code" + elif _host_has_browser(): + kind = "interactive-browser" + else: + kind = "device-code" + rows.append(("interactive", kind, "used only if every silent tier above is unavailable")) + + width = max(len(t) for t, _, _ in rows) + for tier, status, detail in rows: + print(f" {tier.ljust(width)} {status.ljust(12)} {detail}") + + silent_ok = any(s in ("CONFIGURED", "AVAILABLE") for _, s, _ in rows[:3]) + print("") + if silent_ok: + print("Result: a silent tier is available -- normal calls will not prompt.") + else: + print(f"Result: no silent tier -- the next call uses the '{kind}' interactive tier.") + + +def _run_ping(): + """Lightweight reachability check: token + one HTTP call, no SDK import. + + Uses only stdlib (urllib) and the credential chain (which needs msal or + azure-identity -- at least one is typically installed globally even before + pip runs in a fresh workspace). Writes .env and copies auth.py to scripts/ + if missing, so a successful ping makes the workspace ready for SDK use + after pip install. + """ + import shutil + from urllib.error import HTTPError, URLError + from urllib.request import Request, urlopen + + load_env() + url = os.environ.get("DATAVERSE_URL", "").rstrip("/") + tenant_id = os.environ.get("TENANT_ID") + + if not url or not tenant_id: + print("PING FAILED: no DATAVERSE_URL or TENANT_ID (no .env, no user-level config).", flush=True) + sys.exit(1) + + # Acquire a token via the credential chain (shared CLI cache, az, interactive). + # _get_credential needs azure-identity or msal; if neither is installed, fail cleanly. + try: + token = get_token() + except SystemExit: + print("PING FAILED: could not acquire a token (azure-identity or msal may not be installed).", flush=True) + sys.exit(1) + + # One raw HTTP call to verify the org is reachable. + try: + req = Request(f"{url}/api/data/v9.2/WhoAmI") + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Accept", "application/json") + resp = urlopen(req, timeout=20) + resp.read() + except HTTPError as e: + print(f"PING FAILED: {url} returned HTTP {e.code}.", flush=True) + sys.exit(2) + except (URLError, OSError) as e: + reason = getattr(e, "reason", e) + print(f"PING FAILED: {url} unreachable ({reason}).", flush=True) + sys.exit(2) + + # Workspace bootstrap: write .env and copy auth.py if missing. + workspace = Path.cwd() + env_path = workspace / ".env" + if not env_path.exists(): + env_path.write_text(f"DATAVERSE_URL={url}\nTENANT_ID={tenant_id}\n", encoding="utf-8") + dest = workspace / "scripts" / "auth.py" + if not dest.exists(): + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(Path(__file__).resolve(), dest) + gitignore = workspace / ".gitignore" + existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" + for entry in (".env", ".dataverse/"): + if entry not in existing: + with open(gitignore, "a", encoding="utf-8") as f: + f.write(f"\n{entry}\n") + + _save_user_config() + print(f"REACHABLE: {url}", flush=True) + + +def _run_bootstrap(url): + """One-command workspace setup from a Dataverse org URL. + + Probes the URL for tenant ID via WWW-Authenticate, writes .env, copies + auth.py to scripts/, and saves to user-level config. Uses only stdlib + (urllib) so it works before pip install. + """ + import shutil + from urllib.error import HTTPError, URLError + from urllib.request import Request, urlopen + + url = url.rstrip("/") + if not url.startswith("https://"): + print(f"ERROR: URL must start with https:// (got: {url})", flush=True) + sys.exit(1) + + print(f"Probing {url} for tenant ID...", flush=True) + www_auth = "" + try: + req = Request(f"{url}/api/data/v9.2/", method="GET") + req.add_header("Accept", "application/json") + urlopen(req, timeout=20) + print("ERROR: Unexpected 200 from unauthenticated probe.", flush=True) + sys.exit(1) + except HTTPError as e: + www_auth = e.headers.get("WWW-Authenticate", "") + except URLError as e: + print(f"ERROR: Cannot reach {url}: {e.reason}", flush=True) + sys.exit(1) + + match = re.search(r"login\.microsoftonline\.com/([^/,\s]+)", www_auth) + if not match: + print(f"ERROR: Could not discover tenant from {url}", flush=True) + print(f" WWW-Authenticate: {www_auth[:200]}", flush=True) + sys.exit(1) + tenant_id = match.group(1) + + workspace = Path.cwd() + env_path = workspace / ".env" + env_path.write_text( + f"DATAVERSE_URL={url}\nTENANT_ID={tenant_id}\n", encoding="utf-8", + ) + + scripts_dir = workspace / "scripts" + dest = scripts_dir / "auth.py" + if not dest.exists(): + scripts_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(Path(__file__).resolve(), dest) + + gitignore = workspace / ".gitignore" + existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" + missing = [e for e in (".env", ".dataverse/") if e not in existing] + if missing: + with open(gitignore, "a", encoding="utf-8") as f: + f.write("\n" + "\n".join(missing) + "\n") + + os.environ["DATAVERSE_URL"] = url + os.environ["TENANT_ID"] = tenant_id + _save_user_config() + + print(f"BOOTSTRAPPED: {url} (tenant {tenant_id})", flush=True) + print(f" .env: {env_path}", flush=True) + print(f" user config: {_USER_CONFIG_PATH}", flush=True) + print(" Next: pip install deps (if needed), then: python scripts/auth.py --check", flush=True) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Acquire a Dataverse token, or verify the environment is reachable." + ) + parser.add_argument( + "--check", + action="store_true", + help="Make a REAL data-plane call and report reachability instead of printing a token. " + "A token can be minted while the org domain is blocked (restricted-egress hosts), so " + "this is the only proof of an actual connection. Exit 0 = reachable, 2 = not reachable.", + ) + parser.add_argument( + "--ping", + action="store_true", + help="Lightweight reachability check using only stdlib + msal (no SDK import). " + "Loads user-level config or .env, acquires a token via the credential chain, makes " + "one raw HTTP call to verify the org is reachable. Exit 0 = reachable + prints the " + "org URL, 1 = no config or no token, 2 = token OK but org unreachable. Also writes " + ".env and copies auth.py to scripts/ if missing.", + ) + parser.add_argument( + "--diagnose", + action="store_true", + help="Print which credential tiers (service principal / shared cache / az CLI / " + "interactive) are available, WITHOUT prompting. Use when auth hangs or fails to see " + "why and which tier would serve the next call.", + ) + parser.add_argument( + "--bootstrap", metavar="URL", + help="One-shot setup: probe URL for tenant, write .env, copy auth.py to scripts/, " + "save to user-level config. Does not authenticate -- run --check after pip install.", + ) + args = parser.parse_args() + + if args.diagnose: + _run_diagnose() + sys.exit(0) + + if args.bootstrap: + _run_bootstrap(args.bootstrap) + sys.exit(0) + + if args.ping: + _run_ping() + sys.exit(0) + + if not args.check: + # Default (unchanged) behavior: print a bearer token. + print(get_token()) + sys.exit(0) + + # Reachability gate. A green token is NOT a connection: auth traffic goes to + # login.microsoftonline.com (often reachable) while the org's data plane + # (*.dynamics.com) may be blocked by a sandbox egress allowlist. Only a real + # call proves it. Bound the wait so a blocked domain fails fast instead of hanging. + import socket + + socket.setdefaulttimeout(30) + load_env() + url = os.environ.get("DATAVERSE_URL", "").rstrip("/") + try: + client = get_client("dv-connect") + tables = client.tables.list(select=["LogicalName"]) + print(f"REACHABLE: {url} -- {len(tables)} non-private tables") + _save_user_config() + sys.exit(0) + except Exception as e: + print(f"NOT REACHABLE: {url} -- {type(e).__name__}: {e}", flush=True) + print( + "If this is a connection/timeout error, the org domain is blocked by the host " + "network egress allowlist -- auth is fine; the data plane is unreachable. Do NOT " + "report a table count or query result: nothing was retrieved.", + flush=True, + ) + sys.exit(2)
Full technical diff · 88 changed fields
changed /files/.claude-plugin~1plugin.json/sha256
"6e40c3dfa58513854e5ec8e5db8da85ddf561dc41b2ccbdfa149b44c9f4cfa28"
"5677ada5e3f5f6af333915498383bfeb0c7afbd40bcd1d0b4915a11b606411e5"
changed /files/.claude-plugin~1plugin.json/size
533
544
removed /files/.codex-plugin~1assets~1composer-icon.svg
{
"sha256": "5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79",
"size": 5127
}Field is absent
removed /files/.codex-plugin~1assets~1logo.svg
{
"sha256": "5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79",
"size": 5127
}Field is absent
changed /files/.codex-plugin~1plugin.json/sha256
"fcf33fe56fdf5634f299812115c4a8fcf0101e231c173095cd73c2cc2fed7315"
"a1df8dfe6e91d4b59a161aaec0cc6ffd5102863d380c5198425e4acbde681655"
changed /files/.codex-plugin~1plugin.json/size
1739
1688
changed /files/.cursor-plugin~1plugin.json/sha256
"20f54a80eabfcc4353f567691a54f62b5635fda657891edeeeeb751f80351c9c"
"2744004147112bf197e5c11868676854f41938f68cdfc2c01f5e63ab04575243"
changed /files/.cursor-plugin~1plugin.json/size
749
750
changed /files/.github~1plugin~1plugin.json/sha256
"73e0a97d93e1bea8d1960973d1be044e4fe6c8a529d0b9c3397ba86caeacad3b"
"9d5b04268440796ceade88d7587e8d973cf82ac8ab9932758d255dfe55a90f67"
changed /files/.github~1plugin~1plugin.json/size
625
634
changed /files/assets~1dataverse-logo.svg/sha256
"5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79"
"df71c0111148d116515a02c5cb07483caad2bcb779664fa16fd837a4709504ab"
changed /files/assets~1dataverse-logo.svg/size
5127
5067
added /files/gemini-extension.json
Field was absent
{
"sha256": "94bf4a841d8bcd2761b63944ce2fa941d6cc4a123a5c02433425399bdc4252fe",
"size": 843
}added /files/plugin.json
Field was absent
{
"sha256": "23135b1c01f00c3edf364dffe6f927d2c6207ec22a27d0b0aa69b66b89ced875",
"size": 350
}removed /files/scripts~1__pycache__~1auth.cpython-314.pyc
{
"sha256": "9c52b0410a6e850b4104ade4b9b2597f109674beb9bb19872c77e181992d8a67",
"size": 57132
}Field is absent
removed /files/scripts~1__pycache__~1enable-mcp-client.cpython-314.pyc
{
"sha256": "1ef77901b33cc7b450374952d0152cd247f299f6048e09f51bebf4ca8d86a911",
"size": 2839
}Field is absent
removed /files/scripts~1__pycache__~1mcp_remote.cpython-314.pyc
{
"sha256": "e364e0cbc46a938533441df78d198c874e41fe1ab075a0e35e35a76b61469296",
"size": 12914
}Field is absent
removed /files/scripts~1__pycache__~1test_auth.cpython-314.pyc
{
"sha256": "6cc979f4ff4e18dd80d192ee18c730ed920650f40e843db6de06464b81856ef9",
"size": 26593
}Field is absent
changed /files/scripts~1auth.py/sha256
"6d85745167f80bd5dc2c86cb33e954dd52ff6a1fe9b9a982a50337556851fd11"
"338d1d3a8f9877d945f0612f0206e3a0e8d7a417ffde97173bae0bbdad7858fb"
changed /files/scripts~1auth.py/size
50960
54392
changed /files/scripts~1enable-mcp-client.py/sha256
"f294c5747b6bc89c3bb8dd30f7205c95c2666580779c065070bb9c6ab3fbf67b"
"bf832bead4619c958b3b5ff286ae9b8e9bb3a17addc0f0645e154948a8ab3202"
changed /files/scripts~1enable-mcp-client.py/size
2089
2027
changed /files/skills~1dv-admin~1SKILL.md/sha256
"ed1e393c51aac454f6394e2a88936192e813335246dbf0b66e9c77a3a5276a55"
"77df321dd64521a864e5c4950510974d248bdaa5717d1cee62470af56e72685c"
changed /files/skills~1dv-admin~1SKILL.md/size
20586
20373
changed /files/skills~1dv-admin~1references~1erp-batch.md/sha256
"ab11d9c5f426464a8a9daa76dbe5ff662bf796ee0817415aba9069d4da207159"
"6261d03bde0ba2705e8118d7b35f8a3ffca8a5949829671558cc190aa701e7d7"
changed /files/skills~1dv-admin~1references~1erp-batch.md/size
6120
7032
changed /files/skills~1dv-admin~1references~1orgdb-settings.md/sha256
"cc9039161371e236cecbb065c9ff3f6c65264598f9d9a907db84589e844eb525"
"e9b8d2bdd31b7732b429ef909732eb29eef50fca0466fe392bcb66088b45ad0b"
changed /files/skills~1dv-admin~1references~1orgdb-settings.md/size
5276
5180
changed /files/skills~1dv-admin~1references~1recycle-bin.md/sha256
"3b32aaa49711227fbe1f8485809afffa85471d0f806877874f9a17ed9f459596"
"cc81bc00088fd038808a7c5afb8ef107b0737692b9cbcd4e54bc2aa806883376"
changed /files/skills~1dv-admin~1references~1recycle-bin.md/size
9361
9216
changed /files/skills~1dv-admin~1references~1settings-overrides.md/sha256
"2f6bc0dfbdd5eeba77fd9b35a6807882130d68b9140077925118a3acde1444e1"
"99dc767be0e68b5c252d8af2c243d79a333b34a45925f35cb1fe58252ea13b6c"
changed /files/skills~1dv-admin~1references~1settings-overrides.md/size
3199
3133
changed /files/skills~1dv-connect~1SKILL.md/sha256
"66b2aac1dd9851db68663b82c19d2b4ef04a2ac0c2b61f38e94e7288cd5fab0e"
"8cef7f5becce9e57e91601122dba6309eab8f395cc848f4c7210fdd618320fe6"
changed /files/skills~1dv-connect~1SKILL.md/size
20605
20151
changed /files/skills~1dv-connect~1references~1erp-detection.md/sha256
"4e331cdc01fc49dea8b7dba05e6e42b078f235cd21de4e9bd999699286509f3f"
"3fc083f69346c5286bdf1db20f6fa0690f01cac88bf9390310b791478df4648a"
changed /files/skills~1dv-connect~1references~1erp-detection.md/size
1480
1581
changed /files/skills~1dv-connect~1references~1headless-hosts.md/sha256
"5f31867321f86dd61219d188d1c367bfce574ad88c0221329f7c3d106d87ba4d"
"a47004bc83112e8ef7edca39088517fd063b0baddb56244ed1fe3119cea374e5"
changed /files/skills~1dv-connect~1references~1headless-hosts.md/size
17525
17272
added /files/skills~1dv-connect~1references~1helper-scripts.md
Field was absent
{
"sha256": "ff6a129d8e133675c65bae78b291e75c2a032e31781836e7a7ee3b415a892903",
"size": 1378
}changed /files/skills~1dv-connect~1references~1mcp-configuration.md/sha256
"e6455f037882407b5d23c7a167c544de4d130d12fcb6c49c744ccdb6dc49a59b"
"4e19a787b7b70a9a822a2a5057e9602905c11e05b4997d04a404d15d155e0451"
changed /files/skills~1dv-connect~1references~1mcp-configuration.md/size
29925
35226
changed /files/skills~1dv-connect~1references~1tools-setup.md/sha256
"89ea95de02b4f26ca2d92f16be1d63cd868858b0e0b829e627c859bb44882627"
"c8c66a5573731db56fb4ec0cf778ffdca3628b5ef73b0f56995f12e634b72aed"
changed /files/skills~1dv-connect~1references~1tools-setup.md/size
12225
12468
changed /files/skills~1dv-data~1SKILL.md/sha256
"0fe26813b039263468010e8f486e086d2233a0d2252a1011b662a89c66fa595e"
"c5c59e46c1648520c634f9c0f84b9f4503c36f852cb81437b977081b8efe7337"
changed /files/skills~1dv-data~1SKILL.md/size
19497
19495
changed /files/skills~1dv-data~1references~1erp-writes.md/sha256
"4603db5c0415a14355af3a16b4adddd9bc85610160aff485f48e72fb9e21583e"
"2dc9436568a22c5298fc4c7179e0f1eb8e1b7f544aac00c4cbce8cc554a20340"
changed /files/skills~1dv-data~1references~1erp-writes.md/size
2356
3013
changed /files/skills~1dv-data~1references~1multi-table-fk-import.md/sha256
"2ffbe78c453868d686e98b35d9dbc7a170cdcf04286b940524ece6f0d96ad18d"
"f0ed0ad62c7ec92c0eb3d267279f33d94c7cb6573e6e344bd896987cdc9fe160"
changed /files/skills~1dv-data~1references~1multi-table-fk-import.md/size
11259
11047
changed /files/skills~1dv-data~1references~1sample-data-generation.md/sha256
"d066313d84a00681ee9960587a925b884048f68b8c67a2015e291f60eca8ef6e"
"508c1ef32daf740a297f1f0f767a72f9d895e847bc6181d6cc599393348f7c72"
changed /files/skills~1dv-data~1references~1sample-data-generation.md/size
6901
6769
changed /files/skills~1dv-metadata~1SKILL.md/sha256
"cfd7d87efde7ed8b05199470bc1704a9320bcad78b80aaeac630bbcdb2963a3a"
"8ae59235772c1de3db53f7d8628ced486822166b6fb6ede146167017e916cfd8"
changed /files/skills~1dv-metadata~1SKILL.md/size
22156
21708
changed /files/skills~1dv-metadata~1references~1alternate-keys.md/sha256
"4dc441b6045711f07ee2e7f4b0255b070d9616eb25e43ef5cdb8f7d8236606ea"
"57d75957fffe4cc1a8ef0c207d3eff221a4272d2b0e6d110d9b1ca6c7b3a4599"
changed /files/skills~1dv-metadata~1references~1alternate-keys.md/size
4163
4086
changed /files/skills~1dv-metadata~1references~1forms-and-views.md/sha256
"bd1f6f8e4079f96d4e446f60752d95492dad8b45b30e816e027798cccc09c462"
"0a4e53748dd6fa57e2f12c1e3f546b0dcb8420a5414cab52751b8f3d7a6f125e"
changed /files/skills~1dv-metadata~1references~1forms-and-views.md/size
9387
9192
changed /files/skills~1dv-metadata~1references~1metadata-propagation.md/sha256
"6480224b15a192152e6437d80f6e26d942f7ad1d4093ca498dc3f847f096108f"
"325cc285f47e44515f39c0500aee0454f493f41fb44746631148b189273ec5d4"
changed /files/skills~1dv-metadata~1references~1metadata-propagation.md/size
2322
2279
changed /files/skills~1dv-overview~1SKILL.md/sha256
"702103f7eee1154037cb1d9bd0e71da689c1a503bbd1c57d41dc991bf6878bc5"
"6c4f76edcbe2b69db56a33c64aa612ed2ca4b849c530f4c07683666a7ff93838"
changed /files/skills~1dv-overview~1SKILL.md/size
21413
21491
changed /files/skills~1dv-overview~1references~1erp-target.md/sha256
"eb51c682c4e310d1dce51930f5a65d794d4cd29077ef5573066b11bdb487f994"
"83065699935c03dccea3fb63e79db7224eb2216384adb58b813f33a2c389fbf5"
changed /files/skills~1dv-overview~1references~1erp-target.md/size
7215
9668
changed /files/skills~1dv-overview~1references~1windows-scripting.md/sha256
"b32267f44c88e6be0d61798fe94c82d423c249ab1bb18f3abc7c3889ff0f27b1"
"0c00f89adcbcfb4013d8eca09daa80182b71a093588a9ce5d96c45e9a6c07904"
changed /files/skills~1dv-overview~1references~1windows-scripting.md/size
1276
1267
changed /files/skills~1dv-query~1SKILL.md/sha256
"4aed7ed14c67dab4269061639eeec1d1421fb065dec427cfc0bed03638e3a24e"
"c5193acb192537638c2f3fd367ed505c104d5ac538c3ac5ef1f21e82fdcb23fb"
changed /files/skills~1dv-query~1SKILL.md/size
18242
18097
changed /files/skills~1dv-query~1references~1erp-reads.md/sha256
"c21646931461f45773a81b2d8d165b0a01de25a0b7e9255b68a0098ce49461bf"
"f0e24cdebb507e20b7dfd6bfd8629ef1f8171f1266b0c3d4df0f5ec38c60caa3"
changed /files/skills~1dv-query~1references~1erp-reads.md/size
2829
4192
changed /files/skills~1dv-query~1references~1jupyter-setup.md/sha256
"0b48389607a72e1699bab0453fa0a56651d1bea4d0c0183a80d44dca72f995e0"
"e68e02caac2936a5942bb797e24f3cba19e09ef4d70157decb8fc6ed51be8c54"
changed /files/skills~1dv-query~1references~1jupyter-setup.md/size
1125
1968
changed /files/skills~1dv-query~1references~1querybuilder.md/sha256
"38025a0f0356a8d3034bc40d0ac8e26a684abda45a8de3315324f7f051e75c14"
"1a92ddb4ac818dfa10494137d8443e47a43bcd09af8bfbf0020e8ffcdbb0f45f"
changed /files/skills~1dv-query~1references~1querybuilder.md/size
5155
5053
changed /files/skills~1dv-query~1references~1web-api-advanced.md/sha256
"8b5c1f81a29e632216bfeedc5f5eb2ac5d5c20b0791626d1d4d2f7289b78933a"
"c56495572f1c06e33f5f38952e4ea42964f8d32d9fff18621dcfded26fde568e"
changed /files/skills~1dv-query~1references~1web-api-advanced.md/size
6392
6290
changed /files/skills~1dv-security~1SKILL.md/sha256
"5a26142850d2821e40c64e590f6ecb5c9378677de742242e2ae3e396231d60a2"
"f40e1d1e9429fcad38e020c8de188a502208e7d34e92725576ad9b44d548d09d"
changed /files/skills~1dv-security~1SKILL.md/size
8593
8444
changed /files/skills~1dv-solution~1SKILL.md/sha256
"cec6555f1d36f465476f66a38605b36c5a6319468189666ed63d33d58826c326"
"b847302a0b8cef5a2bf40f8a109e1b0fe90c46eec0428b2c3e0130eaf47f21ca"
changed /files/skills~1dv-solution~1SKILL.md/size
12519
12187
added /files/skills~1erp-xpp~1SKILL.md
Field was absent
{
"sha256": "ed0c6ee0f3dc825671953a7ddee895655fa9ac5fdf091723b887971a51e9b711",
"size": 15223
}added /files/skills~1erp-xpp~1references~1authoring.md
Field was absent
{
"sha256": "a3feeb190766aad69ef7f8cebb45d8181ba3ca53cc5f2c6ad6ef59ecbc1135d6",
"size": 16807
}added /files/skills~1erp-xpp~1references~1commands.md
Field was absent
{
"sha256": "6daf1640d88fbfe0f03d29b142648b6e90eef9a9274e51a8b13ce757e5b1b54d",
"size": 5321
}added /files/skills~1erp-xpp~1references~1validation.md
Field was absent
{
"sha256": "3b059247f1b5fccd8ba259a81376a07f1e89c3cef6ca1a895868966028b70ec0",
"size": 15876
}added /files/skills~1erp-xpp~1references~1workflows.md
Field was absent
{
"sha256": "486989fc3f059ef93131f533c243301a51f25051f82cb6170134a9cb0be80e20",
"size": 8955
}changed /files/templates~1CLAUDE.md/sha256
"765cba0f802d6f3f55d5dac3d2ac34621843b2de1cd25c578545471af6334bd1"
"4904f011d33634d81504dd80e222726d744eaafcedff3a79f000eb4138705e11"
changed /files/templates~1CLAUDE.md/size
2492
2433
changed /files/templates~1deploy.yml/sha256
"45cc9bf9254dddb0366a7fef438b7618a5fad157352fb2ac351de75b166c1f7d"
"40893882b55c8f43a3afd753f609d78daf0e3a03ef6634a7c7374cf25c3cab01"
changed /files/templates~1deploy.yml/size
1113
1078
Full snapshot data
{
"files": {
".claude-plugin/plugin.json": {
"sha256": "5677ada5e3f5f6af333915498383bfeb0c7afbd40bcd1d0b4915a11b606411e5",
"size": 544
},
".codex-plugin/plugin.json": {
"sha256": "a1df8dfe6e91d4b59a161aaec0cc6ffd5102863d380c5198425e4acbde681655",
"size": 1688
},
".cursor-plugin/plugin.json": {
"sha256": "2744004147112bf197e5c11868676854f41938f68cdfc2c01f5e63ab04575243",
"size": 750
},
".github/plugin/plugin.json": {
"sha256": "9d5b04268440796ceade88d7587e8d973cf82ac8ab9932758d255dfe55a90f67",
"size": 634
},
"assets/dataverse-logo-256.png": {
"sha256": "2b5d4aea7419a84a432b7282bdcd53173141dc4957665d95de587b924d2773f7",
"size": 52720
},
"assets/dataverse-logo-48.png": {
"sha256": "57cba12bc75cb249794806f42966a5807ce3cb546ebd4a8083aa149ebd8a907d",
"size": 3806
},
"assets/dataverse-logo.svg": {
"sha256": "df71c0111148d116515a02c5cb07483caad2bcb779664fa16fd837a4709504ab",
"size": 5067
},
"gemini-extension.json": {
"sha256": "94bf4a841d8bcd2761b63944ce2fa941d6cc4a123a5c02433425399bdc4252fe",
"size": 843
},
"plugin.json": {
"sha256": "23135b1c01f00c3edf364dffe6f927d2c6207ec22a27d0b0aa69b66b89ced875",
"size": 350
},
"scripts/auth.py": {
"sha256": "338d1d3a8f9877d945f0612f0206e3a0e8d7a417ffde97173bae0bbdad7858fb",
"size": 54392
},
"scripts/enable-mcp-client.py": {
"sha256": "bf832bead4619c958b3b5ff286ae9b8e9bb3a17addc0f0645e154948a8ab3202",
"size": 2027
},
"skills/dv-admin/SKILL.md": {
"sha256": "77df321dd64521a864e5c4950510974d248bdaa5717d1cee62470af56e72685c",
"size": 20373
},
"skills/dv-admin/references/erp-batch.md": {
"sha256": "6261d03bde0ba2705e8118d7b35f8a3ffca8a5949829671558cc190aa701e7d7",
"size": 7032
},
"skills/dv-admin/references/orgdb-settings.md": {
"sha256": "e9b8d2bdd31b7732b429ef909732eb29eef50fca0466fe392bcb66088b45ad0b",
"size": 5180
},
"skills/dv-admin/references/recycle-bin.md": {
"sha256": "cc81bc00088fd038808a7c5afb8ef107b0737692b9cbcd4e54bc2aa806883376",
"size": 9216
},
"skills/dv-admin/references/settings-overrides.md": {
"sha256": "99dc767be0e68b5c252d8af2c243d79a333b34a45925f35cb1fe58252ea13b6c",
"size": 3133
},
"skills/dv-connect/SKILL.md": {
"sha256": "8cef7f5becce9e57e91601122dba6309eab8f395cc848f4c7210fdd618320fe6",
"size": 20151
},
"skills/dv-connect/references/erp-detection.md": {
"sha256": "3fc083f69346c5286bdf1db20f6fa0690f01cac88bf9390310b791478df4648a",
"size": 1581
},
"skills/dv-connect/references/headless-hosts.md": {
"sha256": "a47004bc83112e8ef7edca39088517fd063b0baddb56244ed1fe3119cea374e5",
"size": 17272
},
"skills/dv-connect/references/helper-scripts.md": {
"sha256": "ff6a129d8e133675c65bae78b291e75c2a032e31781836e7a7ee3b415a892903",
"size": 1378
},
"skills/dv-connect/references/mcp-configuration.md": {
"sha256": "4e19a787b7b70a9a822a2a5057e9602905c11e05b4997d04a404d15d155e0451",
"size": 35226
},
"skills/dv-connect/references/tools-setup.md": {
"sha256": "c8c66a5573731db56fb4ec0cf778ffdca3628b5ef73b0f56995f12e634b72aed",
"size": 12468
},
"skills/dv-data/SKILL.md": {
"sha256": "c5c59e46c1648520c634f9c0f84b9f4503c36f852cb81437b977081b8efe7337",
"size": 19495
},
"skills/dv-data/references/erp-writes.md": {
"sha256": "2dc9436568a22c5298fc4c7179e0f1eb8e1b7f544aac00c4cbce8cc554a20340",
"size": 3013
},
"skills/dv-data/references/multi-table-fk-import.md": {
"sha256": "f0ed0ad62c7ec92c0eb3d267279f33d94c7cb6573e6e344bd896987cdc9fe160",
"size": 11047
},
"skills/dv-data/references/sample-data-generation.md": {
"sha256": "508c1ef32daf740a297f1f0f767a72f9d895e847bc6181d6cc599393348f7c72",
"size": 6769
},
"skills/dv-metadata/SKILL.md": {
"sha256": "8ae59235772c1de3db53f7d8628ced486822166b6fb6ede146167017e916cfd8",
"size": 21708
},
"skills/dv-metadata/references/alternate-keys.md": {
"sha256": "57d75957fffe4cc1a8ef0c207d3eff221a4272d2b0e6d110d9b1ca6c7b3a4599",
"size": 4086
},
"skills/dv-metadata/references/forms-and-views.md": {
"sha256": "0a4e53748dd6fa57e2f12c1e3f546b0dcb8420a5414cab52751b8f3d7a6f125e",
"size": 9192
},
"skills/dv-metadata/references/metadata-propagation.md": {
"sha256": "325cc285f47e44515f39c0500aee0454f493f41fb44746631148b189273ec5d4",
"size": 2279
},
"skills/dv-overview/SKILL.md": {
"sha256": "6c4f76edcbe2b69db56a33c64aa612ed2ca4b849c530f4c07683666a7ff93838",
"size": 21491
},
"skills/dv-overview/references/erp-target.md": {
"sha256": "83065699935c03dccea3fb63e79db7224eb2216384adb58b813f33a2c389fbf5",
"size": 9668
},
"skills/dv-overview/references/windows-scripting.md": {
"sha256": "0c00f89adcbcfb4013d8eca09daa80182b71a093588a9ce5d96c45e9a6c07904",
"size": 1267
},
"skills/dv-query/SKILL.md": {
"sha256": "c5193acb192537638c2f3fd367ed505c104d5ac538c3ac5ef1f21e82fdcb23fb",
"size": 18097
},
"skills/dv-query/references/erp-reads.md": {
"sha256": "f0e24cdebb507e20b7dfd6bfd8629ef1f8171f1266b0c3d4df0f5ec38c60caa3",
"size": 4192
},
"skills/dv-query/references/jupyter-setup.md": {
"sha256": "e68e02caac2936a5942bb797e24f3cba19e09ef4d70157decb8fc6ed51be8c54",
"size": 1968
},
"skills/dv-query/references/querybuilder.md": {
"sha256": "1a92ddb4ac818dfa10494137d8443e47a43bcd09af8bfbf0020e8ffcdbb0f45f",
"size": 5053
},
"skills/dv-query/references/web-api-advanced.md": {
"sha256": "c56495572f1c06e33f5f38952e4ea42964f8d32d9fff18621dcfded26fde568e",
"size": 6290
},
"skills/dv-security/SKILL.md": {
"sha256": "f40e1d1e9429fcad38e020c8de188a502208e7d34e92725576ad9b44d548d09d",
"size": 8444
},
"skills/dv-solution/SKILL.md": {
"sha256": "b847302a0b8cef5a2bf40f8a109e1b0fe90c46eec0428b2c3e0130eaf47f21ca",
"size": 12187
},
"skills/erp-xpp/SKILL.md": {
"sha256": "ed0c6ee0f3dc825671953a7ddee895655fa9ac5fdf091723b887971a51e9b711",
"size": 15223
},
"skills/erp-xpp/references/authoring.md": {
"sha256": "a3feeb190766aad69ef7f8cebb45d8181ba3ca53cc5f2c6ad6ef59ecbc1135d6",
"size": 16807
},
"skills/erp-xpp/references/commands.md": {
"sha256": "6daf1640d88fbfe0f03d29b142648b6e90eef9a9274e51a8b13ce757e5b1b54d",
"size": 5321
},
"skills/erp-xpp/references/validation.md": {
"sha256": "3b059247f1b5fccd8ba259a81376a07f1e89c3cef6ca1a895868966028b70ec0",
"size": 15876
},
"skills/erp-xpp/references/workflows.md": {
"sha256": "486989fc3f059ef93131f533c243301a51f25051f82cb6170134a9cb0be80e20",
"size": 8955
},
"templates/CLAUDE.md": {
"sha256": "4904f011d33634d81504dd80e222726d744eaafcedff3a79f000eb4138705e11",
"size": 2433
},
"templates/deploy.yml": {
"sha256": "40893882b55c8f43a3afd753f609d78daf0e3a03ef6634a7c7374cf25c3cab01",
"size": 1078
}
}
}SHA-256 of public snapshot: 58c492ead8697609cee56bbf657fc84de5d1044469cad20cb9af8e740281e814