diff --git a/.env.example b/.env.example index 9a3a72b..7be5648 100644 --- a/.env.example +++ b/.env.example @@ -161,18 +161,45 @@ ENGRAPHIS_LLM_API_KEY=sk-your-key-here # ENGRAPHIS_CLOUD_TOKEN_SUBJECT=member # ENGRAPHIS_CLOUD_ACCESS_TOKEN= -# Persistent customer state (cloud session and optional saved scoped relay bearer). +# Which plan the dashboard shows, and which capabilities it draws as unlocked. You should +# not need either of these. +# +# The plan is resolved automatically: once this installation has reached the control plane +# it reads the authoritative entitlement (plan and feature list) and caches it beside the +# cloud session, so a Pro or Team badge is correct on every later boot including offline +# ones. The refresh is opportunistic and runs on a background thread — it never blocks or +# delays startup, and it never fails the dashboard when the cloud is unreachable. Before +# the very first successful contact a connected installation is presented as Pro, the +# smallest paid plan, so a paying customer is never shown the free local core. +# +# ENGRAPHIS_CLOUD_PLAN is an override for the cases automatic resolution cannot cover: an +# air-gapped deployment, or one pinned to a short-lived ENGRAPHIS_CLOUD_ACCESS_TOKEN that +# cannot refresh. It takes precedence over the cached entitlement. Accepts pro, team, or +# free. It is presentation only — setting it grants nothing, because Engraphis Cloud +# authorizes every paid call regardless of what this client displays. +# ENGRAPHIS_CLOUD_PLAN=team +# +# Set to 0 to stop the client contacting the control plane for the entitlement at all. The +# badge then falls back to ENGRAPHIS_CLOUD_PLAN, or to the last cached answer. +# ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH=0 + +# Persistent customer state (cloud session, the cached hosted entitlement, and an optional +# saved scoped relay bearer). # Locally defaults to ~/.engraphis; in a container use a private persistent volume. # ENGRAPHIS_STATE_DIR=/data/.engraphis # Hosted entitlements may report a separate local-only write grace capped at 24 hours. # It never extends the exact 3-day trial, subscription expiry, or any cloud access. -# Managed compute consent. Set to 1 to allow the open package to upload workspace -# snapshots to the Engraphis Cloud service for managed analytics, dreaming, and -# consolidation. Without this flag, managed compute is off and no cloud upload -# occurs. The cloud service is authoritative for all paid computation. -# ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1 +# Managed compute consent is decided automatically and needs no customer action: a +# local-only installation (no cloud session) is never allowed to upload workspace +# snapshots, while an installation connected to Engraphis Cloud is allowed by default, +# because connecting already accepts the terms covering managed analytics, dreaming, and +# consolidation. This variable is an explicit operator override, not a customer-facing +# setting — set it to 0 to opt a connected installation back out, or to 1 to force +# managed compute on regardless of session state. The cloud service remains authoritative +# for all paid computation. +# ENGRAPHIS_MANAGED_COMPUTE_CONSENT=0 # Optional credential-redacted JSON logs for hosted customer deployments. # ENGRAPHIS_JSON_LOGS=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f579b37..0295e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to Engraphis are documented here. Format loosely follows ## [Unreleased] -No unreleased changes. +### Changed + +- Managed compute consent now travels with the cloud account: an installation connected to + Engraphis Cloud is enabled for managed analytics, dreaming, and consolidation **by + default**, because connecting already accepts the terms that cover it. A local-only + installation with no cloud session is still never allowed. + `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` remains as an explicit operator override (`=0` opts a + connected installation back out, `=1` forces it on regardless of session state) and is no + longer surfaced anywhere in the UI. ## [1.0.1] - 2026-07-24 diff --git a/README.md b/README.md index 592ce25..152330f 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,14 @@ a color palette and layout preset; or change the colors used for each type of no ### Managed compute The open package can upload bounded workspace snapshots to the Engraphis Cloud service -for managed analytics, dreaming, and consolidation. This is **off by default** and -requires explicit opt-in by setting `ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1`. The cloud -service is authoritative for all paid computation; no local setting turns this package -into a compute worker or relay. +for managed analytics, dreaming, and consolidation. A local-only installation with no +cloud session is **never** allowed to upload. Connecting an installation to Engraphis +Cloud accepts the terms that cover managed compute, so a **connected installation is +enabled by default** — there is no separate opt-in step to complete. +`ENGRAPHIS_MANAGED_COMPUTE_CONSENT` remains as an operator override (`=0` opts a +connected installation back out, `=1` forces it on); it is not a customer-facing +setting. The cloud service is authoritative for all paid computation; no local setting +turns this package into a compute worker or relay. The dashboard is powered by the v2 engine — the same `MemoryService` that backs the MCP server and the Python library. What you see in the UI is what your agents get. @@ -616,9 +620,11 @@ background loop, cron wrapper, or worker. Secret-class and session-scoped memories are excluded before a managed snapshot is serialized; secret-class rows are rejected again by the hosted service. The encoded payload is capped at -16 MiB. Managed compute remains off until the customer explicitly sets -`ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1`; cloud entitlement is also required. A managed proposal -never silently rewrites the local database. +16 MiB and travels over HTTPS without end-to-end encryption. Managed compute is enabled by +default once an installation is connected to Engraphis Cloud — connecting accepts the terms +that cover it — and stays off for a local-only installation with no cloud session; cloud +entitlement is also required. `ENGRAPHIS_MANAGED_COMPUTE_CONSENT=0` opts a connected +installation back out. A managed proposal never silently rewrites the local database. Manual consolidation can also use schema-validated LLM output through `MemoryService.consolidate`, `POST /api/consolidate`, `engraphis_consolidate`, or @@ -667,7 +673,7 @@ All via environment (or `.env`): | `ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL` | — | Bootstrap-only rotating hosted credential; after first use the owner-only cloud session replacement takes precedence | | `ENGRAPHIS_CLOUD_TOKEN_SUBJECT` | `member` | Subject fixed during hosted bootstrap (`device` or `member`); set explicitly with an environment-only refresh credential | | `ENGRAPHIS_CLOUD_ACCESS_TOKEN` | — | Optional short-lived access token for ephemeral jobs | -| `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` | `0` | Explicitly set to `1` before uploading a bounded workspace snapshot for hosted Analytics or Automation | +| `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` | *(auto)* | Operator override only; default follows whether a cloud session is configured (connected = allowed, local-only = never). `0` opts a connected installation out, `1` forces it on | See `.env.example` for the full customer-runtime and managed-service client options. diff --git a/SECURITY.md b/SECURITY.md index 4b617a9..d278e23 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -115,9 +115,14 @@ them back as `expected_head` / `expected_count` when independent evidence is req - **Server authority:** every hosted and cost-bearing operation is authorized by the private control plane; local plan labels and upgrade URLs are presentation metadata only. - **Managed-compute consent:** Analytics, Auto Dreaming, and Auto Consolidation upload a - bounded snapshot only after explicit customer opt-in via - `ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1`. Secret-class memories are excluded before - serialization and rejected again by the hosted service. + bounded snapshot. Consent travels with the cloud account: connecting an installation to + Engraphis Cloud accepts the terms covering managed compute, so a connected installation is + allowed and an installation with no cloud session is never allowed. Operators may override + with `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` (`0` opts a connected installation back out). + The snapshot carries normal and sensitive memory content, excludes secret-class and + session-scoped rows, is capped at 16 MiB, and travels over HTTPS without end-to-end + encryption. Secret-class memories are excluded before serialization and rejected again by + the hosted service. - **Trial and grace are separate:** an email-confirmed trial lasts exactly 3 active days. A separately bounded, maximum-24-hour local workspace-write grace never extends the trial, subscription, Cloud Sync, managed compute, Team access, seats, or credentials. diff --git a/deploy/railway-template.json b/deploy/railway-template.json index b2b83e3..a66fa4b 100644 --- a/deploy/railway-template.json +++ b/deploy/railway-template.json @@ -50,8 +50,8 @@ "required": false }, "ENGRAPHIS_MANAGED_COMPUTE_CONSENT": { - "value": "0", - "prompt": "Change to 1 only after reviewing the managed snapshot upload boundary.", + "value": "", + "prompt": "Optional override. Leave blank: managed compute follows the cloud account, so a connected deployment is allowed and a local-only one is not. Set 0 to opt this deployment out.", "required": false } } diff --git a/docs/HOSTING_RAILWAY.md b/docs/HOSTING_RAILWAY.md index afd094f..5cf510d 100644 --- a/docs/HOSTING_RAILWAY.md +++ b/docs/HOSTING_RAILWAY.md @@ -39,9 +39,11 @@ ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL= Prefer mounting the owner-only cloud session file rather than placing a rotating refresh credential directly in deployment configuration. An injected environment credential is only the -bootstrap value; after rotation, the owner-only saved replacement takes precedence. Enabling -managed compute for an authorized customer may upload a snapshot capped at 16 MiB; secret-class -and session-scoped rows are excluded client-side, and secret-class rows are rejected server-side. +bootstrap value; after rotation, the owner-only saved replacement takes precedence. Once +connected, managed compute is enabled by default for an authorized customer and may upload a +snapshot capped at 16 MiB over HTTPS without end-to-end encryption; secret-class and +session-scoped rows are excluded client-side, and secret-class rows are rejected server-side. +Set `ENGRAPHIS_MANAGED_COMPUTE_CONSENT=0` to opt the deployed installation back out. ## Persistence and recovery diff --git a/docs/RAILWAY_TEMPLATE.md b/docs/RAILWAY_TEMPLATE.md index 0c2cf8f..d9a7000 100644 --- a/docs/RAILWAY_TEMPLATE.md +++ b/docs/RAILWAY_TEMPLATE.md @@ -14,8 +14,11 @@ issuer, relay, managed compute, Auto Dreaming, Auto Consolidation, or Team ident - No vendor signer, billing, mail, Team-admin, relay-storage, or worker secrets. Hosted customer endpoint variables may be exposed as optional inputs, but a refresh credential -must be injected as a secret or mounted owner-only state file. Managed compute requires -explicit opt-in via `ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1`. +must be injected as a secret or mounted owner-only state file. Managed compute is enabled by +default once the deployed installation is connected to Engraphis Cloud; connecting accepts the +terms that cover it, and a local-only node with no cloud session is never allowed. Expose +`ENGRAPHIS_MANAGED_COMPUTE_CONSENT` only as an optional operator override (`0` to opt a +connected installation back out), not as a required opt-in input. ## Publish gate diff --git a/engraphis/cloud_features.py b/engraphis/cloud_features.py index 2235517..2c27c63 100644 --- a/engraphis/cloud_features.py +++ b/engraphis/cloud_features.py @@ -14,12 +14,13 @@ import urllib.error import urllib.request import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Optional from urllib.parse import quote from engraphis.cloud_session import CloudSessionError, access_for_workspace -from engraphis.hosted_client import build_pinned_https_opener +from engraphis.cloud_session import configured as cloud_session_configured +from engraphis.hosted_client import build_pinned_https_opener, upgrade_url SNAPSHOT_SCHEMA = "engraphis-managed-snapshot/v1" MAX_RESPONSE_BYTES = 16 * 1024 * 1024 @@ -49,13 +50,26 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): def managed_compute_consent() -> bool: - """Return whether the customer explicitly enabled managed snapshot uploads. + """Return whether this installation may upload workspace content for managed work. - Entitlement is enforced by the cloud service, but it is not consent to upload local - workspace content. The public client therefore remains off unless the customer sets - ``ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1``. + Consent travels with the cloud account: connecting an installation to Engraphis Cloud + accepts the terms that cover managed compute, so a connected installation is allowed by + default and the customer is never asked to hand-edit an environment variable. + + A local installation with no cloud session is never allowed — there is no account, so + there is no agreement to rely on. + + ``ENGRAPHIS_MANAGED_COMPUTE_CONSENT`` remains an explicit override for operators who want + to force the answer either way; ``=0`` opts a connected installation back out. """ - return _truthy(os.environ.get("ENGRAPHIS_MANAGED_COMPUTE_CONSENT")) + override = os.environ.get("ENGRAPHIS_MANAGED_COMPUTE_CONSENT") + if override is not None and override.strip() != "": + return _truthy(override) + try: + return bool(cloud_session_configured(require_compute=False)) + except Exception: + # Consent must never be the reason a dashboard fails to render. + return False def _public_http_error(status: int) -> tuple[str, bool]: @@ -67,7 +81,14 @@ def _public_http_error(status: int) -> tuple[str, bool]: if status in {401, 403}: return "Engraphis Cloud authorization was rejected.", False if status == 402: - return "This hosted feature is not available for the current plan.", False + # 402 is the control plane's "no active paid entitlement", which a lapsed or + # past_due subscription reaches just as often as a genuine free plan. Name the + # billing page so a paying customer can fix it instead of reading a dead end. + return ( + "This hosted feature needs an active Engraphis Cloud subscription. Check " + "billing or upgrade at %s." % upgrade_url(), + False, + ) if status == 404: return "The hosted workspace or feature was not found.", False if status == 409: @@ -81,6 +102,43 @@ def _public_http_error(status: int) -> tuple[str, bool]: return "Engraphis Cloud rejected the request.", False +def _public_session_error(status: int) -> tuple[str, bool]: + """Map a session-acquisition failure to fixed, actionable public copy. + + ``CloudSessionError`` text is never forwarded across this boundary (it can quote local + state paths), but the bare status alone reads as an outage for every cause. A customer + whose subscription lapsed, whose session was revoked, or who is simply offline each + need a different next step, and only the transient ones are worth retrying. + """ + + if status == 401: + return ( + "Connect this installation to Engraphis Cloud to use hosted features.", + False, + ) + if status == 402: + return ( + "This hosted feature needs an active Engraphis Cloud subscription. Check " + "billing or upgrade at %s." % upgrade_url(), + False, + ) + if status == 403: + return ("Engraphis Cloud authorization was rejected.", False) + if status == 409: + return ( + "The saved cloud session is unusable; connect this installation again.", + False, + ) + if status == 429: + return ("Engraphis Cloud is temporarily busy. Try again shortly.", True) + if status >= 500: + return ( + "Engraphis Cloud is unreachable; hosted features resume once it responds.", + True, + ) + return ("The cloud session is unavailable.", False) + + def _metadata(value: Any) -> dict: if isinstance(value, dict): return value @@ -191,8 +249,9 @@ def _build_managed_snapshot_locked(service: Any, workspace: str, *, """Build the bounded client-side transport document for one local workspace. Secret-classified rows are omitted before serialization. ``consent`` allows an - already-confirmed caller to pass its decision explicitly; otherwise the environment - opt-in is required. + already-confirmed caller to pass its decision explicitly; otherwise + :func:`managed_compute_consent` decides, which allows cloud-connected installations and + denies purely local ones. """ clean_workspace = service._clean_ws(workspace) @@ -202,8 +261,8 @@ def _build_managed_snapshot_locked(service: Any, workspace: str, *, allowed = managed_compute_consent() if consent is None else bool(consent) if not allowed: raise CloudFeatureError( - "Managed compute is off. Opt in before uploading workspace content by setting " - "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1.", + "Managed compute is turned off for this installation, so no workspace content " + "was uploaded. Connect this installation to Engraphis Cloud to use it.", status=409, code="consent_required", ) @@ -297,7 +356,9 @@ def _build_managed_snapshot_locked(service: Any, workspace: str, *, class CloudFeatureClient: base_url: str organization_id: str - access_token: str + # A dataclass ``__repr__`` prints every field, so the default would put a live bearer + # token into any traceback, log line, or debugger frame that renders this client. + access_token: str = field(repr=False) timeout_seconds: float = 15.0 @classmethod @@ -306,8 +367,9 @@ def from_environment(cls, workspace_id: str) -> "CloudFeatureClient": access_token, organization_id, base_url = access_for_workspace(workspace_id) except CloudSessionError as exc: status = exc.status if 400 <= exc.status <= 599 else 503 + message, transient = _public_session_error(status) raise CloudFeatureError( - "The cloud session is unavailable.", status=status + message, status=status, transient=transient ) from exc except ValueError as exc: raise CloudFeatureError( diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index f3f97dd..b539afe 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -10,13 +10,19 @@ import os import stat import threading +import time import urllib.error import urllib.request from contextlib import contextmanager from pathlib import Path from typing import Optional, Tuple -from engraphis.hosted_client import build_pinned_https_opener, validate_cloud_base_url +from engraphis.hosted_client import ( + CloudUrlUnresolved, + build_pinned_https_opener, + upgrade_url, + validate_cloud_base_url, +) from engraphis.private_state import ( UnsafeStateFile, atomic_private_text, @@ -53,6 +59,21 @@ def _token_subject(saved: dict) -> str: return _validated_token_subject(configured or saved.get("token_subject") or "member") +def _reachable_cloud_base_url(value: str) -> str: + """Validate a cloud endpoint, keeping "offline" separate from "misconfigured". + + ``validate_cloud_base_url`` resolves the host, so a paying customer on a plane or + behind a broken resolver raises the same ``ValueError`` as a genuinely bad URL. The + caller turns that into a permanent "your configuration is invalid", which is both + wrong and unactionable. Report a resolution failure as the retryable outage it is. + """ + + try: + return validate_cloud_base_url(value) + except CloudUrlUnresolved as exc: + raise CloudSessionError("Engraphis Cloud is temporarily unreachable.") from exc + + def _session_path() -> Path: root = os.environ.get("ENGRAPHIS_STATE_DIR", "").strip() base = Path(root).expanduser() if root else Path.home() / ".engraphis" @@ -191,6 +212,131 @@ def _save(value: dict) -> None: atomic_private_text(path, json.dumps(value, sort_keys=True, separators=(",", ":"))) +#: Plans that carry no paid cloud access, used only to default an absent activity flag. +_UNPAID_PLANS = ("free", "local") +#: Bounds on the entitlement fields before they are written into the session record. The +#: record is read back under a 64 KiB private-state cap, so an unbounded provider value +#: could grow the file past that cap and make the whole session permanently unreadable. +#: These are presentation strings; anything longer is not a plan name or a feature key. +_MAX_PLAN_CHARS = 64 +_MAX_FEATURES = 32 +_MAX_FEATURE_CHARS = 64 + + +def _declared_entitlement(response: object) -> dict: + """Return the entitlement fields a control-plane response carried, or ``{}``. + + ``DeviceRegistrationResponse`` — the body both ``/internal/devices/register`` and + ``POST /v1/tokens/refresh`` answer with — carries ``plan``, ``cloud_features`` and + ``cloud_access_active``. They are read as *optional* on purpose: a control plane that + has not deployed them yet returns exactly what it always did, and this client must keep + working against it rather than requiring a newer server. An absent or unusable ``plan`` + therefore yields ``{}``, which leaves whatever the caller already knew in place. + + Nothing here is authority. The cloud authorizes every paid call regardless of what + this record says; persisting it only saves the dashboard from guessing. + """ + + if not isinstance(response, dict): + return {} + plan = response.get("plan") + if not isinstance(plan, str) or not plan.strip(): + return {} + plan = plan.strip().lower()[:_MAX_PLAN_CHARS] + active = response.get("cloud_access_active") + declared = { + "plan": plan, + # Absent is not "inactive". Defaulting a paid plan to ``False`` would re-lock a + # paying customer against a server that reports the plan but not the flag. + "cloud_access_active": ( + bool(active) if isinstance(active, bool) else plan not in _UNPAID_PLANS + ), + "entitlement_checked_at": time.time(), + } + features = response.get("cloud_features") + if isinstance(features, (list, tuple)): + declared["cloud_features"] = sorted({ + item.strip().lower()[:_MAX_FEATURE_CHARS] + for item in features if isinstance(item, str) and item.strip() + })[:_MAX_FEATURES] + return declared + + +def saved_entitlement() -> dict: + """Return the entitlement persisted from the last registration or refresh, or ``{}``. + + This is the client's primary plan answer: it rides the two calls the client already + makes, so it needs no extra request and is refreshed whenever any cloud feature is used. + Reads state only — no network, no lock, and never raises, because the dashboard's plan + badge is on the boot path. + """ + + try: + saved = _load() + declared = _declared_entitlement(saved) + if not declared: + return {} + try: + checked_at = float(saved.get("entitlement_checked_at") or 0.0) + except (TypeError, ValueError, OverflowError): + checked_at = 0.0 + declared["entitlement_checked_at"] = checked_at + declared["organization_id"] = str(saved.get("organization_id") or "") + return declared + except Exception: # noqa: BLE001 — an unreadable session is simply "nothing known yet" + return {} + + +def record_billing_denial() -> bool: + """Mark the saved entitlement inactive after an authoritative billing denial. + + A lapsed subscription answers ``402`` on token refresh. That is a *billing* answer, not + a transport failure, and the saved session outranks the entitlement cache — so leaving + ``cloud_access_active`` true kept a dashboard advertising paid features indefinitely + while every hosted call was denied. Persisting the denial is what stops the two license + surfaces disagreeing. + + The plan name is deliberately kept so the UI can still say which plan lapsed; only the + access flag and the grants are cleared. Never raises: this runs on the boot path. + + A denial is also an *authoritative entitlement read*, so it stamps + ``entitlement_checked_at`` — on the repeat denial too, which is the steady state for a + lapsed account. Leaving the old timestamp in place kept ``saved_entitlement()`` + answering with a stale ``entitlement_checked_at``, so the caller's refresh interval + never suppressed anything: every ``/api/license`` and ``/api/bootstrap``, in every + worker, spent and rotated the refresh credential again against a control plane that had + already answered 402. Advancing the clock is what bounds that. + + Returns whether this denial newly revoked access; a repeat denial returns ``False`` even + though the timestamp was rewritten, so a caller can still tell the two apart. + """ + + try: + # Under the same lock ``access_for_workspace`` rotates the credential with. This is a + # load-modify-save on the shared session file: unguarded, it could read the old + # single-use refresh credential while another worker was mid-rotation and then write + # that stale value back over the rotated one. The next hosted call would present a + # spent credential, which the control plane treats as replay and answers by revoking + # the whole credential family -- turning a lapsed subscription into a forced + # reconnect. + with _refresh_lock(): + saved = _load() + if not saved: + return False + already_denied = ( + saved.get("cloud_access_active") is False + and not saved.get("cloud_features") + ) + saved["cloud_access_active"] = False + saved["cloud_features"] = [] + saved["entitlement_checked_at"] = time.time() + # Inside the lock: a save that lands after release is exactly the race above. + _save(saved) + return not already_denied + except Exception: + return False + + def save_bootstrap(response: dict, *, control_url: str, compute_url: Optional[str] = None) -> None: """Persist the one-time bootstrap/refresh material returned by the control plane.""" @@ -213,17 +359,54 @@ def save_bootstrap(response: dict, *, control_url: str, response.get("token_subject") or "member" ), } + # Whatever entitlement the control plane volunteered, so the dashboard knows the plan + # from the first boot instead of inferring it. Absent on an older cloud; harmless. + value.update(_declared_entitlement(response)) with _refresh_lock(): _save(value) -def _post_refresh(control_url: str, refresh: str, workspace_id: str, +def _refresh_http_error(status: int) -> CloudSessionError: + """Map a control-plane status to fixed, actionable public copy. + + Only the status is used: provider bodies are untrusted and may carry credentials or + internal URLs. Billing and authorization failures must stay distinguishable from an + outage -- a lapsed subscription reported as "temporarily unavailable" makes a paying + customer retry forever instead of being sent to the one page that fixes it. + """ + + if status in {401, 403}: + return CloudSessionError( + "The cloud session expired or was revoked; connect again.", status=status + ) + if status == 402: + return CloudSessionError( + "This Engraphis Cloud subscription is not active. Update billing at %s to " + "restore Pro and Team features." % upgrade_url(), + status=402, + ) + if status == 404: + return CloudSessionError( + "This installation is no longer registered with Engraphis Cloud; " + "connect again.", + status=409, + ) + if status == 429: + return CloudSessionError( + "Engraphis Cloud is temporarily busy. Try again shortly.", status=429 + ) + return CloudSessionError("Engraphis Cloud could not refresh this session.") + + +def _post_refresh(control_url: str, refresh: str, workspace_id: Optional[str], token_subject: str) -> dict: - payload = json.dumps({ - "refresh_credential": refresh, - "workspace_id": workspace_id, - "token_subject": token_subject, - }, sort_keys=True, separators=(",", ":")).encode("utf-8") + # An org-scoped entitlement read asks for an unbound token, so it passes no workspace. + # Serializing that as ``"workspace_id": null`` invites a 4xx from any control plane that + # requires the field to be a string; omit the key instead of sending an empty value. + body = {"refresh_credential": refresh, "token_subject": token_subject} + if workspace_id: + body["workspace_id"] = workspace_id + payload = json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8") request = urllib.request.Request( control_url + "/v1/tokens/refresh", data=payload, @@ -240,16 +423,21 @@ def _post_refresh(control_url: str, refresh: str, workspace_id: str, ) as response: raw = response.read(_MAX_RESPONSE_BYTES + 1) except urllib.error.HTTPError as exc: + code = exc.code + # Draining and closing the error body can itself time out or reset. A sibling + # ``except`` clause of this ``try`` does NOT cover an exception raised inside this + # handler, so an unguarded read escapes as an unhandled traceback whenever the + # cloud is flaky -- exactly the launch-day condition this path exists for. try: exc.read(_MAX_RESPONSE_BYTES + 1) - if exc.code in {401, 403}: - raise CloudSessionError( - "The cloud session expired or was revoked; connect again.", - status=exc.code, - ) - raise CloudSessionError("Engraphis Cloud could not refresh this session.") + except (OSError, ValueError): + pass finally: - exc.close() + try: + exc.close() + except (OSError, ValueError): + pass + raise _refresh_http_error(code) except (urllib.error.URLError, TimeoutError, OSError) as exc: raise CloudSessionError("Engraphis Cloud is temporarily unreachable.") from exc if len(raw) > _MAX_RESPONSE_BYTES: @@ -286,15 +474,19 @@ def configured(*, require_compute: bool = True) -> bool: def access_for_workspace( - workspace_id: str, *, require_compute: bool = True + workspace_id: Optional[str], *, require_compute: bool = True ) -> Tuple[str, str, str]: - """Return ``(access_token, organization_id, compute_url)`` for a bound workspace.""" + """Return ``(access_token, organization_id, compute_url)`` for a bound workspace. + + ``workspace_id`` may be ``None`` for an org-scoped read that deliberately wants an + unbound token; the refresh body then omits the field rather than sending ``null``. + """ direct_token = os.environ.get("ENGRAPHIS_CLOUD_ACCESS_TOKEN", "").strip() direct_org = os.environ.get("ENGRAPHIS_CLOUD_ORGANIZATION_ID", "").strip() direct_compute = os.environ.get("ENGRAPHIS_CLOUD_COMPUTE_URL", "").strip() if direct_token and direct_org and (direct_compute or not require_compute): - compute_url = validate_cloud_base_url(direct_compute) if direct_compute else "" + compute_url = _reachable_cloud_base_url(direct_compute) if direct_compute else "" return direct_token, direct_org, compute_url # Do not create the owner-only state directory merely to report an unconnected @@ -323,8 +515,8 @@ def access_for_workspace( raise CloudSessionError( "Connect this installation to Engraphis Cloud first.", status=401 ) - control = validate_cloud_base_url(control) - compute = validate_cloud_base_url(compute) if compute else "" + control = _reachable_cloud_base_url(control) + compute = _reachable_cloud_base_url(compute) if compute else "" token_subject = _token_subject(saved) body = _post_refresh(control, refresh, workspace_id, token_subject) access = str(body.get("access_token") or "").strip() @@ -347,5 +539,21 @@ def access_for_workspace( "refresh_expires_at": str(body.get("refresh_expires_at") or ""), "token_subject": response_subject, }) + # The refresh response carries the same entitlement fields as registration, so the + # plan re-confirms itself on every token rotation. An older cloud omits them and + # the previously persisted answer (if any) is left untouched. + declared = _declared_entitlement(body) + if declared and "cloud_features" not in declared: + # A *plan change* may never inherit the previous plan's grants. + # ``_declared_entitlement`` omits ``cloud_features`` whenever the body carried + # no feature list, so merging it onto the saved record left a Team feature list + # alive underneath a downgraded Pro plan and kept the Team tab unlocked + # indefinitely. Dropping the stale key hands the answer back to this client's + # own plan table, which is right for the plan the cloud just named. A refresh + # that re-confirms the *same* plan still keeps the richer saved list. + previous = str(saved.get("plan") or "").strip().lower() + if previous != declared["plan"]: + updated.pop("cloud_features", None) + updated.update(declared) _save(updated) return access, organization_id, compute diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index d633a0c..560ff47 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -11,6 +11,7 @@ import os import socket import sys +import time import urllib.request from typing import Optional from urllib.parse import urlsplit, urlunsplit @@ -20,6 +21,14 @@ TRIAL_SECONDS = 3 * 24 * 60 * 60 MAX_LOCAL_WRITE_GRACE_SECONDS = 24 * 60 * 60 +# A credential-bearing cloud connection must never inherit "block forever". urllib hands +# ``socket._GLOBAL_DEFAULT_TIMEOUT`` to the connection whenever a caller omits ``timeout=``, +# so substitute a bounded default rather than letting a customer's agent hang on launch day. +DEFAULT_CONNECT_TIMEOUT_SECONDS = 15.0 +# One vetted address must not be able to spend the whole budget and leave nothing for the +# rest, but every attempt still gets a usable floor so a slow first hop is not aborted. +MIN_ATTEMPT_TIMEOUT_SECONDS = 0.5 + # The hosted dashboard and the commercial account portal are separate surfaces. # Upgrade/connect actions must land on the authenticated control-plane portal; the # dashboard host does not serve its own ``/account`` route. @@ -36,6 +45,16 @@ } +class CloudUrlUnresolved(ValueError): + """The cloud endpoint could not be resolved right now. + + This is a *reachability* failure, not a misconfiguration: an offline laptop and a + broken resolver both land here. It stays a ``ValueError`` so existing callers keep + working, but lets a caller degrade an offline paying customer to a retryable + "temporarily unreachable" instead of a permanent "your configuration is invalid". + """ + + class HostedFeatureError(RuntimeError): """A hosted feature is unavailable to this local client. @@ -99,7 +118,7 @@ def _validated_addresses(host: str) -> list[str]: host, None, socket.AF_UNSPEC, socket.SOCK_STREAM ) except (socket.gaierror, OSError): - raise ValueError("cloud service URL could not be resolved") from None + raise CloudUrlUnresolved("cloud service URL could not be resolved") from None addresses = [] loopback_name = _is_loopback_host(host) @@ -115,7 +134,7 @@ def _validated_addresses(host: str) -> list[str]: raise ValueError("cloud service URL must not target private/reserved IP ranges") addresses.append(str(address)) if not addresses: - raise ValueError("cloud service URL could not be resolved") + raise CloudUrlUnresolved("cloud service URL could not be resolved") return list(dict.fromkeys(addresses)) @@ -124,9 +143,29 @@ class PinnedHTTPSConnection(http.client.HTTPSConnection): def __init__(self, host, *args, **kwargs): super().__init__(host, *args, **kwargs) + # ``http.client`` stores ``socket._GLOBAL_DEFAULT_TIMEOUT`` (or ``None``) when no + # timeout was supplied, and both mean "block forever" at the socket layer. Neither + # is acceptable for a credential-bearing hosted call, so pin a bounded default. + if not isinstance(self.timeout, (int, float)) or isinstance(self.timeout, bool): + self.timeout = DEFAULT_CONNECT_TIMEOUT_SECONDS self._tls_server_hostname = self.host self._tunnel_targets = [] + def _connect_deadline(self): + """Return the monotonic instant by which every dial attempt must be finished.""" + + return time.monotonic() + max(float(self.timeout), 0.0) + + def _attempt_timeout(self, deadline): + """Share one caller-supplied budget across every vetted address. + + Handing each address the full timeout turns a 10s budget into N x 10s for a + multi-homed endpoint that blackholes traffic -- and ``cloud_session`` dials while + holding an exclusive cross-process refresh lock, so every other worker waits too. + """ + + return max(deadline - time.monotonic(), MIN_ATTEMPT_TIMEOUT_SECONDS) + def set_tunnel(self, host, port=None, headers=None): # Make a configured proxy CONNECT to the vetted numeric target. TLS still # authenticates the original hostname after the tunnel is established. @@ -149,6 +188,7 @@ def connect(self): def _connect_directly(self): last_error = None + deadline = self._connect_deadline() for target in _validated_addresses(self.host): # Overriding connect() skips the sys.audit call in HTTPConnection.connect, # which would make every hosted request invisible to a host process auditing @@ -157,10 +197,13 @@ def _connect_directly(self): sys.audit("http.client.connect", self, target, self.port) try: return self._create_connection( - (target, self.port), self.timeout, self.source_address + (target, self.port), self._attempt_timeout(deadline), + self.source_address, ) except OSError as exc: last_error = exc + if time.monotonic() >= deadline: + break if last_error is None: raise OSError("cloud service URL has no connectable address") raise last_error @@ -182,6 +225,7 @@ def _connect_through_proxy(self): # to the rest exactly like the direct path does. A failed CONNECT leaves the # proxy socket unusable, so each attempt redials the proxy. last_error = None + deadline = self._connect_deadline() base_headers = dict(self._tunnel_headers) for target in self._tunnel_targets or [self._tunnel_host]: # Python 3.9 and 3.10 serialize the CONNECT request target verbatim, so a @@ -202,7 +246,8 @@ def _connect_through_proxy(self): sys.audit("http.client.connect", self, self.host, self.port) try: self.sock = self._create_connection( - (self.host, self.port), self.timeout, self.source_address + (self.host, self.port), self._attempt_timeout(deadline), + self.source_address, ) self._tunnel() return @@ -213,6 +258,8 @@ def _connect_through_proxy(self): if self.sock is not None: self.sock.close() self.sock = None + if time.monotonic() >= deadline: + break if last_error is None: raise OSError("cloud service URL has no connectable address") raise last_error diff --git a/engraphis/routes/memory.py b/engraphis/routes/memory.py index d3a0a0a..986b94c 100644 --- a/engraphis/routes/memory.py +++ b/engraphis/routes/memory.py @@ -707,10 +707,26 @@ async def memory_analytics(): @router.get("/license") async def get_license(): - """GET /memory/license — local core metadata; hosted plans live in Cloud.""" + """GET /memory/license — the same hosted-plan answer ``/api/license`` reports. + + This surface used to hardcode ``plan: "local", features: []``. That describes the + *local core*, not the customer: a paying Team installation was told it was on the free + plan here while ``/api/license`` reported Team, so two endpoints of one product + disagreed about what had been bought. The response keys are unchanged — this stays the + legacy ``{"data": ...}`` SDK shape with the same trial and grace disclosure — and only + the two fields that were lying now tell the truth. + + Resolution is local and non-blocking (see ``v2_api._plan_entitlement``): no network + call happens on this path, and an unconnected installation still reports ``local`` + with no features, exactly as before. The plan stays presentation only; Engraphis Cloud + remains the sole authority for every paid operation. + """ + from engraphis.routes.v2_api import hosted_plan_summary + + summary = hosted_plan_summary() return _ok({ - "plan": "local", - "features": [], + "plan": summary["plan"], + "features": summary["features"], "cloud_managed": True, "trial_seconds": 259_200, "grace_seconds": 86_400, diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index 7adf73e..baf2b0c 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -13,8 +13,12 @@ import os import threading import time +import urllib.error +import urllib.request import weakref +from pathlib import Path from typing import Optional +from urllib.parse import quote from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile from pydantic import BaseModel, Field @@ -138,6 +142,41 @@ def _run(fn, *a, **k): raise HTTPException(status_code=500, detail={"error": "internal server error"}) +#: What the dashboard shows when a managed failure carries no usable public copy. +_MANAGED_ERROR_FALLBACK = "managed cloud operation failed" +#: Hard ceiling on the forwarded copy. Every message this boundary forwards is fixed, +#: status-keyed public text (see ``_managed_error_message``), so nothing legitimate comes +#: close; a message that does is by definition not the fixed copy and is dropped. +_MANAGED_ERROR_MAX_CHARS = 300 + + +def _managed_error_message(exc) -> str: + """Return the public copy a ``CloudFeatureError`` carries, or the generic fallback. + + ``CloudFeatureError`` is the *already redacted* form: every raise site in + ``cloud_features`` builds its message from fixed copy keyed on a status + (``_public_http_error`` / ``_public_session_error``) or from a local literal. Provider + bodies, ``CloudSessionError`` text and local state paths are all dropped before the + exception is constructed, which is the whole point of that type. + + Flattening it again here cost the customer the only actionable part: a 429 or a 5xx + ("temporarily busy", "temporarily unavailable" — retry) and a 409 ("could not accept the + current workspace state" — do not retry, fix the session) all rendered as one fixed + string, so the dashboard's error branch could not tell an outage from a conflict. + + The bound below is a boundary check, not the redaction: it keeps an unexpected message + (a future raise site that interpolates something, or a subclass raised elsewhere) from + becoming an unbounded, control-character-carrying string in a JSON error body. + """ + + message = " ".join(str(exc).split()) + if not message or len(message) > _MANAGED_ERROR_MAX_CHARS: + return _MANAGED_ERROR_FALLBACK + if any(ord(char) < 0x20 or ord(char) == 0x7F for char in message): + return _MANAGED_ERROR_FALLBACK + return message + + def _managed_call(fn, *args, **kwargs): """Map the public cloud-client protocol onto structured dashboard errors.""" from engraphis.cloud_features import CloudFeatureError @@ -147,7 +186,7 @@ def _managed_call(fn, *args, **kwargs): except CloudFeatureError as exc: logger.warning("managed cloud operation failed (%s, status=%s, transient=%s)", type(exc).__name__, exc.status, exc.transient) - detail = {"error": "managed cloud operation failed", "managed_cloud": True, + detail = {"error": _managed_error_message(exc), "managed_cloud": True, "transient": exc.transient} if exc.code == "consent_required": detail["code"] = exc.code @@ -1686,17 +1725,682 @@ def code_export(workspace: str, repo: str): # ── license ─────────────────────────────────────────────────────────────────── +# Mirror of the private control plane's plan→feature table (read-only reference: +# engraphis-cloud/engraphis_cloud/entitlements.py ``PLAN_FEATURES``). Plans are lowercase +# and only ``pro``/``team`` are paid; any other value — unknown, empty, or mis-cased — +# resolves to no features, exactly as the server treats it. +# +# The server's own keys are {analytics, automation, export, sync, team}. This client's +# commercial manifest additionally names Auto Consolidation and Auto Dreaming, which the +# server grants under ``automation``. They are expanded here so the dashboard can never +# draw a lock on a capability the customer's plan already includes. +_AUTOMATION_FEATURES = ("automation", "consolidation", "dreaming") +_PRO_FEATURES = ("analytics", "export", "sync") + _AUTOMATION_FEATURES +_PLAN_FEATURES = { + "free": (), + "local": (), + "pro": _PRO_FEATURES, + "team": _PRO_FEATURES + ("team",), +} + +# Labels for the dashboard's entitlement list; it renders one row per key and ticks the +# ones ``features`` contains. Naming follows the customer-facing vocabulary in +# .env.example and the commercial manifest. +_FEATURE_LABELS = { + "analytics": "Analytics", + "automation": "Automation", + "consolidation": "Auto Consolidation", + "dreaming": "Auto Dreaming", + "export": "Compliance export", + "sync": "Cloud Sync", + "team": "Team administration", +} + + +def entitled_features(plan: str) -> list: + """Return the feature keys a hosted plan grants. + + Presentation only. This decides which lock badges the dashboard draws, never whether + an operation is permitted: every paid operation is still authorized by Engraphis + Cloud and gated on its response status. + """ + + return sorted(_PLAN_FEATURES.get(str(plan or "").strip().lower(), ())) + + +# ── authoritative plan resolution ───────────────────────────────────────────────────── +# The control plane knows the plan; this client has to be told. Inferring "connected ⇒ pro" +# showed a paying TEAM customer a PRO badge and a lock on the Team administration they were +# paying for, so the plan is now read rather than guessed. +# +# ``DeviceRegistrationResponse`` — the body both ``/internal/devices/register`` and +# ``POST /v1/tokens/refresh`` answer with — carries ``plan``, ``cloud_features`` and +# ``cloud_access_active``. Those are the calls this client already makes, so the plan +# arrives with credentials it was going to mint anyway: ``cloud_session`` persists the +# fields on registration and re-confirms them on every token rotation, and +# ``_session_entitlement`` reads them back. No extra request, no separate cache, correct on +# the first boot after onboarding, and correct offline because the session record outlives +# the connection. +# +# Those fields are OPTIONAL here. A control plane that has not deployed them yet simply +# omits them, and the client falls back to ``GET /v1/entitlements/{organization_id}``, +# which returns the same ``plan`` and ``cloud_features``. Every access token this client +# can mint already carries the ``entitlement:read`` scope that route requires, and the +# route is declared ``require_workspace_binding=False``, so no workspace context is needed. +# That fallback answer is cached in its own file and is *strictly* outranked by the session +# record above, so the two persisted answers can never disagree silently. +# +# Neither answer is ever fetched inline. ``/api/license`` is on the ``/api/bootstrap`` boot +# path, and boot must not make a blocking, credential-bearing network call: a read answers +# from persisted state immediately and, when that state is missing or stale, refreshes it on +# a daemon thread so the next read is right. That background refresh is also what corrects a +# plan the customer changed in the account portal — a Pro→Team upgrade unlocks a tab the +# customer cannot click *until* it is unlocked, so nothing else would ever ask. Every +# failure — offline, lapsed, revoked, unreadable state directory, malformed body — degrades +# to the last known plan and finally to the inference below. Nothing here can raise into, or +# delay, ``/api/bootstrap``. + +#: Cache envelope version; an unrecognised value is discarded rather than trusted. +_ENTITLEMENT_CACHE_SCHEMA = "engraphis-cloud-entitlement/v1" +#: How stale the cached entitlement may get before a read schedules a background refresh. +_ENTITLEMENT_REFRESH_SECONDS = 15 * 60 +#: Bounded budget for the background fetch. It never runs on a request thread, but an +#: unbounded dial would still pin a daemon thread and a rotated credential indefinitely. +_ENTITLEMENT_TIMEOUT_SECONDS = 8.0 +_ENTITLEMENT_MAX_RESPONSE_BYTES = 64 * 1024 +_ENTITLEMENT_REFRESH_LOCK = threading.Lock() +_entitlement_refreshing = False +#: Same opt-out vocabulary as ``ENGRAPHIS_UPDATE_CHECK`` (see engraphis/update_check.py). +_FALSY_SETTINGS = {"0", "false", "no", "off", "disable", "disabled"} + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Block redirects on the credential-bearing entitlement read. + + The request carries a live bearer token, so a crafted 30x must not be able to replay + it at another host. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def _entitlement_refresh_enabled() -> bool: + """Background entitlement refresh is on by default; any falsy value disables it.""" + + value = os.environ.get("ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH", "1").strip().lower() + return value not in _FALSY_SETTINGS + + +def _entitlement_cache_path() -> Optional["Path"]: + """Return the cache leaf beside the cloud session, or ``None`` if unlocatable.""" + + try: + root = os.environ.get("ENGRAPHIS_STATE_DIR", "").strip() + base = Path(root).expanduser() if root else Path.home() / ".engraphis" + except (OSError, RuntimeError): # no resolvable home directory + return None + return base / "cloud_entitlement.json" + + +def _cloud_control_url() -> str: + """Return the configured control-plane base URL, or ``""``. Never raises. + + ``cloud_session`` exposes no public accessor for the saved endpoint, so the saved + record is read through its loader defensively: a rename degrades to "not configured", + which merely skips the refresh rather than breaking the boot path. + """ + + value = os.environ.get("ENGRAPHIS_CLOUD_CONTROL_URL", "").strip() + if value: + return value.rstrip("/") + try: + from engraphis import cloud_session + loader = getattr(cloud_session, "_load", None) + saved = loader() if loader is not None else None + except Exception: # noqa: BLE001 - an unreadable session is simply "not configured" + return "" + if not isinstance(saved, dict): + return "" + return str(saved.get("control_url") or "").strip().rstrip("/") + + +def _configured_organization_id() -> str: + """Return the organization this installation is bound to *right now*, or ``""``. + + A pinned ``ENGRAPHIS_CLOUD_ORGANIZATION_ID`` wins, exactly as it does in + ``cloud_session.access_for_workspace``; otherwise the saved session names it. Never + raises: an unreadable or absent session simply means "unknown", and an unknown + organization leaves the persisted answers in place rather than blanking a paying + customer's badge. + """ + + pinned = os.environ.get("ENGRAPHIS_CLOUD_ORGANIZATION_ID", "").strip() + if pinned: + return pinned + try: + from engraphis import cloud_session + loader = getattr(cloud_session, "_load", None) + saved = loader() if loader is not None else None + except Exception: # noqa: BLE001 - an unreadable session is simply "not configured" + return "" + if not isinstance(saved, dict): + return "" + return str(saved.get("organization_id") or "").strip() + + +def _normalized_plan(value: object) -> str: + """Map any control-plane plan name onto this client's presentation vocabulary.""" + + plan = str(value or "").strip().lower() + return plan if plan in ("pro", "team") else "local" + + +def _normalized_features(values: object, plan: str) -> list: + """Keep the server's own grant, expanded to the names this dashboard renders. + + The server folds Auto Consolidation and Auto Dreaming into ``automation``; the license + panel lists them separately, so echoing the server's keys verbatim would leave a Team + customer looking at two unticked rows for capabilities they are paying for. Anything + the dashboard cannot render is dropped, so ``features`` stays a subset of + ``known_features`` even if a future server release adds a key this build predates. + """ + + if not isinstance(values, (list, tuple)): + return entitled_features(plan) + granted = {str(item).strip().lower() for item in values if isinstance(item, str)} + if "automation" in granted: + granted.update(_AUTOMATION_FEATURES) + return sorted(granted & set(_FEATURE_LABELS)) + + +def _session_entitlement() -> dict: + """Return the entitlement the control plane put on this client's own session. + + Shaped exactly like ``_read_entitlement_cache`` so both persisted answers feed the + resolver identically and only their precedence differs. Reads state only — no network — + and never raises: this is on the ``/api/bootstrap`` boot path. + """ + + try: + from engraphis import cloud_session + reader = getattr(cloud_session, "saved_entitlement", None) + declared = reader() if reader is not None else None + if not isinstance(declared, dict) or not declared: + return {} + # A deployment pinned to ``ENGRAPHIS_CLOUD_ORGANIZATION_ID`` may be pointed at a + # different organization than the saved session was registered for. Refuse to + # relabel one customer's plan with another's, exactly as the entitlements read + # refuses a mis-routed answer. + pinned = os.environ.get("ENGRAPHIS_CLOUD_ORGANIZATION_ID", "").strip() + if pinned and pinned != str(declared.get("organization_id") or ""): + return {} + plan = _normalized_plan(declared.get("plan")) + active = bool(declared.get("cloud_access_active")) + return { + "plan": plan, + # The server empties ``cloud_features`` the moment paid access stops being + # live; mirror that, exactly as the entitlements route's answer does below. + # An older field-less body leaves ``cloud_features`` absent, and this client's + # own plan table fills it in. + "features": _normalized_features(declared.get("cloud_features"), plan) + if active else [], + "cloud_access_active": active, + "organization_id": str(declared.get("organization_id") or ""), + "fetched_at": float(declared.get("entitlement_checked_at") or 0.0), + } + except Exception: # noqa: BLE001 - a badge must never break /bootstrap + return {} + + +def _read_entitlement_cache() -> dict: + """Return the last cached ``GET /v1/entitlements`` answer, or ``{}``. Never raises. + + Secondary to ``_session_entitlement``: this file exists only for a control plane that + does not yet return the entitlement on registration and refresh. + """ + + path = _entitlement_cache_path() + if path is None: + return {} + try: + from engraphis.private_state import read_private_text + raw = read_private_text( + path, max_bytes=_ENTITLEMENT_MAX_RESPONSE_BYTES, allow_missing=True + ) + except Exception: # noqa: BLE001 - an unreadable cache is just "nothing known yet" + return {} + if not raw: + return {} + try: + value = json.loads(raw) + except (ValueError, RecursionError): + return {} + if not isinstance(value, dict) or value.get("schema") != _ENTITLEMENT_CACHE_SCHEMA: + return {} + # Validate rather than coerce the plan: a corrupt value must be *discarded* so the + # caller falls through to its own inference. Coercing it would quietly downgrade a + # connected paying customer to the free local core on a damaged file. + stored_plan = value.get("plan") + if not isinstance(stored_plan, str) or stored_plan.strip().lower() not in ( + "pro", "team", "local", "free" + ): + return {} + try: + fetched_at = float(value.get("fetched_at") or 0.0) + except (TypeError, ValueError, OverflowError): + fetched_at = 0.0 + # Whose plan is this? The cache lives in the state directory, which outlives a + # reconnect and is shared by every organization this installation is ever pointed at. + # Serving it unchecked kept the PREVIOUS organization's Pro/Team badge and feature + # grants on screen for a whole refresh interval — and indefinitely with + # ``ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH=0``. Fail closed and ignore a cache that names + # anyone else (or names nobody), exactly as ``_session_entitlement`` refuses a session + # plan recorded for a different organization. The refresh scheduled by the caller then + # writes the right one. + # An *unknown* current organization is not permission to trust an organization-scoped + # cache either. A fresh bootstrap has only a refresh credential and a control URL until + # the first refresh names the organization, so a reused state directory would otherwise + # serve the previous organization's paid plan for that whole window -- indefinitely with + # ``ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH=0``. Require a match, not merely the absence of + # a mismatch; the refresh the caller schedules writes the right answer. + organization_id = str(value.get("organization_id") or "") + current = _configured_organization_id() + if not current or organization_id != current: + return {} + plan = _normalized_plan(stored_plan) + return { + "plan": plan, + "features": _normalized_features(value.get("features"), plan), + "cloud_access_active": bool(value.get("cloud_access_active")), + "organization_id": organization_id, + "fetched_at": fetched_at, + } + + +def _write_entitlement_cache(entitlement: dict) -> None: + """Persist the authoritative entitlement so the next boot starts correct. + + Written through the owner-only atomic helper used for the cloud session itself: the + plan is not a secret, but it lives in the same private state directory and a partial + file must never be readable. Never raises — a read-only state directory costs + freshness across restarts, not the dashboard. + """ + + path = _entitlement_cache_path() + if path is None: + return + try: + from engraphis.private_state import atomic_private_text + atomic_private_text(path, json.dumps({ + "schema": _ENTITLEMENT_CACHE_SCHEMA, + "plan": entitlement["plan"], + "features": list(entitlement["features"]), + "cloud_access_active": bool(entitlement["cloud_access_active"]), + "organization_id": str(entitlement.get("organization_id") or ""), + "fetched_at": float(entitlement.get("fetched_at") or time.time()), + }, sort_keys=True, separators=(",", ":"))) + except Exception: # noqa: BLE001 - losing the cache write must not surface anywhere + logger.debug("entitlement cache write skipped") + + +def _deny_entitlement_cache() -> None: + """Clear this cache's grants after an authoritative billing denial. + + ``cloud_session.record_billing_denial`` settles the session record, but an older + control plane omits the entitlement fields from it entirely — that session stays + planless, ``saved_entitlement()`` keeps answering ``{}``, and ``_plan_entitlement`` + falls through to *this* cache. Left alone it went on advertising paid features while + every cloud operation was denied, which is the same disagreement one layer down. + + The plan name is preserved so the UI can still say which plan lapsed; only the access + flag and the grants are cleared. Never raises: this runs on the refresh thread. + + ``fetched_at`` advances on *every* denial, including the repeat denial that finds the + cache already settled. That case used to return without writing anything, which left + ``_plan_entitlement`` serving a stale ``fetched_at`` and + ``_refresh_entitlement_in_background`` re-scheduling a token refresh on every request + against an account the control plane had already answered 402 for. A denial is an + authoritative read; it has to advance the clock exactly as + ``cloud_session.record_billing_denial`` now does one layer up. + """ + + try: + cached = _read_entitlement_cache() + if not cached: + return + denied = dict(cached) + denied["cloud_access_active"] = False + denied["features"] = [] + denied["fetched_at"] = time.time() + _write_entitlement_cache(denied) + except Exception: # noqa: BLE001 - a denial we cannot persist is still a denial + logger.debug("entitlement cache denial skipped") + + +def _fetch_authoritative_entitlement() -> Optional[dict]: + """Re-read the plan from the control plane. Returns ``None`` when nothing was cached. + + Two steps, in order: + + 1. mint a token. ``access_for_workspace`` performs the ordinary token refresh, and a + control plane that returns the entitlement on ``DeviceRegistrationResponse`` has + *already answered* by the time it comes back — ``cloud_session`` persisted the plan + as a side effect of the call. That is the whole refresh; there is nothing to cache + separately, so this returns ``None`` and the session record stays the one source. + 2. only if that produced no plan — an older control plane — fall back to + ``GET /v1/entitlements/{organization_id}`` and cache what it says. + + Runs only on the background refresh thread; never on a request thread. No workspace + binding is requested, so the issued token carries only the organization-scoped read + scopes the entitlements route needs. + """ + + control = _cloud_control_url() + if not control: + return None + try: + from engraphis.cloud_session import access_for_workspace + from engraphis.hosted_client import ( + build_pinned_https_opener, + validate_cloud_base_url, + ) + # Vet the endpoint before minting a credential for it. ``access_for_workspace`` + # validates the control URL on the saved-session path, but short-circuits without + # validating it when a pinned ``ENGRAPHIS_CLOUD_ACCESS_TOKEN`` is configured — and + # the pinned opener only replaces urllib's *HTTPS* handler, so an ``http://`` + # value would put a live bearer token on the wire in cleartext to an unvetted, + # possibly private-range host. Validation also rejects embedded credentials and + # re-resolves the host, closing the same DNS-rebinding window every other + # outbound client in this package closes. + control = validate_cloud_base_url(control) + access_token, organization_id, _ = access_for_workspace( + None, require_compute=False + ) + except Exception as exc: # noqa: BLE001 - offline, lapsed, revoked, invalid + # A 402 is the control plane's authoritative answer that billing lapsed -- not a + # transport hiccup. The saved session outranks this cache, so treating it like any + # other failure left ``cloud_access_active`` true and kept paid features on the + # dashboard indefinitely while every hosted call was denied. Persist the denial so + # the two license surfaces cannot disagree; everything else is still "not now". + if getattr(exc, "status", None) == 402: + try: + from engraphis.cloud_session import record_billing_denial + + record_billing_denial() + except Exception: # noqa: BLE001 - a denial we cannot persist is still a denial + pass + # Settle both layers. Against an older control plane the session carries no + # entitlement fields at all, so the session write above leaves it planless and + # the resolver falls through to the compatibility cache -- which would keep + # advertising the paid plan on its own. + _deny_entitlement_cache() + return None + if not access_token or not organization_id: + return None + # Step 1 landed: the refresh above persisted plan/cloud_features/cloud_access_active, + # which outranks this cache anyway. A second round trip would buy nothing. + if _session_entitlement(): + return None + request = urllib.request.Request( + control + "/v1/entitlements/" + quote(organization_id, safe=""), + headers={ + "Accept": "application/json", + "Authorization": "Bearer " + access_token, + "User-Agent": "Engraphis/1.0 (+https://engraphis.com)", + }, + method="GET", + ) + try: + with build_pinned_https_opener(_NoRedirect()).open( + request, timeout=_ENTITLEMENT_TIMEOUT_SECONDS + ) as response: + raw = response.read(_ENTITLEMENT_MAX_RESPONSE_BYTES + 1) + except urllib.error.HTTPError as exc: + # Draining and closing the error body can itself time out or reset, and a sibling + # ``except`` clause of this ``try`` does not cover an exception raised inside this + # handler. Leaving it unguarded is how a flaky cloud becomes an unhandled + # traceback on a background thread. + try: + exc.read(_ENTITLEMENT_MAX_RESPONSE_BYTES + 1) + except (OSError, ValueError): + pass + finally: + try: + exc.close() + except (OSError, ValueError): + pass + return None + except Exception: # noqa: BLE001 - transport, TLS, and URL failures are all "not now" + return None + if len(raw) > _ENTITLEMENT_MAX_RESPONSE_BYTES: + return None + try: + body = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError, RecursionError): + return None + if not isinstance(body, dict): + return None + # Refuse an answer about a different organization than this session is bound to, so a + # mis-routed or replayed response can never relabel the customer's plan. + if str(body.get("organization_id") or "") != organization_id: + return None + # ``plan`` and ``cloud_access_active`` are required fields of the server's + # ``EntitlementDTO``. Demand both rather than defaulting them: a truncated or partial + # body would otherwise coerce to "free, no access" and cache a paying customer as the + # free local core. Absent means "no answer", which keeps the previous one. + declared_plan = body.get("plan") + active = body.get("cloud_access_active") + if not isinstance(declared_plan, str) or not declared_plan.strip(): + return None + if not isinstance(active, bool): + return None + plan = _normalized_plan(declared_plan) + return { + "plan": plan, + # The server empties ``cloud_features`` the moment paid access stops being live. + # Mirroring that re-draws the locks on a lapsed subscription while the badge keeps + # naming the plan the customer is on, so the dashboard offers the account portal + # rather than a trial they have already used. + "features": _normalized_features(body.get("cloud_features"), plan) if active else [], + "cloud_access_active": active, + "organization_id": organization_id, + "fetched_at": time.time(), + } + + +def _refresh_entitlement_in_background(known: dict) -> None: + """Re-confirm the plan off the request thread. Never blocks, never raises. + + Secondary by design. The plan normally arrives on registration and on every token + refresh the client already makes, so this exists for the one case nothing else covers: + a plan changed in the account portal, on an installation whose newly bought tab is + locked and therefore cannot be clicked to trigger a refresh of its own. + + ``known`` is the answer currently being served (session record, else cached + entitlement); its age decides whether to re-confirm. At most one refresh is in flight + per process, and no thread is started at all unless a control-plane endpoint is actually + configured, so an unconnected (or offline) process does no background work whatsoever. + """ + + global _entitlement_refreshing + if not _entitlement_refresh_enabled(): + return + age = time.time() - float(known.get("fetched_at") or 0.0) + # A negative age means the clock moved backwards; re-check rather than trust it. + if known and 0.0 <= age < _ENTITLEMENT_REFRESH_SECONDS: + return + if not _cloud_control_url(): + return + with _ENTITLEMENT_REFRESH_LOCK: + if _entitlement_refreshing: + return + _entitlement_refreshing = True + + def _run() -> None: + global _entitlement_refreshing + try: + fetched = _fetch_authoritative_entitlement() + if fetched is not None: + _write_entitlement_cache(fetched) + except Exception: # noqa: BLE001 - a daemon thread must never surface anything + pass + finally: + with _ENTITLEMENT_REFRESH_LOCK: + _entitlement_refreshing = False + + try: + threading.Thread( + target=_run, name="engraphis-entitlement", daemon=True + ).start() + except Exception: # noqa: BLE001 - a thread-starved host keeps the cached answer + with _ENTITLEMENT_REFRESH_LOCK: + _entitlement_refreshing = False + + +def _plan_entitlement() -> dict: + """Resolve the customer's plan without ever blocking on the network. + + Order of precedence. One source wins outright at each step, so the persisted answers + can never disagree silently: + + 1. ``ENGRAPHIS_CLOUD_PLAN`` — an explicit operator override, kept as an escape hatch + for air-gapped and pinned-token deployments; + 2. the entitlement the control plane returned on this installation's own registration + or token refresh (``DeviceRegistrationResponse.plan`` / ``.cloud_features`` / + ``.cloud_access_active``), persisted by ``cloud_session``. Primary: it rides calls + the client already makes, costs no extra request, is re-confirmed on every token + rotation, and outlives the connection so it is still right offline; + 3. the cached ``GET /v1/entitlements/{org}`` answer — the compatibility path for a + control plane that does not return (2) yet, warmed on a background thread; + 4. inference — ``pro`` for a connected installation neither (2) nor (3) has answered + for, the smallest paid plan, so a paying customer is never shown the free local + core; ``local`` for an unconnected installation. + + One deployment cannot reach (2) or (3) at all: a pinned ``ENGRAPHIS_CLOUD_ACCESS_TOKEN`` + mints no refresh and so never re-confirms anything. That is precisely what (1) is + documented for in ``.env.example``. + + Never raises: this feeds ``/api/license`` and therefore ``/api/bootstrap``. + """ + + declared = os.environ.get("ENGRAPHIS_CLOUD_PLAN", "").strip().lower() + if declared in ("pro", "team", "free", "local"): + plan = declared if declared in ("pro", "team") else "local" + return {"plan": plan, "features": entitled_features(plan), + "source": "environment", "cloud_access_active": plan != "local", + "checked_at": 0.0} + try: + from engraphis import cloud_session + connected = cloud_session.configured(require_compute=False) + except Exception: # noqa: BLE001 - a badge must never break /bootstrap + connected = False + if not connected: + return {"plan": "local", "features": [], "source": "local", + "cloud_access_active": False, "checked_at": 0.0} + session = _session_entitlement() + if session: + _refresh_entitlement_in_background(session) + return {"plan": session["plan"], "features": list(session["features"]), + "source": "session", "cloud_access_active": session["cloud_access_active"], + "checked_at": session["fetched_at"]} + cached = _read_entitlement_cache() + _refresh_entitlement_in_background(cached) + if cached: + return {"plan": cached["plan"], "features": list(cached["features"]), + "source": "cloud", "cloud_access_active": cached["cloud_access_active"], + "checked_at": cached["fetched_at"]} + # Connected, but the control plane has never answered (first boot after onboarding, or + # offline since). ``pro`` unlocks what every paid plan includes and leaves only the Team + # upsell showing; the refresh scheduled above corrects it. + return {"plan": "pro", "features": entitled_features("pro"), "source": "connected", + "cloud_access_active": True, "checked_at": 0.0} + + +def _hosted_plan() -> str: + """Return the hosted plan this installation is on, else ``local``. + + The public client still holds no entitlement authority: this reports what the control + plane last said (or, failing that, how the installation was provisioned). Forging any + of its inputs grants nothing — the server authorizes every paid call. + """ + + return _plan_entitlement()["plan"] + + +#: The unpatched ``_hosted_plan``, captured so ``hosted_plan_summary`` can tell "nobody has +#: replaced the seam" from "somebody has". Calling ``_hosted_plan()`` unconditionally +#: resolved the entitlement a *second* time — two more state-file reads, and a second +#: chance to schedule a background refresh — on every ``/api/license`` and therefore every +#: ``/api/bootstrap`` request, for an answer already sitting in ``entitlement["plan"]``. +_DEFAULT_HOSTED_PLAN = _hosted_plan + + +def hosted_plan_summary() -> dict: + """Return the one plan answer every license surface reports. + + ``/api/license`` and the legacy ``/memory/license`` both render this, so the two can + never again disagree about what the customer has bought. + """ + + entitlement = _plan_entitlement() + # ``_hosted_plan`` is the single override seam; when a caller replaces it, its answer + # wins and the feature list falls back to this client's plan table. Left alone it + # returns exactly ``_plan_entitlement()["plan"]``, so it is consulted only when it has + # actually been replaced — calling it unconditionally repeated the resolution above + # verbatim, state-file reads and refresh scheduling included. + if _hosted_plan is not _DEFAULT_HOSTED_PLAN: + plan = _hosted_plan() + if plan != entitlement["plan"]: + entitlement = {"plan": plan, "features": entitled_features(plan), + "source": "override", "cloud_access_active": plan != "local", + "checked_at": 0.0} + return { + "plan": entitlement["plan"], + "features": list(entitlement["features"]), + "plan_source": entitlement["source"], + "cloud_access_active": entitlement["cloud_access_active"], + "plan_checked_at": entitlement["checked_at"], + } + + @router.get("/license") def get_license(): - """The local core has no Pro/Team authority; hosted entitlements live in Cloud.""" + """Hosted plan presentation for the dashboard; Cloud remains the authority. + + ``features`` drives the dashboard's lock badges. Hardcoding it empty drew a "PRO" or + "TEAM" lock over Analytics, Automation, and Team for customers who had already paid + for them, and inferring the plan drew a "PRO" badge plus a Team lock over a paying + Team customer. Both now follow the control plane's own answer once it has been read. + """ + summary = hosted_plan_summary() + plan = summary["plan"] return { - "plan": "local", - "features": [], + "plan": plan, + "features": summary["features"], + "known_features": dict(_FEATURE_LABELS), + # Diagnostics for support: which resolution rule produced this plan — one of + # ``environment`` (the operator override), ``session`` (the entitlement the cloud + # returned on registration/refresh), ``cloud`` (the cached entitlements read), + # ``connected`` or ``local`` (inference) — and when the control plane last + # confirmed it (0 when it never has). + "plan_source": summary["plan_source"], + "plan_checked_at": summary["plan_checked_at"], + "cloud_access_active": summary["cloud_access_active"], + # The client cannot distinguish a trial from a paid subscription; the control + # plane owns that. Report the honest default so the badge falls back to the plan + # name instead of silently claiming a trial the customer may not be on. + "is_trial": False, + "trial": {"used": False, "trial_days": licensing.TRIAL_DAYS}, "cloud_managed": True, "trial_seconds": 259_200, "grace_seconds": 86_400, "grace_scope": "existing authenticated local workspace writes only", "upgrade_url": licensing.upgrade_url(), + # Pro and Team bill through separate checkout targets. Emitting only the generic + # URL sent every Team upgrade click to the Pro page. + "pro_upgrade_url": licensing.upgrade_url("pro"), + "team_upgrade_url": licensing.upgrade_url("team"), } diff --git a/engraphis/static/dashboard.css b/engraphis/static/dashboard.css index 6e02cf4..fc2c328 100644 --- a/engraphis/static/dashboard.css +++ b/engraphis/static/dashboard.css @@ -264,6 +264,7 @@ body{ .update-banner .ub-dismiss:hover{color:var(--color-text)} .empty{padding:var(--space-6) 0;color:var(--color-text-dim);line-height:1.5;text-align:left} .empty .btn{margin-top:var(--space-3)} +.upgrade-panel{max-width:720px;padding:var(--space-6);border:var(--rule-strong) solid var(--color-accent);background:linear-gradient(135deg,var(--color-accent-bg),transparent 68%)}.upgrade-panel-kicker,.upgrade-panel-benefits-title{color:var(--color-accent);font-family:var(--mono);font-size:var(--text-micro);font-weight:600;letter-spacing:.08em}.upgrade-panel h2{margin:var(--space-2) 0;font-size:var(--text-xl);letter-spacing:-.02em;color:var(--color-text)}.upgrade-panel-lede{max-width:620px;margin:0;color:var(--color-text-dim)}.upgrade-panel-price{margin-top:var(--space-4);color:var(--color-text);font-size:var(--text-lg);font-weight:600}.upgrade-panel-benefits{margin-top:var(--space-4);padding-top:var(--space-4);border-top:var(--rule) solid var(--color-border)}.upgrade-panel-benefits ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--space-2) var(--space-5);margin:var(--space-3) 0 0;padding:0;list-style:none}.upgrade-panel-benefits li{display:flex;gap:var(--space-2);color:var(--color-text-muted);font-size:var(--text-sm);line-height:1.35}.upgrade-panel-benefits li::before{color:var(--color-accent);content:"✓"}.upgrade-panel-trial{margin:var(--space-4) 0 0;color:var(--color-text-dim);font-size:var(--text-sm)}.upgrade-panel-actions{display:flex;gap:var(--space-2);margin-top:var(--space-4);flex-wrap:wrap}.upgrade-panel-actions .btn{margin-top:0}@media(max-width:640px){.upgrade-panel{padding:var(--space-4)}.upgrade-panel-benefits ul{grid-template-columns:1fr}} /* Route compositions inherit the same ledger geometry. */ #view-overview{padding-top:calc(var(--space-6) / 2)} diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 9b86abc..902f0e0 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -141,22 +141,25 @@ async function loadOverviewAnalytics(){ const cell=(v,l,c)=>{const tone=c==='var(--red)'?' tone-red':(c==='var(--amber)'?' tone-amber':(c==='var(--green)'?' tone-green':''));return `
${v}
${l}
`}; el.innerHTML=`
${cell(avg+'%','Avg retention',avg<40?'var(--red)':(avg<70?'var(--amber)':'var(--green)'))}${cell(f.at_risk_7d||0,'Fading ≤ 7 days',f.at_risk_7d>0?'var(--amber)':'')}${cell(thisWeek,'Written this week')}${cell(t.pinned||0,'Pinned')}
`; }catch(e){ - if(e.status===401||e.status===402||e.status===501){ + // Same routing as loadAnalytics: consent first, entitlement second, everything else + // reported as the error it is. See hostedFeatureUnavailable for why 409 is not an + // entitlement status. + if(managedConsentRequired(e)){ + lock.textContent='CLOUD'; + lock.className='pill pill-accent'; + el.innerHTML=managedConsentHtml('Analytics'); + }else if(hostedFeatureUnavailable(e)){ lock.textContent='PRO'; lock.className='pill pill-muted'; const used=LIC&&LIC.trial&&LIC.trial.used; el.innerHTML='
Hosted growth, retention distribution, and decay forecast.
'+(used?'':' ')+'
'; - }else if(managedConsentRequired(e)){ - lock.textContent='CLOUD'; - lock.className='pill pill-accent'; - el.innerHTML=managedConsentHtml('Analytics'); }else el.innerHTML='
'+esc(e.message)+'
'; } } /* ── shared hosted upgrade / trial CTA ── */ function hostedPlanUrl(plan,trial){const raw=(LIC&&(plan==='team'?LIC.team_upgrade_url:LIC.pro_upgrade_url))||(LIC&&LIC.upgrade_url);const safe=safeUrl(raw);if(!safe||safe==='#')return '#';try{const url=new URL(safe,location.href);if(plan==='pro'||plan==='team')url.searchParams.set('plan',plan);if(trial)url.searchParams.set('trial',plan);return url.href}catch(e){return safe}} -function unlockHtml(feature,plan){const url=hostedPlanUrl(plan),trialUrl=hostedPlanUrl(plan,true);const used=LIC&&LIC.trial&&LIC.trial.used;const trial=plan==='team'?'Start hosted Team trial':'Start hosted Pro trial';const detail=used?'Your free trial has already been used.':`The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days.`;return `
${esc(feature)} runs in Engraphis ${plan==='team'?'Team':'Pro'} Cloud
${detail} Local-only write grace is separate, capped at 24 hours, and never extends cloud access.
${used?'':`${trial}`}${plan==='team'?'View Team plans':'View Pro plans'}
`} +function unlockHtml(feature,plan){const url=hostedPlanUrl(plan),trialUrl=hostedPlanUrl(plan,true),team=plan==='team';const used=LIC&&LIC.trial&&LIC.trial.used;const trial=team?'Start hosted Team trial':'Start hosted Pro trial';const purchase=team?'Purchase Team license':'Purchase Pro license';const price=team?'$20 per seat/month or $200 per seat/year':'$10/month or $100/year';const detail=used?'Your free trial has already been used.':`The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days.`;const benefits=team?['Everything in Pro','Hosted organizations, invitations, and named seats','Roles, scoped credentials, and Team audit history']:['Hosted Cloud Sync across your installations','Growth, retention, decay, and entity Analytics','Auto Consolidation with hosted retention policies','Auto Dreaming with reviewable managed proposals','Signed compliance exports with bi-temporal checksums','Priority support'];return `
ENGRAPHIS ${team?'TEAM':'PRO'}

Unlock ${esc(feature)} and more

Make the local memory engine work across your installations—and keep improving without manual upkeep.

${price}
Your license unlocks
    ${benefits.map(item=>`
  • ${esc(item)}
  • `).join('')}

${detail}

${used?'':`${trial}`}${purchase}
`} function startTrialPlan(plan){const url=hostedPlanUrl(plan,true);if(url==='#'){toast('Hosted signup URL is not configured','err');return}const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener';link.click()} function startTrial(){return startTrialPlan('pro')} function startTeamTrial(){return startTrialPlan('team')} @@ -180,14 +183,31 @@ function updateFeatureLocks(){ function barRow(label,val,peak,color){const tone=color==='var(--green)'?' analytics-bar-green':(color==='var(--blue)'?' analytics-bar-blue':(color==='var(--cyan)'?' analytics-bar-cyan':(color==='var(--accent-dim)'?' analytics-bar-dim':'')));return `
${esc(label)}
${val}
`} function statMini(v,l,color){const tone=color==='var(--red)'?' tone-red':(color==='var(--amber)'?' tone-amber':(color==='var(--green)'?' tone-green':''));return `
${v}
${esc(l)}
`} function renderAnalytics(a,isPortfolio){const t=a.totals||{},f=a.decay_forecast||{};const weeks=a.growth_weekly||[];const gp=Math.max(...weeks,1);const gitems=weeks.map((n,i)=>{const back=weeks.length-1-i;return barRow(back===0?'now':back+'w ago',n,gp,'var(--accent-dim)')}).join('')||'
No data
';const hist=a.retention_histogram||{};const hc=hist.counts||[],hb=hist.buckets||[];const hp=Math.max(...hc,1);const hitems=hb.map((b,i)=>barRow(b,hc[i]||0,hp,'var(--green)')).join('');const mix=a.resolver_mix||{};const mk=Object.keys(mix);const mp=Math.max(...Object.values(mix),1);const mitems=mk.length?mk.map(k=>barRow(k,mix[k],mp,'var(--blue)')).join(''):'
No resolver events yet.
';const bt=a.by_type||{};const btk=Object.keys(bt);const bp=Math.max(...Object.values(bt),1);const btitems=btk.length?btk.map(k=>barRow(k,bt[k],bp,'var(--accent)')).join(''):'
No memories yet.
';const ents=a.top_entities||[];const ep=Math.max(...ents.map(e=>e.n),1);const eitems=ents.length?ents.map(e=>barRow(e.name+(isPortfolio&&e.workspace?' · '+e.workspace:''),e.n,ep,'var(--cyan)')).join(''):'
No entities yet — they appear as the graph grows.
';const avg=Math.round((t.avg_retention||0)*100);let wsTable='';if(isPortfolio&&a.workspaces){wsTable=`
Per-workspace breakdown
${a.workspaces.map(w=>``).join('')}
WorkspaceLivePinnedAvg ret.Fading 7d
${esc(w.workspace)}${w.live}${w.pinned}${Math.round((w.avg_retention||0)*100)}%${w.at_risk_7d}
`}return `
${statMini(t.live!=null?t.live:'—','Live memories')}${statMini(avg+'%','Avg retention',avg<40?'var(--red)':(avg<70?'var(--amber)':'var(--green)'))}${statMini(f.at_risk_7d!=null?f.at_risk_7d:'—','Fading ≤ 7 days',f.at_risk_7d>0?'var(--amber)':'')}${statMini(f.at_risk_30d!=null?f.at_risk_30d:'—','Fading ≤ 30 days')}${statMini(t.pinned!=null?t.pinned:'—','Pinned (protected)')}${isPortfolio?statMini(t.workspaces||0,'Workspaces'):statMini(t.superseded!=null?t.superseded:'—','Superseded (history)')}
Memories written per week
${gitems}
Retention distribution
${hitems}
By type
${btitems}
Write-path resolver activity
${mitems}
Most connected entities
${eitems}
${wsTable}`} -function managedConsentHtml(feature){return `
${esc(feature)} needs your explicit permission before this installation uploads a bounded workspace snapshot.
Set ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1 in .env, then restart Engraphis. Secret and session-scoped memories remain excluded.
`} +function managedConsentHtml(feature){return `
${esc(feature)} runs in Engraphis Cloud, and managed compute is turned off for this installation.
Connect this installation to Engraphis Cloud to enable it. Secret and session-scoped memories are never uploaded.
`} function managedConsentRequired(error){return error&&error.status===409&&error.detail&&error.detail.code==='consent_required'} -async function loadAnalytics(){const el=document.getElementById('analytics-body'),lock=document.getElementById('an-lock'),acts=document.getElementById('an-actions');el.innerHTML='
';try{const a=await api('/analytics?workspace='+encodeURIComponent(WS||''));setPlanPill(lock,(LIC&&LIC.is_trial)?'TRIAL':'CLOUD','pill pill-accent');showAs(acts,true,'flex');el.innerHTML=renderAnalytics(a,false)}catch(e){if(e.status===401||e.status===402||e.status===501){setPlanPill(lock,'PRO','pill pill-muted');showAs(acts,false);el.innerHTML=unlockHtml('Analytics','pro')}else if(managedConsentRequired(e)){setPlanPill(lock,'CLOUD','pill pill-accent');showAs(acts,false);el.innerHTML=managedConsentHtml('Analytics')}else{el.innerHTML='
'+esc(e.message)+'
'}}} +/* Entitlement failure — and only that. 402 subscription not active and 501 feature not + offered here: each means the customer's plan does not include what they just opened, + so each may draw the purchase panel. + 401 is deliberately NOT here either. The cloud maps it to "the cloud session expired or + was revoked; connect again" — a credential problem, not a billing one. An already-paying + customer whose refresh credential lapsed was being sold a plan they own; 401 falls + through to the error branch so the reconnect instruction is what they actually read. + 409 is deliberately NOT here. Every 409 this API emits is a *conflict*, never a billing + answer: detail.code==='consent_required' means the customer IS entitled but managed + compute is off for this installation (managedConsentRequired, above), and the rest are + transient state conflicts (snapshot generation, cloud session state, rebuilding index). + Counting any of them as "unpaid" showed a paying Pro customer a panel selling Pro, so + every caller routes managedConsentRequired first and this second. + Neither predicate may branch on CURRENT_VIEW: doing so made both the consent branch and + the plain-error branch unreachable on the analytics and automation views, which is the + regression this comment previously described but the code did not implement. */ +function hostedFeatureUnavailable(error){return !!error&&(error.status===402||error.status===501)} +async function loadAnalytics(){const el=document.getElementById('analytics-body'),lock=document.getElementById('an-lock'),acts=document.getElementById('an-actions');el.innerHTML='
';try{const a=await api('/analytics?workspace='+encodeURIComponent(WS||''));setPlanPill(lock,(LIC&&LIC.is_trial)?'TRIAL':'CLOUD','pill pill-accent');showAs(acts,true,'flex');el.innerHTML=renderAnalytics(a,false)}catch(e){if(managedConsentRequired(e)){setPlanPill(lock,'CLOUD','pill pill-accent');showAs(acts,false);el.innerHTML=managedConsentHtml('Analytics')}else if(hostedFeatureUnavailable(e)){setPlanPill(lock,'PRO','pill pill-muted');showAs(acts,false);el.innerHTML=unlockHtml('Analytics','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}} /* ── hosted automation policy (Pro / Team) ── */ -async function loadAutomation(){const el=document.getElementById('automation-body'),lock=document.getElementById('au-lock'),ws='?workspace='+encodeURIComponent(WS||'');el.innerHTML='
';try{const p=await api('/automation'+ws);setPlanPill(lock,(LIC&&LIC.is_trial)?'TRIAL':'CLOUD','pill pill-accent');const last=p.last_run?fmtRel(p.last_run):'never',dream=p.dream_enabled!=null?p.dream_enabled:p.dream;el.innerHTML=`
Hosted maintenance policy
The cloud returns reviewable proposals. Pinned memories remain protected.
Cloud worker status
Status${p.enabled?'ENABLED':'OFF'}
Last run${esc(last)}
After explicit opt-in with ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1, requesting managed work uploads the selected workspace’s normal and sensitive memory content, excluding secret and session-scoped rows, capped at 16 MiB, over HTTPS without end-to-end encryption. Results are proposals and never automatically write the local database.
`}catch(e){if(e.status===401||e.status===402||e.status===501){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}} -async function saveAutomation(){const body={enabled:document.getElementById('au-enabled').checked,cadence_hours:Number(document.getElementById('au-cadence').value)||24,consolidate:document.getElementById('au-consolidate').checked,min_cluster:Number(document.getElementById('au-mincluster').value)||3,archive_below:Number(document.getElementById('au-archive').value)||0.05,dream_enabled:document.getElementById('au-dream').checked,dream_min_new:Number(document.getElementById('au-dream-min').value)||20,dream_idle_minutes:Number(document.getElementById('au-dream-idle').value)};try{await api('/automation?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Hosted policy saved','ok');loadAutomation()}catch(e){if(managedConsentRequired(e)){const result=document.getElementById('au-result');if(result)result.innerHTML=managedConsentHtml('Hosted Automation');toast('Enable managed compute consent and restart Engraphis','err');return}toast((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message,'err')}} -async function runMaintenance(){const el=document.getElementById('au-result');if(el)el.innerHTML='
';try{const d=await api('/maintenance/run?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dry_run:true})});if(el)el.innerHTML=`PROPOSAL Hosted work was submitted for review.
${esc(JSON.stringify(d,null,2))}
`;toast('Managed proposal requested','ok')}catch(e){if(el)el.innerHTML=managedConsentRequired(e)?managedConsentHtml('Hosted Automation'):'
'+esc(e.message)+'
';toast(managedConsentRequired(e)?'Enable managed compute consent and restart Engraphis':((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message),'err')}} +async function loadAutomation(){const el=document.getElementById('automation-body'),lock=document.getElementById('au-lock'),ws='?workspace='+encodeURIComponent(WS||'');el.innerHTML='
';try{const p=await api('/automation'+ws);setPlanPill(lock,(LIC&&LIC.is_trial)?'TRIAL':'CLOUD','pill pill-accent');const last=p.last_run?fmtRel(p.last_run):'never',dream=p.dream_enabled!=null?p.dream_enabled:p.dream;el.innerHTML=`
Hosted maintenance policy
The cloud returns reviewable proposals. Pinned memories remain protected.
Cloud worker status
Status${p.enabled?'ENABLED':'OFF'}
Last run${esc(last)}
Requesting managed work uploads the selected workspace’s normal and sensitive memory content, excluding secret and session-scoped rows, capped at 16 MiB, over HTTPS without end-to-end encryption. Results are proposals and never automatically write the local database.
`}catch(e){if(managedConsentRequired(e)){setPlanPill(lock,'CLOUD','pill pill-accent');el.innerHTML=managedConsentHtml('Hosted Automation')}else if(hostedFeatureUnavailable(e)){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}} +async function saveAutomation(){const body={enabled:document.getElementById('au-enabled').checked,cadence_hours:Number(document.getElementById('au-cadence').value)||24,consolidate:document.getElementById('au-consolidate').checked,min_cluster:Number(document.getElementById('au-mincluster').value)||3,archive_below:Number(document.getElementById('au-archive').value)||0.05,dream_enabled:document.getElementById('au-dream').checked,dream_min_new:Number(document.getElementById('au-dream-min').value)||20,dream_idle_minutes:Number(document.getElementById('au-dream-idle').value)};try{await api('/automation?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Hosted policy saved','ok');loadAutomation()}catch(e){if(managedConsentRequired(e)){const result=document.getElementById('au-result');if(result)result.innerHTML=managedConsentHtml('Hosted Automation');toast('Connect this installation to Engraphis Cloud to use managed compute','err');return}toast((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message,'err')}} +async function runMaintenance(){const el=document.getElementById('au-result');if(el)el.innerHTML='
';try{const d=await api('/maintenance/run?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dry_run:true})});if(el)el.innerHTML=`PROPOSAL Hosted work was submitted for review.
${esc(JSON.stringify(d,null,2))}
`;toast('Managed proposal requested','ok')}catch(e){if(el)el.innerHTML=managedConsentRequired(e)?managedConsentHtml('Hosted Automation'):'
'+esc(e.message)+'
';toast(managedConsentRequired(e)?'Connect this installation to Engraphis Cloud to use managed compute':((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message),'err')}} const runMaintenanceBase=runMaintenance; let MAINTENANCE_PENDING=false; @@ -444,7 +464,10 @@ async function setLlmExtractor(on){try{const d=await api('/llm/extractor',{metho async function testLlm(){const r=document.getElementById('llm-test-result');if(r){r.textContent='Testing…';setTone(r,'muted')}try{const d=await api('/llm/test',{method:'POST'});if(r){if(d.ok){const transient=d.auto_enabled&&d.persisted===false;r.textContent=(transient?'⚠ ':'✓ ')+'Connected — '+esc(d.provider)+'/'+esc(d.model)+(transient?' Extraction is active for this process, but the setting could not be saved for restart. Set ENGRAPHIS_EXTRACTOR=llm_structured and ENGRAPHIS_LLM_AUTO_EXTRACT=1 in the deployment environment.':'');setTone(r,transient?'red':'green')}else{r.textContent='✗ '+(d.error||'failed');setTone(r,'red')}}}catch(e){if(r){r.textContent='✗ '+esc(e.message);setTone(r,'red')}}} async function loadHostedAgentAccess(){const el=document.getElementById('tokens-body');if(!el)return;let url=hostedPlanUrl('team');try{const st=await api('/auth/state');if(url==='#'&&st&&st.cloud_url)url=safeUrl(st.cloud_url)}catch(e){}el.innerHTML=`
Per-member agent accounts, roles, named seats, and rotating device credentials are managed in Team Cloud, not by this local dashboard.
Open Team CloudAgent Connect guide
`} -async function loadSyncStatus(){try{const d=await api('/sync/status');renderSync(d)}catch(e){const el=document.getElementById('sync-body');if(el)el.innerHTML=unlockHtml('Cloud Sync','pro')}} +/* Route by cause, exactly like loadAnalytics/loadAutomation. Rendering the purchase panel for + every failure told a paying customer to buy the plan they already own whenever the network + blipped or the cloud answered 5xx. Only 401/402/501 are billing answers. */ +async function loadSyncStatus(){try{const d=await api('/sync/status');renderSync(d)}catch(e){const el=document.getElementById('sync-body');if(!el)return;if(managedConsentRequired(e))el.innerHTML=managedConsentHtml('Cloud Sync');else if(hostedFeatureUnavailable(e))el.innerHTML=unlockHtml('Cloud Sync','pro');else el.innerHTML='
'+esc(e.message)+'
'}} function syncTotalAuthorizationDenial(last){if(!last||!(Number(last.attempted)>0)||Number(last.succeeded)!==0)return false;const errors=Array.isArray(last.errors)?last.errors:[];return errors.length===Number(last.attempted)&&errors.every(error=>[401,402,403].includes(Number(error.status)))} function syncRecoveryHtml(){return unlockHtml('Cloud Sync','pro')+`
`} function renderSync(d){const el=document.getElementById('sync-body');if(!el)return;d=d||{};const last=d.last;if(!d.available){el.innerHTML=unlockHtml('Cloud Sync','pro');return}if(syncTotalAuthorizationDenial(last)){el.innerHTML=syncRecoveryHtml();return}let status='Cloud session connected; no sync recorded on this installation.';if(last){const when=new Date((last.at||0)*1000).toLocaleString();status='Last synced '+when+' — pushed '+(last.exported||0)+', +'+(last.added||0)+' received'+((last.errors&&last.errors.length)?' · '+last.errors.length+' issue(s)':'')+'.'}el.innerHTML=`
Hosted relayCONNECTED
Relay storage and authorization run in Engraphis Cloud. This package contains only the customer client; it does not run a local relay or background scheduler.
${esc(status)}
`} diff --git a/engraphis/update_check.py b/engraphis/update_check.py index 1b2273f..d5d711e 100644 --- a/engraphis/update_check.py +++ b/engraphis/update_check.py @@ -31,6 +31,10 @@ import urllib.request from typing import Callable, Optional +# Stdlib-only itself (see the module docstring): importing it keeps this module free of +# the config/server stack while giving the probe the package's vetted HTTPS connector. +from engraphis.hosted_client import build_pinned_https_opener + try: # installed distribution → real version; source tree → pinned fallback from engraphis import __version__ as CURRENT_VERSION except Exception: # pragma: no cover - engraphis always importable in practice @@ -159,7 +163,12 @@ def _fetch(url: str, timeout: float) -> Optional[dict]: "User-Agent": "Engraphis/%s update-check" % CURRENT_VERSION, "Accept": "application/vnd.github+json, application/json;q=0.9, */*;q=0.1", }) - opener = urllib.request.build_opener(_NoRedirect) + # ``ENGRAPHIS_UPDATE_URL`` makes this endpoint operator-controllable, so the probe + # gets the same pinned opener every other outbound client uses (hosted_client, + # cloud_session, sync_relay): the vetted address is the one actually dialled, which + # rejects private/reserved targets and closes the DNS-rebinding window between the + # scheme check above and the connect. A plain ``build_opener`` had neither guard. + opener = build_pinned_https_opener(_NoRedirect()) try: with opener.open(req, timeout=timeout) as resp: # nosec B310 - scheme checked above raw = resp.read(_MAX_BYTES + 1) diff --git a/scripts/update.py b/scripts/update.py index 855153c..74fa5db 100644 --- a/scripts/update.py +++ b/scripts/update.py @@ -15,12 +15,15 @@ """ from __future__ import annotations +from typing import Optional + import importlib.metadata import json import os import re import shutil +import signal import subprocess import sys from pathlib import Path @@ -34,8 +37,217 @@ ) -def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: - return subprocess.run(cmd, capture_output=True, text=True, check=check) +# Every step below runs with an explicit, differentiated budget. An unbounded call against +# a stalled package index or an unreachable git remote is an indefinite hang, and a +# *captured* one is a silent hang with nothing on screen to explain it. Sizes follow the +# work each command actually does: a refs query is one round trip, a fetch may transfer a +# whole object delta, and an install downloads and may build wheels. A budget is only real +# if nothing can outlive it — see ``_run`` and ``_run_captured`` for how that is enforced. +_GIT_LOCAL_TIMEOUT_S = 30 # plumbing on an existing clone (see scripts/graph_cli.py) +_GIT_CHECKOUT_TIMEOUT_S = 120 # local, but runs checkout filters and hooks +_GIT_LS_REMOTE_TIMEOUT_S = 60 # one network round trip for refs; no object transfer +_GIT_FETCH_TIMEOUT_S = 600 # may transfer every object a long-stale clone is missing +_PIP_METADATA_TIMEOUT_S = 60 # `pip show` is local, but a cold pip import is not fast +_PIP_RESOLVE_TIMEOUT_S = 300 # `--dry-run` still queries and resolves against the index +_PIP_INSTALL_TIMEOUT_S = 1800 # download plus build; an sdist with C extensions is slow +_PIPX_TIMEOUT_S = 1800 # a pip install plus venv creation +_TREE_KILL_TIMEOUT_S = 10 # bounding the kill itself; `taskkill` is local and fast +_DRAIN_AFTER_KILL_S = 5 # reading a pipe whose writers were just destroyed + +# ``os.killpg`` must target *our* tree, never the shell that launched the updater, so the +# POSIX child gets its own session. Windows has no equivalent at spawn time — the tree is +# walked by ``taskkill /T`` instead — and the keyword's Windows meaning changed in 3.13, +# so it is not passed there at all. Every step is spawned this way, not just the captured +# ones: a session is the only handle POSIX gives us on a *descendant*, and it has to exist +# before the child runs, not after the budget has already expired. +_OWN_PROCESS_GROUP = {} if os.name == "nt" else {"start_new_session": True} + + +class UpdateTimeout(RuntimeError): + """A step exceeded its bounded budget. + + Carries ready-to-print, actionable copy so a stalled remote never degrades into a + silent hang, and so the editable-install rollback below can treat a timeout exactly + like a failed reinstall instead of stranding a half-applied checkout. + """ + + +def _timed_out(what: str, timeout: int) -> UpdateTimeout: + return UpdateTimeout( + "%s timed out after %ds. Check your network connection, proxy settings, and " + "package index, then run `engraphis-update` again." % (what, timeout) + ) + + +def _git_env() -> dict: + """Environment for every git call: never stop to ask a human for credentials. + + An expired token, a revoked SSH key or a corporate proxy that wants authentication + otherwise drops the updater into git's terminal prompt — or, on Windows, the Git + Credential Manager dialog — and it blocks forever behind a question nobody is there + to answer. That is a hang with no network fault to diagnose, so the budgets above look + like they simply do not work. Fail the call instead; the caller already prints what to + do about it. + """ + env = dict(os.environ) + env["GIT_TERMINAL_PROMPT"] = "0" + env["GCM_INTERACTIVE"] = "never" + return env + + +def _kill_process_tree(process: subprocess.Popen) -> None: + """Kill *process* and every descendant it spawned. Best effort; already-dead is fine. + + Killing only the direct child is what makes a "bounded" capture unbounded: git forks + ``git-remote-https`` (and credential helpers), those grandchildren inherit the pipe's + write handle, and a read of that pipe cannot complete until the last handle closes. + """ + if os.name == "nt": + taskkill = shutil.which("taskkill") + if taskkill: + try: + subprocess.run( + [taskkill, "/F", "/T", "/PID", str(process.pid)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=_TREE_KILL_TIMEOUT_S, + ) + except (OSError, subprocess.SubprocessError): + pass + else: + try: + os.killpg(os.getpgid(process.pid), getattr(signal, "SIGKILL", signal.SIGTERM)) + except (OSError, AttributeError): + pass + try: + process.kill() + except OSError: + pass + + +def _bounded_call(cmd: list[str], what: str, timeout: int, capture: bool, + env: Optional[dict]) -> subprocess.CompletedProcess: + """Run *cmd* under a budget that **nothing in its process tree** can outlive. + + Every step lands here, because "bounded" has to mean the same thing for a step that is + merely displayed as for one that is parsed. ``subprocess.run(timeout=...)`` cannot + provide it, in two distinct ways: + + * *With* pipes it does not even bound the call. Once the budget expires CPython kills + the direct child and then drains with an **unbounded** ``communicate()``, which waits + for every inherited write handle to close — ``git-remote-https`` included. + * *Without* pipes it returns on time but leaves the descendants running. ``pip``'s + resolver or a credential helper keeps writing to the environment and the repository + while the caller has already moved on to a rollback or a retry, which is precisely + the guarantee the budget is supposed to buy. + + So the child is spawned into its own session (POSIX) and the whole tree is torn down + with ``taskkill /T`` (Windows) before the pipe is re-read — and that drain is bounded + too, so a pipe a dead writer still owns cannot re-hang the call. + + Only stdout is ever piped, and only when *capture* asks for it: stderr staying on the + terminal both surfaces git's own explanation of a failure and leaves one fewer + inherited write handle for a grandchild to hold open. ``stdin`` is closed for every + step — a subprocess that stops to read from a terminal is the same indefinite hang as + a stalled socket, and none of these commands has anything to read. + """ + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE if capture else None, stdin=subprocess.DEVNULL, + text=True, env=env, **_OWN_PROCESS_GROUP, + ) + try: + stdout, _ = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + _kill_process_tree(process) + try: + process.communicate(timeout=_DRAIN_AFTER_KILL_S) + except subprocess.TimeoutExpired: + pass + raise _timed_out(what, timeout) from None + return subprocess.CompletedProcess(cmd, process.returncode, stdout or "", None) + + +def _run(cmd: list[str], what: str, timeout: int, check: bool = False, + capture: bool = False, env: Optional[dict] = None) -> subprocess.CompletedProcess: + """Run *cmd* under an explicit budget; a stall raises instead of hanging forever. + + ``capture`` stays opt-in — a pipe nobody reads is only another handle a grandchild can + hold open — but it no longer selects between an enforceable path and an unenforceable + one. Both go through :func:`_bounded_call`, so a timeout kills the descendants either + way. ``check`` keeps ``subprocess.run``'s meaning: a non-zero exit raises + ``CalledProcessError``. + """ + result = _bounded_call(cmd, what, timeout, capture, env) + if check and result.returncode: + raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, None) + return result + + +def _run_captured(cmd: list[str], what: str, timeout: int, + env: Optional[dict] = None) -> subprocess.CompletedProcess: + """Run *cmd* for its stdout under a budget that is actually enforced. + + For the steps that must be *parsed* rather than merely displayed, so simply not + capturing is not an option. + """ + return _bounded_call(cmd, what, timeout, True, env) + + +def _index_lock(project_dir: Path) -> Path: + """Path to this clone's ``index.lock``, following a ``.git`` *file* when there is one. + + A worktree or submodule checkout records ``gitdir: `` in a plain file instead of + holding a ``.git`` directory, and the lock lives in the pointed-to git dir. Guessing + ``/.git/index.lock`` there would tell the user to delete a file that does not + exist while the real one keeps blocking every git command. + """ + git_dir = project_dir / ".git" + if git_dir.is_file(): + try: + pointer = git_dir.read_text(encoding="utf-8").strip() + except OSError: + pointer = "" + if pointer.startswith("gitdir:"): + target = Path(pointer.split(":", 1)[1].strip()) + git_dir = target if target.is_absolute() else project_dir / target + return git_dir / "index.lock" + + +def _release_index_lock(project_dir: Path, existed_before: bool, ours: bool) -> None: + """Clear the index lock our own killed checkout left — and only that one. + + A ``git checkout`` terminated at its budget dies still holding ``index.lock``, and + every later git command in the clone then fails with "Another git process seems to be + running" — the restore below first of all. But deleting the lock unconditionally is + worse than the failure it fixes: an unrelated, *live* git in the same clone would have + its index pulled out from under it mid-write. + + Two independent facts therefore have to line up before we touch it. The lock was absent + when this updater started the checkout, so it appeared during our own invocation; and + the checkout is one *we* killed. A git process that exits on its own always removes the + lock it created, so a lock surviving any other failure belongs to somebody else. When + it does, name the exact path and let the user decide — an unexplained wedged clone is + the outcome worth avoiding, not an unattended delete. + """ + lock = _index_lock(project_dir) + if not lock.exists(): + return + if ours and not existed_before: + try: + lock.unlink() + except OSError as exc: + print("Could not remove the index lock left by the interrupted checkout: %s " + "(%s). Delete that file, then re-run `engraphis-update`." % (lock, exc), + file=sys.stderr) + else: + print("Removed the index lock left by the interrupted checkout: %s" % lock, + file=sys.stderr) + return + print( + "A git index lock is present that this update did not create: %s\n" + "Another git process may be running in %s. Close it — or delete that file if none " + "is — then re-run `engraphis-update`." % (lock, project_dir), + file=sys.stderr, + ) def _select_latest_tag(tags) -> str: @@ -51,9 +263,10 @@ def _select_latest_tag(tags) -> str: def _remote_latest_tag(git: str, repo_url: str = REPO_URL) -> str: - result = subprocess.run( + result = _run_captured( [git, "ls-remote", "--tags", "--refs", repo_url, "v*"], - capture_output=True, text=True, + "Listing release tags from the Git remote", _GIT_LS_REMOTE_TIMEOUT_S, + env=_git_env(), ) if result.returncode: return "" @@ -96,9 +309,10 @@ def _detect_install() -> str: # installed it in develop mode. pip show engraphis will list an "Editable # project location" line. try: - result = subprocess.run( + result = _run( [sys.executable, "-m", "pip", "show", "engraphis"], - capture_output=True, text=True) + "Reading the installed Engraphis metadata", _PIP_METADATA_TIMEOUT_S, + capture=True) if result.returncode == 0: info = result.stdout if "Editable project location:" in info: @@ -111,6 +325,10 @@ def _detect_install() -> str: if _installed_git_url(): return "git" return "pypi" + except UpdateTimeout: + # A stalled `pip show` must report why, not masquerade as "unknown install + # method" and send the user off to guess at a reinstall command. + raise except Exception: pass @@ -120,9 +338,10 @@ def _detect_install() -> str: def _git_update(check_only: bool = False) -> None: """Update an editable install to a validated stable tag and reinstall it.""" try: - result = subprocess.run( + result = _run( [sys.executable, "-m", "pip", "show", "engraphis"], - capture_output=True, text=True, check=True) + "Reading the installed Engraphis metadata", _PIP_METADATA_TIMEOUT_S, + check=True, capture=True) except subprocess.CalledProcessError: print("Engraphis is not installed.", file=sys.stderr) sys.exit(1) @@ -146,26 +365,33 @@ def _git_update(check_only: bool = False) -> None: # Fetch and compare. Fail closed on a network/ref error: selecting the highest LOCAL # tag would let a stray or malicious tag masquerade as the latest upstream release. - fetched = subprocess.run( + # Nothing here parses the fetch's output, and capturing it would forfeit the budget + # below (see ``_run``), so let git report its own progress straight to the terminal. + print("Fetching release tags from origin...") + fetched = _run( [git, "-C", str(project_dir), "fetch", "--tags", "origin"], - capture_output=True, text=True, + "Fetching release tags from origin", _GIT_FETCH_TIMEOUT_S, + env=_git_env(), ) if fetched.returncode: print("Could not fetch release tags from origin; no update was applied.", file=sys.stderr) sys.exit(1) - local = subprocess.run([git, "-C", str(project_dir), "rev-parse", "HEAD"], - capture_output=True, text=True).stdout.strip() - branch_result = subprocess.run( + local = _run([git, "-C", str(project_dir), "rev-parse", "HEAD"], + "Reading the current revision", _GIT_LOCAL_TIMEOUT_S, + capture=True, env=_git_env()).stdout.strip() + branch_result = _run( [git, "-C", str(project_dir), "symbolic-ref", "--quiet", "--short", "HEAD"], - capture_output=True, text=True, + "Reading the current branch", _GIT_LOCAL_TIMEOUT_S, + capture=True, env=_git_env(), ) original_ref = branch_result.stdout.strip() if branch_result.returncode == 0 else local tag = LATEST_TAG if not tag: - tags = subprocess.run( + tags = _run_captured( [git, "-C", str(project_dir), "ls-remote", "--tags", "--refs", "origin", "v*"], - capture_output=True, text=True, + "Listing release tags from origin", _GIT_LS_REMOTE_TIMEOUT_S, + env=_git_env(), ) if tags.returncode: print("Could not list release tags from origin; no update was applied.", @@ -180,9 +406,10 @@ def _git_update(check_only: bool = False) -> None: sys.exit(1) # ``rev-list`` peels annotated tags; comparing HEAD to the tag object itself would # report a false update forever. - remote = subprocess.run( + remote = _run( [git, "-C", str(project_dir), "rev-list", "-n", "1", tag], - capture_output=True, text=True, + "Resolving the release tag", _GIT_LOCAL_TIMEOUT_S, + capture=True, env=_git_env(), ) remote_sha = remote.stdout.strip() if remote.returncode == 0 else "" @@ -200,29 +427,65 @@ def _git_update(check_only: bool = False) -> None: if check_only: return - dirty = subprocess.run( + dirty = _run( [git, "-C", str(project_dir), "status", "--porcelain"], - capture_output=True, text=True, + "Checking the working tree", _GIT_LOCAL_TIMEOUT_S, + capture=True, env=_git_env(), ) if dirty.stdout.strip(): print("Refusing to update a working tree with uncommitted changes.", file=sys.stderr) sys.exit(1) print(f"Checking out release {tag}...") - subprocess.run([git, "-C", str(project_dir), "checkout", f"tags/{tag}"], check=True) + # The checkout is the destructive step, so it belongs *inside* the rollback boundary, + # not above it. Run outside, a checkout that exceeded its budget or exited non-zero + # raised straight past the restore and left an editable install partially switched + # while the CLI reported nothing but a timeout. + lock_existed = _index_lock(project_dir).exists() + stage = "checkout" try: - subprocess.run( + _run([git, "-C", str(project_dir), "checkout", f"tags/{tag}"], + "Checking out the release tag", _GIT_CHECKOUT_TIMEOUT_S, + check=True, capture=False, env=_git_env()) + stage = "reinstall" + print(f"Reinstalling from {project_dir}...") + _run( [sys.executable, "-m", "pip", "install", "-e", str(project_dir)], - check=True, + "Reinstalling the editable checkout", _PIP_INSTALL_TIMEOUT_S, + check=True, capture=False, ) - except subprocess.CalledProcessError: - # A failed reinstall must not strand a previously working editable checkout at - # a half-applied detached release. Restore its original branch (or exact commit - # when it started detached) and best-effort reinstall before propagating failure. - subprocess.run([git, "-C", str(project_dir), "checkout", original_ref], check=False) - subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", str(project_dir)], - check=False, + except (subprocess.CalledProcessError, UpdateTimeout) as exc: + # A failed *or stalled* checkout or reinstall must not strand a previously working + # editable install on a half-applied detached release. Catching the timeout is what + # lets this rollback run at all. Restore the original branch (or exact commit when + # it started detached) and reinstall, then propagate the original failure. + print("Restoring the previous checkout...", file=sys.stderr) + # Only a checkout *we* terminated can have abandoned a lock; see _release_index_lock. + _release_index_lock( + project_dir, lock_existed, + ours=stage == "checkout" and isinstance(exc, UpdateTimeout), + ) + manual = ( + "Run `git -C %s checkout %s` and `%s -m pip install -e %s` to restore the " + "previous installation." % (project_dir, original_ref, sys.executable, project_dir) ) + try: + _run([git, "-C", str(project_dir), "checkout", original_ref], + "Restoring the previous checkout", _GIT_CHECKOUT_TIMEOUT_S, + check=True, capture=False, env=_git_env()) + _run( + [sys.executable, "-m", "pip", "install", "-e", str(project_dir)], + "Reinstalling the previous checkout", _PIP_INSTALL_TIMEOUT_S, + check=True, capture=False, + ) + except UpdateTimeout: + # Rollback itself stalled: name the two commands that finish it by hand + # rather than exiting on a tree the user does not know has moved. + print("Rollback did not finish. " + manual, file=sys.stderr) + except subprocess.CalledProcessError: + # The restore ran unchecked before, so a *failed* one was silent and main() + # still told the user the previous installation had been restored. It had not. + print("Rollback FAILED: the working tree may still be on %s. %s" + % (tag, manual), file=sys.stderr) raise print(f"Updated to {tag}.") @@ -243,22 +506,25 @@ def _pip_update(method: str, check_only: bool = False) -> None: if check_only: print(f"Latest stable Git release: {tag}") return - subprocess.run( + _run( [sys.executable, "-m", "pip", "install", "--upgrade", f"git+{remote}@{tag}#egg=engraphis"], - check=True) + "Installing the update from Git", _PIP_INSTALL_TIMEOUT_S, + check=True, capture=False) return version = LATEST_TAG[1:] if LATEST_TAG else "" target = "engraphis[server]" + ("==" + version if version else "") if check_only: - subprocess.run( + _run( [sys.executable, "-m", "pip", "install", "--dry-run", "--upgrade", target], - check=False, + "Checking the package index for a newer release", _PIP_RESOLVE_TIMEOUT_S, + capture=False, ) return - subprocess.run( + _run( [sys.executable, "-m", "pip", "install", "--upgrade", target], - check=True) + "Installing the update from the package index", _PIP_INSTALL_TIMEOUT_S, + check=True, capture=False) def _pipx_update(check_only: bool = False) -> None: @@ -266,20 +532,23 @@ def _pipx_update(check_only: bool = False) -> None: if check_only: if LATEST_TAG: target = "engraphis[server]==" + LATEST_TAG[1:] - subprocess.run( + _run( ["pipx", "runpip", "engraphis", "install", "--dry-run", "--upgrade", target], - check=False, + "Checking the package index for a newer release", _PIP_RESOLVE_TIMEOUT_S, + capture=False, ) else: print("pipx detected - run `pipx upgrade engraphis` to check for updates.") return if LATEST_TAG: - subprocess.run( + _run( ["pipx", "install", "--force", "engraphis[server]==" + LATEST_TAG[1:]], - check=True, + "Installing the update with pipx", _PIPX_TIMEOUT_S, + check=True, capture=False, ) return - subprocess.run(["pipx", "upgrade", "engraphis"], check=True) + _run(["pipx", "upgrade", "engraphis"], "Upgrading with pipx", _PIPX_TIMEOUT_S, + check=True, capture=False) def _docker_update(check_only: bool = False) -> None: @@ -310,10 +579,13 @@ def main(argv=None) -> None: if not LATEST_TAG: ap.error("version must be a stable MAJOR.MINOR.PATCH tag (for example v1.0.0)") - method = _detect_install() - print(f"Install method: {method}") - try: + # Inside the guard, not before it. ``_detect_install`` re-raises ``UpdateTimeout`` + # on purpose so a stalled `pip show` says which step hung and what to do about it; + # raising it outside this ``try`` threw that crafted message away and printed a + # traceback instead — the exact failure mode the exception exists to prevent. + method = _detect_install() + print(f"Install method: {method}") if method == "editable": _git_update(check_only=args.check) elif method == "pipx": @@ -332,6 +604,10 @@ def main(argv=None) -> None: 1, "Error: update failed; the previous installation was restored when possible.\n", ) + except UpdateTimeout as exc: + # Say which step stalled and what to do about it. Silence here is the bug: every + # network step used to be unbounded, so a stalled index simply never returned. + ap.exit(1, "Error: %s\n" % exc) if __name__ == "__main__": diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index cecdf4d..c6607e4 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -1,24 +1,54 @@ const { test, expect } = require('@playwright/test'); const AxeBuilder = require('@axe-core/playwright').default; -const hostedLicense = { - plan: 'local', - features: [], - cloud_managed: true, - trial_seconds: 259_200, - grace_seconds: 86_400, - grace_scope: 'existing authenticated local workspace writes only', - pro_upgrade_url: 'https://cloud.engraphis.test/pro', - team_upgrade_url: 'https://cloud.engraphis.test/team', - upgrade_url: 'https://cloud.engraphis.test/pricing', - trial: { used: false, trial_days: 3 }, +// The dashboard's entitlement vocabulary, mirrored from v2_api._FEATURE_LABELS. The +// license panel renders one row per key and ticks the ones `features` contains, so a +// missing key here would silently stop a paying customer's capability being asserted on. +const knownFeatures = { + analytics: 'Analytics', + automation: 'Automation', + consolidation: 'Auto Consolidation', + dreaming: 'Auto Dreaming', + export: 'Compliance export', + sync: 'Cloud Sync', + team: 'Team administration', }; +const proFeatures = ['analytics', 'automation', 'consolidation', 'dreaming', 'export', 'sync']; +const teamFeatures = [...proFeatures, 'team']; + +function licenseFor(plan, features) { + return { + plan, + features, + known_features: knownFeatures, + cloud_managed: true, + cloud_access_active: plan !== 'local', + trial_seconds: 259_200, + grace_seconds: 86_400, + grace_scope: 'existing authenticated local workspace writes only', + pro_upgrade_url: 'https://cloud.engraphis.test/pro', + team_upgrade_url: 'https://cloud.engraphis.test/team', + upgrade_url: 'https://cloud.engraphis.test/pricing', + trial: { used: false, trial_days: 3 }, + }; +} + +// A FREE customer. Every test below used to run against this one payload, which is why a +// paying customer being shown a lock badge survived all the way to launch day. +const hostedLicense = licenseFor('local', []); +const proLicense = licenseFor('pro', proFeatures); +const teamLicense = licenseFor('team', teamFeatures); +// A Team subscription whose billing has lapsed: the plan is still Team, but the control +// plane has withdrawn every feature. +const lapsedTeamLicense = { ...licenseFor('team', []), cloud_access_active: false }; + async function mockLocalClient( page, cloudStatus = 402, syncRunStatus = null, automationPostStatus = null, + license = hostedLicense, ) { const calls = []; let syncLast = null; @@ -36,7 +66,7 @@ async function mockLocalClient( if (path === '/bootstrap') { body = { - license: hostedLicense, + license, workspaces: [], embedder: { semantic: true }, }; @@ -51,7 +81,7 @@ async function mockLocalClient( by_type: {}, }; } else if (path === '/license') { - body = hostedLicense; + body = license; } else if (path === '/auth/state') { body = { enabled: false, @@ -151,14 +181,14 @@ test('Cloud Sync denial returns an unlicensed installation to the hosted upgrade await page.getByRole('button', { name: 'Sync now' }).click(); const sync = page.locator('#sync-body'); - await expect(sync).toContainText('Cloud Sync runs in Engraphis Pro Cloud'); + await expect(sync).toContainText('Unlock Cloud Sync and more'); await expect(sync.getByRole('link', { name: 'Start hosted Pro trial' })) .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro'); await expect(calls.some(call => call.path === '/sync/run' && call.method === 'POST')).toBe(true); await page.reload(); await openView(page, 'settings'); - await expect(page.locator('#sync-body')).toContainText('Cloud Sync runs in Engraphis Pro Cloud'); + await expect(page.locator('#sync-body')).toContainText('Unlock Cloud Sync and more'); await expect(page.getByRole('button', { name: 'Try Cloud Sync again' })).toBeVisible(); calls.setSyncRunStatus(200); @@ -251,7 +281,102 @@ test('local dashboard exposes hosted Pro and Team CTAs without local commercial expect(errors).toEqual([]); }); -for (const cloudStatus of [401, 402, 501]) { +// ── paying customers ───────────────────────────────────────────────────────────────── +// Until these existed, every test in this file ran against one FREE payload, so the whole +// end-to-end suite never once rendered a customer who had paid. That is exactly how a +// paying customer being shown a lock badge on a feature they had bought survived to +// launch day. `#nav-*-lock` is the element that carried the wrong badge, so it is what +// these assert on directly. +const navLocks = { + analytics: '#nav-analytics-lock', + automation: '#nav-automation-lock', + team: '#nav-team-lock', +}; + +test('a free installation is locked out of every hosted capability', async ({ page }) => { + const errors = recordBrowserErrors(page); + await mockLocalClient(page); + await page.goto('/'); + + // The baseline the paid cases below are the counterpart to: all three locks drawn. + await expect(page.getByLabel('Open hosted plan settings')).toHaveText('LOCAL'); + await expect(page.locator(navLocks.analytics)).toHaveText('PRO'); + await expect(page.locator(navLocks.automation)).toHaveText('PRO'); + await expect(page.locator(navLocks.team)).toHaveText('TEAM'); + expect(errors).toEqual([]); +}); + +test('a paying Team customer sees TEAM with Team administration unlocked', async ({ page }) => { + const errors = recordBrowserErrors(page); + await mockLocalClient(page, 200, null, null, teamLicense); + await page.goto('/'); + + // The badge settles before the locks: dashboard.js sets both inside loadLicense(), + // so asserting it first means the empty locks below cannot be read pre-render. + await expect(page.getByLabel('Open hosted plan settings')).toHaveText('TEAM'); + for (const [feature, selector] of Object.entries(navLocks)) { + await expect(page.locator(selector), `${feature} is still locked for a Team customer`) + .toHaveText(''); + } + + await openView(page, 'settings'); + const licensePanel = page.locator('.settings-license-panel'); + await expect(licensePanel.getByText('TEAM', { exact: true })).toBeVisible(); + // Every capability Team grants is ticked, including the two the server folds into + // `automation` and this client names separately. + for (const label of Object.values(knownFeatures)) { + await expect(licensePanel).toContainText(`✓ ${label}`); + } + await expect(licensePanel).not.toContainText('○'); + // A paying customer is offered the account portal, never another trial. + await expect(licensePanel.getByRole('link', { name: 'Open Team Cloud' })) + .toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team'); + await expect(licensePanel.getByRole('button', { name: 'Start hosted Team trial' })) + .toHaveCount(0); + await expect(licensePanel.getByRole('button', { name: 'Start hosted Pro trial' })) + .toHaveCount(0); + expect(errors).toEqual([]); +}); + +test('a paying Pro customer keeps only the Team upsell', async ({ page }) => { + const errors = recordBrowserErrors(page); + await mockLocalClient(page, 200, null, null, proLicense); + await page.goto('/'); + + await expect(page.getByLabel('Open hosted plan settings')).toHaveText('PRO'); + await expect(page.locator(navLocks.analytics)).toHaveText(''); + await expect(page.locator(navLocks.automation)).toHaveText(''); + await expect(page.locator(navLocks.team)).toHaveText('TEAM'); + + await openView(page, 'settings'); + const licensePanel = page.locator('.settings-license-panel'); + await expect(licensePanel.getByText('PRO', { exact: true })).toBeVisible(); + await expect(licensePanel).toContainText(`✓ ${knownFeatures.analytics}`); + await expect(licensePanel).toContainText(`✓ ${knownFeatures.consolidation}`); + await expect(licensePanel).toContainText(`○ ${knownFeatures.team}`); + expect(errors).toEqual([]); +}); + +test('a lapsed Team subscription is sent to billing, not to a spent trial', async ({ page }) => { + const errors = recordBrowserErrors(page); + await mockLocalClient(page, 402, null, null, lapsedTeamLicense); + await page.goto('/'); + + // The plan name survives an inactive entitlement so the CTA stays the account portal; + // the locks come back because the control plane has withdrawn the features. + await expect(page.getByLabel('Open hosted plan settings')).toHaveText('TEAM'); + await expect(page.locator(navLocks.team)).toHaveText('TEAM'); + await expect(page.locator(navLocks.analytics)).toHaveText('PRO'); + + await openView(page, 'settings'); + const licensePanel = page.locator('.settings-license-panel'); + await expect(licensePanel.getByRole('link', { name: 'Open Team Cloud' })).toBeVisible(); + await expect(licensePanel.getByRole('button', { name: 'Start hosted Team trial' })) + .toHaveCount(0); + expect(errors).toEqual([]); +}); + +for (const cloudStatus of [402, 501]) { test(`Analytics and Automation defer to cloud proxy status ${cloudStatus}`, async ({ page }) => { const errors = recordBrowserErrors(page); const calls = await mockLocalClient(page, cloudStatus); @@ -264,14 +389,13 @@ for (const cloudStatus of [401, 402, 501]) { () => calls.filter(call => call.path === '/analytics').length, ).toBeGreaterThan(analyticsBefore); const analytics = page.locator('#analytics-body'); - await expect(analytics).toContainText('Analytics runs in Engraphis Pro Cloud'); + await expect(analytics).toContainText('Unlock Analytics and more'); await expect(analytics).toContainText('exactly 3 active days'); - await expect(analytics).toContainText( - 'Local-only write grace is separate, capped at 24 hours, and never extends cloud access.', - ); + await expect(analytics).toContainText('$10/month or $100/year'); + await expect(analytics).toContainText('Hosted Cloud Sync across your installations'); await expect(analytics.getByRole('link', { name: 'Start hosted Pro trial' })) .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro'); - await expect(analytics.getByRole('link', { name: 'View Pro plans' })) + await expect(analytics.getByRole('link', { name: 'Purchase Pro license' })) .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro'); await expect(page.locator('#an-lock')).toHaveText('PRO'); @@ -282,15 +406,14 @@ for (const cloudStatus of [401, 402, 501]) { ).toBeGreaterThan(automationBefore); const automation = page.locator('#automation-body'); await expect(automation).toContainText( - 'Automation, Auto Consolidation, and Auto Dreaming runs in Engraphis Pro Cloud', + 'Unlock Automation, Auto Consolidation, and Auto Dreaming and more', ); await expect(automation).toContainText('exactly 3 active days'); - await expect(automation).toContainText( - 'Local-only write grace is separate, capped at 24 hours, and never extends cloud access.', - ); + await expect(automation).toContainText('$10/month or $100/year'); + await expect(automation).toContainText('Auto Dreaming with reviewable managed proposals'); await expect(automation.getByRole('link', { name: 'Start hosted Pro trial' })) .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro'); - await expect(automation.getByRole('link', { name: 'View Pro plans' })) + await expect(automation.getByRole('link', { name: 'Purchase Pro license' })) .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro'); await expect(page.locator('#au-lock')).toHaveText('PRO'); @@ -300,21 +423,47 @@ for (const cloudStatus of [401, 402, 501]) { }); } -test('Analytics explains the local managed-compute consent step', async ({ page }) => { +// 401 is a credential problem, not a billing one: the cloud maps it to "the cloud session +// expired or was revoked; connect again". Selling Pro to a customer who already owns it -- +// and who only needs to reconnect -- is the regression this guards. +test('An expired cloud session asks the customer to reconnect, not to buy', async ({ page }) => { + const errors = recordBrowserErrors(page); + await mockLocalClient(page, 401); + await page.goto('/'); + + await openView(page, 'analytics'); + const analytics = page.locator('#analytics-body'); + await expect(analytics).not.toContainText('Unlock Analytics and more'); + await expect(analytics.getByRole('link', { name: 'Purchase Pro license' })).toHaveCount(0); + + await openView(page, 'automation'); + const automation = page.locator('#automation-body'); + await expect(automation).not.toContainText('Unlock Automation'); + await expect(automation.getByRole('link', { name: 'Purchase Pro license' })).toHaveCount(0); + + expect(errors).toEqual([]); +}); + +// Consent travels with the cloud account: connecting an installation accepts the terms +// covering managed compute. A 409 consent_required therefore means "this installation is +// not connected", never "go and hand-edit an environment variable", and it must never be +// mistaken for an unpaid invoice and answered with the Pro purchase panel. +test('Analytics explains that managed compute follows the cloud connection', async ({ page }) => { const errors = recordBrowserErrors(page); await mockLocalClient(page, 409); await page.goto('/'); await openView(page, 'analytics'); const analytics = page.locator('#analytics-body'); - await expect(analytics).toContainText('needs your explicit permission'); - await expect(analytics).toContainText('ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1'); - await expect(analytics).toContainText('restart Engraphis'); + await expect(analytics).toContainText('managed compute is turned off for this installation'); + await expect(analytics).toContainText('Connect this installation to Engraphis Cloud'); + await expect(analytics).not.toContainText('ENGRAPHIS_MANAGED_COMPUTE_CONSENT'); + await expect(analytics).not.toContainText('Purchase Pro license'); await expect(page.locator('#an-lock')).toHaveText('CLOUD'); expect(errors).toEqual([]); }); -test('Automation policy save explains the managed-compute consent step', async ({ page }) => { +test('Automation policy save explains that managed compute follows the cloud connection', async ({ page }) => { const errors = recordBrowserErrors(page); await mockLocalClient(page, 200, null, 409); await page.goto('/'); @@ -323,8 +472,9 @@ test('Automation policy save explains the managed-compute consent step', async ( await page.locator('#au-enabled').check(); await page.getByRole('button', { name: 'Save hosted policy' }).click(); const result = page.locator('#au-result'); - await expect(result).toContainText('needs your explicit permission'); - await expect(result).toContainText('ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1'); - await expect(result).toContainText('restart Engraphis'); + await expect(result).toContainText('managed compute is turned off for this installation'); + await expect(result).toContainText('Connect this installation to Engraphis Cloud'); + await expect(result).not.toContainText('ENGRAPHIS_MANAGED_COMPUTE_CONSENT'); + await expect(result).not.toContainText('Purchase Pro license'); expect(errors).toEqual([]); }); diff --git a/tests/test_client_launch.py b/tests/test_client_launch.py new file mode 100644 index 0000000..b8ae237 --- /dev/null +++ b/tests/test_client_launch.py @@ -0,0 +1,462 @@ +"""Launch guards for the public client: no silent hangs, no unvetted probe, no false locks. + +Each test pins a defect that reached a customer's machine: + +* ``engraphis-update`` shelled out to git/pip/pipx with no ``timeout=`` and + ``capture_output=True``, so a stalled index or unreachable remote hung forever with + nothing on screen — and a stall *after* the release tag was checked out skipped the + rollback and left a wedged half-upgrade; +* the update probe was the one credential-path-adjacent HTTP call using a plain + ``build_opener``, skipping the repo's SSRF / DNS-rebinding vetting on an endpoint that + ``ENGRAPHIS_UPDATE_URL`` makes operator-controllable; and +* ``/api/license`` always returned ``features: []``, so a paying Pro or Team customer saw + "PRO"/"TEAM" lock badges on the features they had just bought. + +*Which* plan that feature list is computed from is pinned separately, in +tests/test_hosted_plan_resolution.py: this file owns the plan → feature table and the +dashboard's lock-badge loop; that one owns how the plan itself is learned from the control +plane, cached, and degraded offline. +""" +from __future__ import annotations + +import ast +import inspect +import re +import subprocess +import sys +import urllib.error +from pathlib import Path + +import pytest + +# ``engraphis.routes.v2_api`` imports FastAPI, which the numpy-only core floor job does not +# install. Skip rather than error at collection, matching the rest of the suite. +pytest.importorskip("fastapi", reason="full-stack extra not installed") + +from engraphis import update_check # noqa: E402 +from engraphis.routes import v2_api # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] +UPDATER = REPO_ROOT / "scripts" / "update.py" +DASHBOARD_JS = REPO_ROOT / "engraphis" / "static" / "dashboard.js" + +# The private control plane's plan→feature table, mirrored from (read-only) +# engraphis-cloud/engraphis_cloud/entitlements.py PLAN_FEATURES. A drift here means a +# purchased capability silently renders as locked. +SERVER_PLAN_FEATURES = { + "free": set(), + "pro": {"analytics", "automation", "export", "sync"}, + "team": {"analytics", "automation", "export", "sync", "team"}, +} +# Named separately by this client's commercial manifest; the server grants both under +# ``automation``, so any plan granting ``automation`` must grant these too. +CLIENT_AUTOMATION_ALIASES = {"consolidation", "dreaming"} + + +# ── (1) the updater must never hang on a stalled remote ─────────────────────── +_SPAWNERS = ("subprocess.run", "subprocess.call", "subprocess.check_output", + "subprocess.check_call", "subprocess.Popen") + + +def _drains_under_a_deadline(scope: ast.AST) -> bool: + """True when *scope* reads its child's pipe with an explicit budget on the read.""" + + return any( + isinstance(node, ast.Call) + and ast.unparse(node.func).endswith(".communicate") + and any(keyword.arg == "timeout" for keyword in node.keywords) + for node in ast.walk(scope) + ) + + +def test_the_updater_runs_no_unbounded_subprocess() -> None: + """Every shell-out is bounded — including the ones whose output must be parsed. + + ``subprocess.run(capture_output=True, timeout=N)`` does not enforce ``N``: once the + budget expires CPython kills the direct child and then drains the pipes with an + *unbounded* ``communicate()``, which waits for every inherited write handle to close — + grandchildren such as ``git-remote-https`` included. So a ``timeout=`` keyword is not + on its own proof of a bound. A ``Popen`` is accepted only when the same function bounds + the read that follows it; anything else is the old silent hang wearing a budget. + """ + + tree = ast.parse(UPDATER.read_text(encoding="utf-8")) + enclosing = {} + for scope in ast.walk(tree): + if isinstance(scope, ast.FunctionDef): + for node in ast.walk(scope): + enclosing[node] = scope + + unbounded = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + target = ast.unparse(node.func) + if target not in _SPAWNERS: + continue + if any(kw.arg == "timeout" for kw in node.keywords): + continue + scope = enclosing.get(node) + if target == "subprocess.Popen" and scope is not None: + if _drains_under_a_deadline(scope): + continue + unbounded.append(target) + + assert unbounded == [], "unbounded subprocess call(s) in scripts/update.py: %s" % unbounded + + +def test_the_updater_helper_makes_a_timeout_impossible_to_omit() -> None: + """``timeout`` has no default, so a new call site cannot silently be unbounded.""" + + import scripts.update as updater + + for helper in (updater._run, updater._run_captured): + timeout = inspect.signature(helper).parameters["timeout"] + assert timeout.default is inspect.Parameter.empty, helper.__name__ + + tree = ast.parse(UPDATER.read_text(encoding="utf-8")) + call_sites = [ + node for node in ast.walk(tree) + if isinstance(node, ast.Call) + and getattr(node.func, "id", "") in ("_run", "_run_captured") + ] + assert call_sites, "expected the updater to shell out through _run" + for node in call_sites: + supplied = len(node.args) + len(node.keywords) + assert supplied >= 3, ast.unparse(node) + + +def test_a_stalled_remote_aborts_instead_of_waiting_forever(monkeypatch) -> None: + """The failing call is abandoned at its budget and reported, not awaited. + + ``ls-remote`` output has to be parsed, so this step cannot simply stop capturing. It + therefore has to kill the *whole* process tree before it reads the pipe again, and + bound that read too — otherwise the surviving ``git-remote-https`` still owns the write + handle and the "budget" expires into an indefinite wait. + """ + + import scripts.update as updater + + seen = {"drains": []} + + class _Stalled: + pid = 4242 + returncode = None + + def communicate(self, timeout=None): + seen["drains"].append(timeout) + raise subprocess.TimeoutExpired("git", timeout) + + def _spawn(cmd, **kwargs): + seen["cmd"] = list(cmd) + seen["stdin"] = kwargs.get("stdin") + seen["env"] = kwargs.get("env") + return _Stalled() + + monkeypatch.setattr(updater.subprocess, "Popen", _spawn) + monkeypatch.setattr( + updater.subprocess, "run", + lambda *a, **k: pytest.fail("a network query must not use the unenforceable path"), + ) + monkeypatch.setattr( + updater, "_kill_process_tree", + lambda process: seen.__setitem__("killed", process.pid), + ) + + with pytest.raises(updater.UpdateTimeout) as excinfo: + updater._remote_latest_tag("/usr/bin/git", "https://example.test/engraphis.git") + + assert isinstance(seen["drains"][0], (int, float)) and seen["drains"][0] > 0 + assert seen["killed"] == 4242, "the grandchildren must die before the pipe is re-read" + assert len(seen["drains"]) == 2 and seen["drains"][1] > 0, "the drain is bounded too" + # Nothing may stop to ask a human for a password on a machine nobody is watching. + assert seen["stdin"] is subprocess.DEVNULL + assert seen["env"]["GIT_TERMINAL_PROMPT"] == "0" + # A hang is only survivable if the user is told which step stalled and what to do. + message = str(excinfo.value) + assert "timed out" in message + assert "engraphis-update" in message + + +def test_main_reports_a_stalled_step_and_exits(monkeypatch, capsys) -> None: + """The stall must surface as a non-zero exit with copy, not an unhandled traceback.""" + + import scripts.update as updater + + monkeypatch.setattr(updater, "_detect_install", lambda: "pypi") + + class _Stalled: + pid = 5150 + returncode = None + + def communicate(self, timeout=None): + raise subprocess.TimeoutExpired("pip", timeout) + + monkeypatch.setattr(updater.subprocess, "Popen", lambda cmd, **kwargs: _Stalled()) + monkeypatch.setattr(updater, "_kill_process_tree", lambda process: None) + + with pytest.raises(SystemExit) as excinfo: + updater.main([]) + + assert excinfo.value.code == 1 + assert "timed out" in capsys.readouterr().err + + +def test_a_stalled_reinstall_still_rolls_back_the_checkout(monkeypatch, tmp_path) -> None: + """The regression that wedges an install. + + By the time the reinstall runs, the tree is already detached onto the new release + tag. An unbounded reinstall therefore hangs *after* the destructive step and the + rollback below it is never reached, stranding a working editable install on a + half-applied upgrade. The timeout has to be catchable for the rollback to run at all. + """ + + import scripts.update as updater + + project = tmp_path / "engraphis" + (project / ".git").mkdir(parents=True) + monkeypatch.setattr(updater, "LATEST_TAG", "") + monkeypatch.setattr(updater.shutil, "which", lambda name: "/usr/bin/git") + + calls = [] + reinstalls = {"n": 0} + + class _Proc: + pid = 909 + + def __init__(self, returncode=0, stdout="", stall=False): + self.returncode = returncode + self._stdout = stdout + self._stall = stall + + def communicate(self, timeout=None): + assert timeout, "every step must carry a budget" + if self._stall: + raise subprocess.TimeoutExpired("cmd", timeout) + return self._stdout, None + + def _fake_popen(cmd, **kwargs): + cmd = list(cmd) + calls.append(cmd) + if cmd[:4] == [sys.executable, "-m", "pip", "show"]: + return _Proc(stdout="Editable project location: %s\n" % project) + if cmd[:5] == [sys.executable, "-m", "pip", "install", "-e"]: + reinstalls["n"] += 1 + # the upgrade reinstall stalls; the rollback reinstall succeeds + return _Proc(stall=reinstalls["n"] == 1) + if "ls-remote" in cmd: + assert kwargs.get("stdout") is subprocess.PIPE, "the refs query must be parsed" + return _Proc(stdout="%s\trefs/tags/v9.9.9\n" % ("b" * 40)) + if "rev-parse" in cmd: + return _Proc(stdout="a" * 40 + "\n") + if "symbolic-ref" in cmd: + return _Proc(stdout="main\n") + if "rev-list" in cmd: + return _Proc(stdout="b" * 40 + "\n") + if "status" in cmd: + return _Proc(stdout="") + return _Proc() + + monkeypatch.setattr(updater.subprocess, "Popen", _fake_popen) + monkeypatch.setattr( + updater.subprocess, "run", + lambda *a, **k: pytest.fail("no step may spawn through the unenforceable path"), + ) + # Never let a fabricated pid reach the real ``taskkill``/``killpg``. + monkeypatch.setattr(updater, "_kill_process_tree", lambda process: None) + + with pytest.raises(updater.UpdateTimeout): + updater._git_update() + + checkouts = [cmd for cmd in calls if "checkout" in cmd] + assert ["/usr/bin/git", "-C", str(project), "checkout", "tags/v9.9.9"] in checkouts + # The rollback ran despite the stall, and restored the original branch. + assert ["/usr/bin/git", "-C", str(project), "checkout", "main"] in checkouts + assert reinstalls["n"] == 2, "the previous checkout must be reinstalled" + + +# ── (2) the update probe must use the vetted connector ──────────────────────── +def test_update_probe_uses_the_pinned_opener_with_a_timeout(monkeypatch) -> None: + """``ENGRAPHIS_UPDATE_URL`` makes this endpoint operator-controllable. + + A plain ``build_opener`` dials whatever the hostname resolves to at connect time, so + it neither rejects private/reserved targets nor closes the DNS-rebinding window + between the scheme check and the connect. + """ + + used = {} + + def _fake_pinned(*handlers): + used["handlers"] = handlers + + class _Opener: + def open(self, request, timeout=None): + used["timeout"] = timeout + raise urllib.error.URLError("blocked") + + return _Opener() + + def _forbidden(*args, **kwargs): + raise AssertionError("the update probe must not build an unvetted opener") + + monkeypatch.setattr(update_check, "build_pinned_https_opener", _fake_pinned) + monkeypatch.setattr(update_check.urllib.request, "build_opener", _forbidden) + + assert update_check._fetch("https://mirror.example.test/latest.json", 4.0) is None + assert used["handlers"], "the no-redirect handler must still be installed" + assert used["timeout"] == 4.0 + + +def test_update_probe_imports_the_repo_connector() -> None: + """Pin the import so a refactor cannot quietly fall back to urllib's default.""" + + assert update_check.build_pinned_https_opener is not None + source = (REPO_ROOT / "engraphis" / "update_check.py").read_text(encoding="utf-8") + assert "urllib.request.build_opener(" not in source + + +# ── (3) a paying customer must not see a lock on what they bought ───────────── +@pytest.mark.parametrize("plan", ["pro", "team"]) +def test_a_paid_plan_grants_a_non_empty_feature_list(plan) -> None: + features = v2_api.entitled_features(plan) + + assert features, "a paid plan must grant features" + assert SERVER_PLAN_FEATURES[plan].issubset(features) + # The server folds these into ``automation``; the client names them separately. + assert CLIENT_AUTOMATION_ALIASES.issubset(features) + + +@pytest.mark.parametrize("plan", ["free", "local", "", None, "enterprise", "unknown", "pro-plus"]) +def test_an_unpaid_or_unrecognised_plan_grants_nothing(plan) -> None: + assert v2_api.entitled_features(plan) == [] + + +@pytest.mark.parametrize("spelling", ["PRO", " pro ", "Pro", "\tpro\n"]) +def test_plan_lookup_is_case_and_whitespace_insensitive(spelling) -> None: + """The control plane emits lowercase plans; a caller must not be able to drift.""" + + assert v2_api.entitled_features(spelling) == v2_api.entitled_features("pro") + + +def test_team_grants_everything_pro_does_plus_team() -> None: + pro, team = set(v2_api.entitled_features("pro")), set(v2_api.entitled_features("team")) + + assert pro < team + assert team - pro == {"team"} + + +def test_client_never_advertises_a_feature_the_server_cannot_grant() -> None: + server_keys = set().union(*SERVER_PLAN_FEATURES.values()) + for plan in ("pro", "team"): + extra = set(v2_api.entitled_features(plan)) - server_keys + assert extra <= CLIENT_AUTOMATION_ALIASES, extra + + +def test_license_route_unlocks_a_connected_paying_customer(monkeypatch) -> None: + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "team") + + payload = v2_api.get_license() + + assert payload["plan"] == "team" + assert payload["features"], "a paying customer must not be sent an empty feature list" + assert "team" in payload["features"] + + +def test_license_route_leaves_the_free_local_core_locked(monkeypatch) -> None: + monkeypatch.delenv("ENGRAPHIS_CLOUD_PLAN", raising=False) + monkeypatch.setattr(v2_api, "_hosted_plan", lambda: "local") + + payload = v2_api.get_license() + + assert payload["plan"] == "local" + assert payload["features"] == [] + + +def test_an_unconnected_installation_is_the_free_local_core(monkeypatch) -> None: + from engraphis import cloud_session + + monkeypatch.delenv("ENGRAPHIS_CLOUD_PLAN", raising=False) + monkeypatch.setattr(cloud_session, "configured", lambda **kw: False) + + assert v2_api._hosted_plan() == "local" + + +def test_a_connected_installation_reports_a_paid_plan(monkeypatch) -> None: + """The fallback before the control plane has ever answered. + + ``pro`` is the smallest paid plan, so a connected customer is never shown the free + local core while the authoritative entitlement is still unknown. It is a floor, not + the answer: once ``GET /v1/entitlements/{org}`` has been read, the cached plan wins + (tests/test_hosted_plan_resolution.py). + """ + + from engraphis import cloud_session + + monkeypatch.delenv("ENGRAPHIS_CLOUD_PLAN", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_CONTROL_URL", raising=False) + monkeypatch.setattr(cloud_session, "configured", lambda **kw: True) + + assert v2_api._hosted_plan() == "pro" + # With no endpoint configured there is nothing to dial, so nothing is scheduled. + assert v2_api._entitlement_refreshing is False + + +def test_an_unreadable_cloud_session_never_breaks_the_dashboard(monkeypatch) -> None: + """``/api/license`` is on the ``/api/bootstrap`` path; a badge must not 500 the boot.""" + + from engraphis import cloud_session + + def _boom(**kwargs): + raise cloud_session.CloudSessionError("temporarily unreadable") + + monkeypatch.delenv("ENGRAPHIS_CLOUD_PLAN", raising=False) + monkeypatch.setattr(cloud_session, "configured", _boom) + + assert v2_api._hosted_plan() == "local" + assert v2_api.get_license()["features"] == [] + + +def test_license_route_emits_every_field_the_dashboard_reads(monkeypatch) -> None: + """The dashboard read these off the license payload; no route ever emitted them.""" + + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "pro") + + payload = v2_api.get_license() + + for field in ("plan", "features", "known_features", "is_trial", "trial", + "upgrade_url", "pro_upgrade_url", "team_upgrade_url"): + assert field in payload, field + assert "used" in payload["trial"] + # Pro and Team bill through separate checkout targets. + assert payload["pro_upgrade_url"] and payload["team_upgrade_url"] + # Every advertised key must be renderable, and every grantable key advertised. + assert set(payload["features"]) <= set(payload["known_features"]) + assert set(v2_api.entitled_features("team")) == set(payload["known_features"]) + + +def test_dashboard_lock_badges_clear_for_a_paid_plan(monkeypatch) -> None: + """Close the loop on the JS: ``locked = !features.includes(f)``. + + The dashboard's three gated nav items are read straight out of the shipped asset so a + renamed feature key cannot re-lock a paid customer without failing here. + """ + + script = DASHBOARD_JS.read_text(encoding="utf-8") + block = script[script.index("function updateFeatureLocks()"):] + block = block[:block.index("\n}")] + gated = re.findall(r"apply\('[^']+',\s*'([^']+)'", block) + + assert set(gated) == {"analytics", "automation", "team"}, gated + + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "team") + team_features = v2_api.get_license()["features"] + for feature in gated: + assert feature in team_features, "Team still renders a lock on %s" % feature + + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "pro") + pro_features = v2_api.get_license()["features"] + assert {"analytics", "automation"} <= set(pro_features) + assert "team" not in pro_features # Team upsell stays visible on a Pro plan + + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "free") + assert v2_api.get_license()["features"] == [] diff --git a/tests/test_cloud_features.py b/tests/test_cloud_features.py index 3a84ffd..484ffa5 100644 --- a/tests/test_cloud_features.py +++ b/tests/test_cloud_features.py @@ -34,18 +34,94 @@ def _service() -> MemoryService: return service -def test_snapshot_requires_explicit_consent(monkeypatch) -> None: +def _cloud_session(monkeypatch, connected: bool) -> list: + """Pin whether this installation has a cloud session, and record how it was asked.""" + + calls: list = [] + + def configured(**kwargs): + calls.append(kwargs) + return connected + + monkeypatch.setattr(cloud_features, "cloud_session_configured", configured) + return calls + + +def test_a_local_installation_with_no_cloud_account_never_uploads(monkeypatch) -> None: + """No account means no agreement to rely on, so a purely local install is denied.""" + monkeypatch.delenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", raising=False) + _cloud_session(monkeypatch, connected=False) assert cloud_features.managed_compute_consent() is False - with pytest.raises(CloudFeatureError, match="Managed compute is off") as captured: + with pytest.raises( + CloudFeatureError, match="Managed compute is turned off" + ) as captured: build_managed_snapshot(_service(), "acme") assert captured.value.status == 409 + assert captured.value.code == "consent_required" + # The customer is never told to hand-edit an environment variable. + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT" not in str(captured.value) + + +def test_connecting_to_the_cloud_is_itself_the_managed_compute_consent( + monkeypatch, +) -> None: + """Connecting accepts the terms covering managed compute; nothing else is asked for.""" + + monkeypatch.delenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", raising=False) + calls = _cloud_session(monkeypatch, connected=True) + + assert cloud_features.managed_compute_consent() is True + + _, snapshot = build_managed_snapshot(_service(), "acme") + assert snapshot["managed_compute_consent"] is True + # A session without a compute URL is still an account that accepted the terms. + assert calls and all(call == {"require_compute": False} for call in calls) + + +def test_a_connected_installation_can_be_opted_back_out(monkeypatch) -> None: + """``=0`` is the operator override that withdraws a connected installation.""" + + monkeypatch.setenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", "0") + _cloud_session(monkeypatch, connected=True) + + assert cloud_features.managed_compute_consent() is False + with pytest.raises(CloudFeatureError, match="Managed compute is turned off"): + build_managed_snapshot(_service(), "acme") + + +@pytest.mark.parametrize("blank", ["", " "]) +def test_a_blank_override_defers_to_the_cloud_account(monkeypatch, blank) -> None: + """An empty variable is not an answer — it must not read as an opt-out.""" + + monkeypatch.setenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", blank) + _cloud_session(monkeypatch, connected=True) + assert cloud_features.managed_compute_consent() is True + + _cloud_session(monkeypatch, connected=False) + assert cloud_features.managed_compute_consent() is False + + +def test_consent_is_never_the_reason_a_dashboard_fails_to_render(monkeypatch) -> None: + """An unreadable session file denies consent instead of raising into the view.""" + + monkeypatch.delenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", raising=False) + + def explode(**kwargs): + raise OSError("session state is unreadable") + + monkeypatch.setattr(cloud_features, "cloud_session_configured", explode) + + assert cloud_features.managed_compute_consent() is False def test_snapshot_accepts_environment_opt_in(monkeypatch) -> None: + """A truthy override forces consent on even with no cloud session at all.""" + monkeypatch.setenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", "yes") + _cloud_session(monkeypatch, connected=False) _, snapshot = build_managed_snapshot(_service(), "acme") @@ -83,7 +159,7 @@ def fail_access(*args, **kwargs): def test_explicit_false_consent_cannot_be_overridden_by_environment(monkeypatch) -> None: monkeypatch.setenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", "1") - with pytest.raises(CloudFeatureError, match="Managed compute is off"): + with pytest.raises(CloudFeatureError, match="Managed compute is turned off"): build_managed_snapshot(_service(), "acme", consent=False) diff --git a/tests/test_cloud_session.py b/tests/test_cloud_session.py index 533604b..c5334e3 100644 --- a/tests/test_cloud_session.py +++ b/tests/test_cloud_session.py @@ -299,6 +299,86 @@ def open(self, request, timeout): assert "private network detail" not in str(caught.value) +def _downgrade_state(monkeypatch, *, body: dict) -> dict: + """A saved Team session, refreshed against a control plane that answers *body*.""" + + monkeypatch.delenv("ENGRAPHIS_CLOUD_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_CONTROL_URL", raising=False) + state = { + "control_url": "https://control.example.test", + "organization_id": "org_1", + "refresh_credential": "old-refresh", + "token_subject": "member", + "plan": "team", + "cloud_access_active": True, + "cloud_features": ["analytics", "automation", "export", "sync", "team"], + } + + def _replace(value: dict) -> None: + state.clear() + state.update(value) + + monkeypatch.setattr(cloud_session, "_load", lambda: dict(state)) + monkeypatch.setattr(cloud_session, "_save", _replace) + monkeypatch.setattr( + cloud_session, "validate_cloud_base_url", lambda value: value.rstrip("/") + ) + monkeypatch.setattr(cloud_session, "_post_refresh", lambda *_args: dict({ + "access_token": "short-lived-access", + "organization_id": "org_1", + "refresh_credential": "rotated-refresh", + "token_subject": "member", + }, **body)) + cloud_session.access_for_workspace("ws", require_compute=False) + return state + + +def test_a_plan_downgrade_does_not_carry_the_previous_plans_features(monkeypatch) -> None: + """Team -> Pro must not leave ``team`` in the persisted grant list. + + ``_declared_entitlement`` omits ``cloud_features`` whenever the body carries no feature + list, so merging it onto the saved record replaced ``plan`` while the *old* list + survived. ``/api/license`` then reported ``plan: "pro"`` with ``team`` still granted and + the Team tab stayed unlocked indefinitely — a paid capability handed out for free. + """ + + state = _downgrade_state(monkeypatch, body={ + "plan": "pro", "cloud_access_active": True, + }) + + assert state["plan"] == "pro" + assert "cloud_features" not in state, "the Team grants outlived the Team plan" + # With no stale list left, the client's own plan table answers for Pro. + assert "team" not in cloud_session.saved_entitlement().get("cloud_features", []) + + +def test_a_downgrade_that_declares_its_own_features_is_taken_verbatim(monkeypatch) -> None: + """A control plane that does send a list stays authoritative over the plan table.""" + + state = _downgrade_state(monkeypatch, body={ + "plan": "pro", "cloud_access_active": True, + "cloud_features": ["analytics", "sync"], + }) + + assert state["plan"] == "pro" + assert state["cloud_features"] == ["analytics", "sync"] + + +def test_a_refresh_reconfirming_the_same_plan_keeps_its_saved_features(monkeypatch) -> None: + """Only a plan *change* drops the list; re-confirming Team must not blank it. + + Otherwise every token rotation against a control plane that reports the plan but not + the features would quietly demote a Team customer to the generic table. + """ + + state = _downgrade_state(monkeypatch, body={ + "plan": "team", "cloud_access_active": True, + }) + + assert state["plan"] == "team" + assert "team" in state["cloud_features"] + + @pytest.mark.parametrize("subject", ["admin", "", "device member"]) def test_environment_refresh_rejects_invalid_subject(monkeypatch, subject) -> None: monkeypatch.setenv("ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", "env-refresh") @@ -312,3 +392,97 @@ def test_environment_refresh_rejects_invalid_subject(monkeypatch, subject) -> No else: with pytest.raises(cloud_session.CloudSessionError, match="device.*member"): cloud_session.configured(require_compute=False) + + +def test_record_billing_denial_stamps_every_denial_including_the_repeat() -> None: + """A 402 is an authoritative entitlement read, so it has to advance the clock. + + ``entitlement_checked_at`` is what ``_session_entitlement`` reports as ``fetched_at`` + and what the dashboard's 15-minute refresh interval throttles on. The denial used to + clear the grants without touching it -- and to write nothing whatsoever once the + session was already denied -- so a lapsed account re-rotated its single-use refresh + credential on every request, in every worker, forever. + """ + + cloud_session._save({ + "plan": "team", + "cloud_access_active": True, + "cloud_features": ["analytics", "team"], + "entitlement_checked_at": 1.0, + "organization_id": "org_1", + "refresh_credential": "engr_rt_saved", + }) + + assert cloud_session.record_billing_denial() is True + first = cloud_session._load() + assert first["cloud_access_active"] is False + assert first["cloud_features"] == [] + assert first["plan"] == "team", "the lapsed plan is still named for the UI" + assert first["refresh_credential"] == "engr_rt_saved", "the denial is not a disconnect" + stamped = first["entitlement_checked_at"] + assert stamped > 1.0, "the denial was never stamped as checked" + + # The steady state for a lapsed account: already denied, and aged past the interval. + aged = dict(first) + aged["entitlement_checked_at"] = stamped - 3600.0 + cloud_session._save(aged) + + assert cloud_session.record_billing_denial() is False, "nothing new was revoked" + repeated = cloud_session._load() + assert repeated["entitlement_checked_at"] >= stamped, ( + "a repeat denial must advance the clock the refresh interval reads" + ) + assert cloud_session.saved_entitlement()["entitlement_checked_at"] >= stamped + +def test_record_billing_denial_writes_under_the_refresh_lock(tmp_path, monkeypatch) -> None: + """The denial is a load-modify-save on the shared session file, so it must be serialized. + + Unguarded, it could read the old single-use refresh credential while another worker was + mid-rotation and write that stale value back over the rotated one. The control plane + treats a spent credential as replay and revokes the whole family, turning a lapsed + subscription into a forced reconnect. + """ + + monkeypatch.setenv("ENGRAPHIS_STATE_DIR", str(tmp_path)) + cloud_session._save({ + "schema": "engraphis-cloud-session/v1", + "control_url": "https://control.example.test", + "compute_url": "", + "organization_id": "org_paid", + "refresh_credential": "engr_rt_live", + "token_subject": "member", + "plan": "team", + "cloud_features": ["analytics", "team"], + "cloud_access_active": True, + }) + + held = [] + real_lock = cloud_session._refresh_lock + + import contextlib + + @contextlib.contextmanager + def _tracking_lock(): + with real_lock(): + held.append("in") + try: + yield + finally: + held.append("out") + + saved_while = [] + real_save = cloud_session._save + monkeypatch.setattr(cloud_session, "_refresh_lock", _tracking_lock) + monkeypatch.setattr( + cloud_session, "_save", + lambda v: (saved_while.append(held[-1] if held else None), real_save(v))[1], + ) + + assert cloud_session.record_billing_denial() is True + assert held == ["in", "out"], "the refresh lock was not taken" + assert saved_while == ["in"], "the session was written outside the refresh lock" + + record = cloud_session._load() + assert record["cloud_access_active"] is False + assert record["cloud_features"] == [] + assert record["refresh_credential"] == "engr_rt_live", "the credential was clobbered" diff --git a/tests/test_dashboard_auth_placement.py b/tests/test_dashboard_auth_placement.py index d0c50f7..da32b1d 100644 --- a/tests/test_dashboard_auth_placement.py +++ b/tests/test_dashboard_auth_placement.py @@ -1,10 +1,16 @@ """Static UI contract for the single-user local client and hosted commercial boundary.""" +import json import re +import shutil +import subprocess from pathlib import Path +import pytest + INDEX = Path(__file__).resolve().parents[1] / "engraphis" / "static" / "index.html" SCRIPT = Path(__file__).resolve().parents[1] / "engraphis" / "static" / "dashboard.js" +STYLES = Path(__file__).resolve().parents[1] / "engraphis" / "static" / "dashboard.css" def test_dashboard_has_no_local_team_auth_or_license_activation_ui(): @@ -68,8 +74,267 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses(): automation = script[script.index("async function loadAutomation()"): script.index("async function saveAutomation()")] for body in (analytics, automation): - assert "e.status===401||e.status===402||e.status===501" in body + assert "hostedFeatureUnavailable(e)" in body assert "unlockHtml" in body + # The billing predicate itself, verbatim: the loaders delegate to it, so widening it + # here is the only way a non-billing failure can reach ``unlockHtml``. + assert "error.status===402||error.status===501" in script + assert "error.status===409" in script + assert "Purchase Pro license" in script + + +# ── a paying customer must never be sold the plan they already own ──────────── +# The hosted views route a failed request to one of three answers. Only an entitlement +# status may draw the purchase panel; a 409 is a conflict, and ``consent_required`` in +# particular is a customer who HAS paid and whose installation simply has managed compute +# turned off — connecting it to Engraphis Cloud is what turns it on. +# +# ``_route`` below executes the shipped routing rather than asserting on its source: the +# regression it guards (409 folded into ``hostedFeatureUnavailable``) kept every string +# these files already assert on, and only a run can tell which branch actually won. +_ROUTED_FUNCTIONS = ( + "hostedPlanUrl", "unlockHtml", "managedConsentHtml", + "managedConsentRequired", "hostedFeatureUnavailable", + "loadAnalytics", "loadAutomation", +) + +# Everything the routed code touches that is not itself under test. The DOM, the API call +# and the license blob are stubbed; ``esc`` is the real escaping contract. +_ROUTING_STUBS = """ +'use strict'; +const NODES = {}; +const document = {getElementById(id){ + return NODES[id] || (NODES[id] = {innerHTML:'', textContent:'', className:'', style:{}})}}; +function esc(s){return String(s==null?'':s).replace(/[&<>"']/g, c=>( + {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} +function safeUrl(u){return u || '#'} +function setPlanPill(el,text,cls){if(el){el.textContent=text;el.className=cls}} +function showAs(el,on,disp){if(el)el.style.display=on?(disp||'block'):'none'} +function renderAnalytics(){return '
'} +function fmtRel(){return 'just now'} +function toast(){} +const TRIAL_DAYS = 3, WS = 'workspace'; +let CURRENT_VIEW = 'overview'; +const LIC = {pro_upgrade_url:'https://engraphis.com/pricing', + team_upgrade_url:'https://engraphis.com/pricing?plan=team', + upgrade_url:'https://engraphis.com/pricing', trial:{used:false}}; +const location = {href:'https://127.0.0.1:8077/'}; +let THROWN = null; +async function api(){if(THROWN) throw THROWN; return {}} +""" + +_ROUTING_DRIVER = """ +const CASES = JSON.parse(process.argv[2]); +(async () => { + const out = []; + for (const c of CASES) { + THROWN = Object.assign(new Error(c.message || 'request failed'), c.error); + CURRENT_VIEW = c.view; + for (const key of Object.keys(NODES)) delete NODES[key]; + await (c.view === 'analytics' ? loadAnalytics() : loadAutomation()); + const body = c.view === 'analytics' ? 'analytics-body' : 'automation-body'; + const lock = c.view === 'analytics' ? 'an-lock' : 'au-lock'; + out.push({name: c.name, html: NODES[body].innerHTML, pill: NODES[lock].textContent}); + } + process.stdout.write(JSON.stringify(out)); +})().catch(err => {process.stderr.write(String(err && err.stack)); process.exit(1)}); +""" + + +def _dashboard_function(name): + """Slice one top-level declaration out of the shipped dashboard bundle.""" + + script = SCRIPT.read_text(encoding="utf-8") + for head in ("\nasync function %s(" % name, "\nfunction %s(" % name): + start = script.find(head) + if start >= 0: + start += 1 + break + else: + raise AssertionError("dashboard.js no longer declares %s" % name) + end = re.compile(r"\n(?:async function |function |/\* |// |const |let |var )").search( + script, start) + return script[start:end.start() if end else len(script)].rstrip() + + +def _route(tmp_path, cases): + """Run the real hosted-view error routing over ``cases`` and return what it rendered.""" + + bundle = "\n".join([ + _ROUTING_STUBS, + "\n".join(_dashboard_function(name) for name in _ROUTED_FUNCTIONS), + _ROUTING_DRIVER, + ]) + runner = tmp_path / "routing.js" + runner.write_text(bundle, encoding="utf-8") + result = subprocess.run( + ["node", str(runner), json.dumps(cases)], + capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stderr + return {row["name"]: row for row in json.loads(result.stdout)} + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") +@pytest.mark.parametrize("view", ["analytics", "automation"]) +def test_a_consent_required_conflict_is_answered_with_the_consent_panel( + tmp_path, view, +): + """A 409 ``consent_required`` is a conflict, not a bill — never sell Pro for it. + + Analytics and Automation are the two views that reach managed compute, so they are + exactly the views this panel has to appear in. + """ + + rendered = _route(tmp_path, [{ + "name": "consent", "view": view, + "error": {"status": 409, "detail": {"code": "consent_required"}}, + }])["consent"] + + assert "managed compute is turned off for this installation" in rendered["html"] + assert "Connect this installation to Engraphis Cloud" in rendered["html"] + assert 'class="upgrade-panel"' not in rendered["html"] + assert "Purchase Pro license" not in rendered["html"] + # Consent travels with the cloud account; the customer is never sent to edit ``.env``. + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT" not in rendered["html"] + assert rendered["pill"] == "CLOUD" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") +@pytest.mark.parametrize("view", ["analytics", "automation"]) +@pytest.mark.parametrize("status", [402, 501]) +def test_a_genuine_entitlement_failure_still_renders_the_upgrade_panel( + tmp_path, view, status, +): + """402 not subscribed and 501 not offered are billing answers. 401 is not: + + the cloud maps it to "connect again", so it must reach the customer as a reconnect + instruction rather than a panel selling a plan they may already own. + """ + + rendered = _route(tmp_path, [{ + "name": "unentitled", "view": view, "error": {"status": status}, + }])["unentitled"] + + assert 'class="upgrade-panel"' in rendered["html"] + assert "Purchase Pro license" in rendered["html"] + assert "Start hosted Pro trial" in rendered["html"] + assert rendered["pill"] == "PRO" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") +@pytest.mark.parametrize("view", ["analytics", "automation"]) +@pytest.mark.parametrize("status", [400, 401, 500, 503]) +def test_a_non_billing_failure_shows_the_error_instead_of_selling_pro( + tmp_path, view, status, +): + """A bad request, an expired session, or a cloud outage is not an unpaid invoice. + + 401 belongs here rather than with 402/501: the cloud maps it to "the cloud session + expired or was revoked; connect again". Drawing the purchase panel for it sold an + already-paying customer the plan they own, instead of telling them to reconnect. + """ + + rendered = _route(tmp_path, [{ + "name": "broken", "view": view, + "message": "the hosted service is briefly unavailable", + "error": {"status": status}, + }])["broken"] + + assert 'class="upgrade-panel"' not in rendered["html"] + assert "Purchase Pro license" not in rendered["html"] + assert "the hosted service is briefly unavailable" in rendered["html"] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") +@pytest.mark.parametrize("view", ["analytics", "automation"]) +def test_a_transient_hosted_conflict_is_not_answered_with_a_purchase_panel( + tmp_path, view, +): + """A 409 that is not ``consent_required`` is a state conflict, not a billing answer.""" + + rendered = _route(tmp_path, [{ + "name": "conflict", "view": view, + "message": "managed snapshot generation must advance", + "error": {"status": 409, "detail": {"error": "generation conflict"}}, + }])["conflict"] + + assert 'class="upgrade-panel"' not in rendered["html"] + assert "Purchase Pro license" not in rendered["html"] + # The customer sees the real cause and can retry, rather than a panel selling Pro. + assert "managed snapshot generation must advance" in rendered["html"] + + +def test_only_an_entitlement_status_may_draw_the_purchase_panel(): + """Pin both routing predicates literally. + + The regression these guard folded a view name into ``hostedFeatureUnavailable`` and out + of ``managedConsentRequired``, which made every failure on Analytics and Automation -- + including a 409 from a paying customer -- render the panel selling Pro. A loose + substring check passed straight through that, so assert the exact predicate and the + exhaustive set of statuses it may test. + """ + + helper = _dashboard_function("hostedFeatureUnavailable") + assert "error.status===402||error.status===501" in helper + # No view may widen it and no fourth status may join it. + assert "CURRENT_VIEW" not in helper + assert sorted(set(re.findall(r"status\s*===\s*(\d+)", helper))) == ["402", "501"] + + consent = _dashboard_function("managedConsentRequired") + assert "error.status===409" in consent + assert "code==='consent_required'" in consent + assert sorted(set(re.findall(r"status\s*===\s*(\d+)", consent))) == ["409"] + # The consent branch must fire in every view, including the two sales surfaces. + assert "CURRENT_VIEW" not in consent + + for name in ( + "loadOverviewAnalytics", + "loadAnalytics", + "loadAutomation", + "loadSyncStatus", + ): + body = _dashboard_function(name) + assert "managedConsentRequired(e)" in body, name + assert "managedConsentHtml(" in body, name + assert body.index("managedConsentRequired(e)") < body.index( + "hostedFeatureUnavailable(e)"), name + + +def test_sync_status_does_not_sell_pro_to_a_customer_who_already_owns_it(): + """``loadSyncStatus`` rendered the purchase panel for EVERY failure. + + A dropped connection or a 5xx from the relay told a paying Pro customer to buy Pro. + Only a billing answer may reach ``unlockHtml``; everything else shows the real error. + """ + + body = _dashboard_function("loadSyncStatus") + + assert "unlockHtml('Cloud Sync','pro')" in body + # The unlock must be reached through the entitlement predicate, never unconditionally. + assert "hostedFeatureUnavailable(e)" in body + assert body.index("hostedFeatureUnavailable(e)") < body.index("unlockHtml(") + # A cause that is neither consent nor billing surfaces the server's own message. + assert "esc(e.message)" in body + + +def test_pro_upgrade_panel_lists_every_pro_benefit_and_purchase_cta(): + script = SCRIPT.read_text(encoding="utf-8") + styles = STYLES.read_text(encoding="utf-8") + + assert 'class="upgrade-panel"' in script + assert "Start hosted Pro trial" in script + assert "Purchase Pro license" in script + for benefit in ( + "Hosted Cloud Sync across your installations", + "Growth, retention, decay, and entity Analytics", + "Auto Consolidation with hosted retention policies", + "Auto Dreaming with reviewable managed proposals", + "Signed compliance exports with bi-temporal checksums", + "Priority support", + ): + assert benefit in script + assert ".upgrade-panel" in styles def test_team_invitations_and_password_setup_are_not_in_local_client(): diff --git a/tests/test_dashboard_dream_ui.py b/tests/test_dashboard_dream_ui.py index 903abf7..388a105 100644 --- a/tests/test_dashboard_dream_ui.py +++ b/tests/test_dashboard_dream_ui.py @@ -14,8 +14,10 @@ def test_automation_form_renders_dream_controls(): for el in ("au-dream", "au-dream-min", "au-dream-idle"): assert f'id="{el}"' in script, el assert 'id="au-infer"' not in script - assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1" in script assert "uploads the selected workspace’s normal and sensitive memory content" in script + # Consent travels with the cloud account. The dashboard must never name the operator + # override, so the string cannot appear anywhere the browser could render it. + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT" not in script def test_save_body_posts_dream_fields(): diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index f66c1c0..7ad408f 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -1,4 +1,7 @@ """Unified local dashboard tests for the public open-core boundary.""" +import ast +import io +import urllib.error from pathlib import Path import pytest @@ -9,6 +12,7 @@ from fastapi.testclient import TestClient # noqa: E402 from fastapi import HTTPException # noqa: E402 +from engraphis import cloud_features # noqa: E402 from engraphis.config import settings # noqa: E402 from engraphis.cloud_features import CloudFeatureError # noqa: E402 from engraphis.routes import v2_api # noqa: E402 @@ -118,8 +122,10 @@ def test_unconnected_automation_returns_a_structured_auth_error(monkeypatch, tmp response = client.get("/api/automation?workspace=demo") assert response.status_code == 401 + # The copy is ``_public_session_error(401)``: fixed, status-keyed, and actionable. The + # generic placeholder told an unconnected customer nothing they could act on. assert response.json()["detail"] == { - "error": "managed cloud operation failed", + "error": "Connect this installation to Engraphis Cloud to use hosted features.", "managed_cloud": True, "transient": False, } @@ -263,8 +269,11 @@ def test_dashboard_automation_uses_active_workspace_and_discloses_upload_boundar assert "/automation?workspace=" in source assert "/maintenance/run?workspace=" in source assert "Preview snapshot" not in source - assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1" in source assert "uploads the selected workspace’s normal and sensitive memory content" in source + # The upload boundary is still disclosed, but consent now travels with the cloud + # account: the dashboard must not name the operator override anywhere. + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT" not in source + assert "managed compute is turned off for this installation" in source def test_portfolio_and_report_analytics_are_hosted_only(monkeypatch, tmp_path): @@ -333,25 +342,156 @@ def fail_with(exc): assert ordinary_value_error.value.detail == {"error": "internal server error"} assert secret not in repr(ordinary_value_error.value.detail) - with pytest.raises(HTTPException) as managed: - v2_api._managed_call(fail_with, CloudFeatureError(secret, status=502)) - assert managed.value.status_code == 502 - assert managed.value.detail == { - "error": "managed cloud operation failed", "managed_cloud": True, - "transient": False, - } - assert secret not in repr(managed.value.detail) + +def test_managed_cloud_errors_forward_only_bounded_public_copy(): + """``_managed_call`` forwards the message; the bound is the boundary's own check. + + ``CloudFeatureError`` is the already-redacted form -- every raise site builds it from + fixed, status-keyed copy -- so its text is what the customer should read. The bound + here is not the redaction, it is the guard for a message that is *not* that fixed copy: + anything oversized, empty, or carrying control characters is dropped for the generic + placeholder rather than rendered into a JSON error body. + """ + + def fail_with(exc): + raise exc + + for message in ("x" * 301, "", "connection\x00reset", "trace\x1b[31m"): + with pytest.raises(HTTPException) as caught: + v2_api._managed_call(fail_with, CloudFeatureError(message, status=502)) + assert caught.value.status_code == 502 + assert caught.value.detail == { + "error": v2_api._MANAGED_ERROR_FALLBACK, "managed_cloud": True, + "transient": False, + } with pytest.raises(HTTPException) as consent: v2_api._managed_call( fail_with, - CloudFeatureError(secret, status=409, code="consent_required"), + CloudFeatureError( + "Managed compute is turned off for this installation.", + status=409, code="consent_required", + ), ) assert consent.value.status_code == 409 assert consent.value.detail == { - "error": "managed cloud operation failed", + "error": "Managed compute is turned off for this installation.", "managed_cloud": True, "transient": False, "code": "consent_required", } - assert secret not in repr(consent.value.detail) + + +def _managed_http_failure(monkeypatch, status: int) -> HTTPException: + """Drive one real hosted request against a control plane that answers ``status``.""" + + class _Opener: + def open(self, request, timeout=None): + raise urllib.error.HTTPError( + "https://compute.example.test/private", status, "failure", {}, + io.BytesIO(b'{"detail": "provider-internals https://backend.invalid"}'), + ) + + monkeypatch.setattr( + cloud_features, "build_pinned_https_opener", lambda *handlers: _Opener() + ) + client = cloud_features.CloudFeatureClient( + "https://compute.example.test", "org_1", "token" + ) + with pytest.raises(HTTPException) as caught: + v2_api._managed_call(client._request, "GET", "/private") + return caught.value + + +def test_a_managed_outage_is_distinguishable_from_a_workspace_conflict(monkeypatch): + """The defect: every hosted failure rendered as one fixed, unactionable string. + + ``cloud_features._public_http_error`` already produces redacted, status-keyed copy that + tells a retryable outage apart from a conflict the customer has to fix -- and + ``_managed_call`` threw all of it away, so the dashboard's error branch could only ever + show "managed cloud operation failed" for a 429, a 5xx and a 409 alike. + """ + + busy = _managed_http_failure(monkeypatch, 429) + down = _managed_http_failure(monkeypatch, 503) + conflict = _managed_http_failure(monkeypatch, 409) + + assert busy.status_code == 429 + assert busy.detail["transient"] is True + assert "temporarily busy" in busy.detail["error"], busy.detail["error"] + + assert down.status_code == 503 + assert down.detail["transient"] is True + assert "temporarily unavailable" in down.detail["error"], down.detail["error"] + + assert conflict.status_code == 409 + assert conflict.detail["transient"] is False + assert "workspace state" in conflict.detail["error"], conflict.detail["error"] + + messages = {busy.detail["error"], down.detail["error"], conflict.detail["error"]} + assert len(messages) == 3, "the dashboard still cannot tell these three apart" + assert v2_api._MANAGED_ERROR_FALLBACK not in messages + # Forwarding the public copy must not forward the provider's body with it. + assert all("provider-internals" not in text for text in messages) + assert all("backend.invalid" not in text for text in messages) + + +def test_every_managed_cloud_error_message_is_fixed_local_copy(): + """The invariant that makes forwarding safe, pinned against future raise sites. + + ``_managed_call`` may forward a ``CloudFeatureError`` message only because every one of + them is built from a literal in this repository -- never from a provider body, a + ``CloudSessionError``, or a local path. A raise site that interpolated a runtime value + would silently turn this boundary into a reflection point, so the shape is asserted + rather than trusted. + + Three forms are accepted: a string literal; a name bound from ``_public_http_error`` / + ``_public_session_error`` (both of which switch on a bare integer status and return + fixed copy); and the one audited ``%`` template, below. + """ + + source = Path(cloud_features.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + + public_copy = {"_public_http_error", "_public_session_error"} + from_public_copy = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call): + continue + called = node.value.func + if not isinstance(called, ast.Name) or called.id not in public_copy: + continue + for target in node.targets: + elements = target.elts if isinstance(target, ast.Tuple) else [target] + from_public_copy.update( + item.id for item in elements if isinstance(item, ast.Name) + ) + assert from_public_copy, "the fixed-copy helpers are no longer bound to a name" + + interpolated = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + name = node.func.id if isinstance(node.func, ast.Name) else None + if name != "CloudFeatureError" or not node.args: + continue + message = node.args[0] + if isinstance(message, ast.Constant) and isinstance(message.value, str): + continue + if isinstance(message, ast.Name) and message.id in from_public_copy: + continue + # ``"literal %s" % (...)`` is allowed only where the substituted values are + # themselves constrained to local literals; ``run_job`` is the single such site + # and its ``status`` is guarded by an ``in {"failed", "canceled"}`` membership + # test one line above. Anything else -- an f-string, a bare name, a concatenated + # response field -- is a reflection risk and fails here. + if (isinstance(message, ast.BinOp) and isinstance(message.op, ast.Mod) + and isinstance(message.left, ast.Constant) + and message.left.value == "Managed %s did not complete (%s)."): + continue + interpolated.append((node.lineno, ast.dump(message)[:120])) + + assert interpolated == [], ( + "a CloudFeatureError message is no longer fixed local copy; _managed_call " + "forwards it to the customer: %r" % (interpolated,) + ) diff --git a/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py new file mode 100644 index 0000000..8dbedf3 --- /dev/null +++ b/tests/test_hosted_plan_resolution.py @@ -0,0 +1,1521 @@ +"""Launch guards for *which plan* the client says a customer is on. + +``/api/license`` learned to report a real feature list, which fixed the free-vs-paid lock +badges. It still guessed the plan: ``_hosted_plan`` read an undocumented +``ENGRAPHIS_CLOUD_PLAN`` and otherwise returned ``"pro"`` for any connected installation. +A paying TEAM customer who had never set that variable was therefore shown a PRO badge and +a lock on the Team administration they were paying for. + +The control plane does know. It just never told this client: ``POST /v1/devices/register`` +and ``POST /v1/tokens/refresh`` both answer with ``DeviceRegistrationResponse``, whose only +entitlement field is ``entitlement_version`` — so ``cloud_session.save_bootstrap`` has +nothing to persist. ``GET /v1/entitlements/{organization_id}`` *is* authoritative, and +every access token this client can mint already carries the ``entitlement:read`` scope it +requires. The client now reads it opportunistically and caches the answer. + +Every test here pins one half of that bargain: + +* the badge and the feature list must follow the control plane's own answer; and +* getting that answer must never block boot, hang, or raise — the exact failure modes + fixed earlier in this release cycle, on a path that now makes a network call. +""" +from __future__ import annotations + +import ast +import io +import json +import os +import re +import socket +import threading +import time +import typing +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +# ``engraphis.routes.v2_api`` imports FastAPI, which the numpy-only core floor job does not +# install. Skip rather than error at collection, matching the rest of the suite. +pytest.importorskip("fastapi", reason="full-stack extra not installed") + +from engraphis import cloud_session # noqa: E402 +from engraphis.routes import v2_api # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] +DASHBOARD_JS = REPO_ROOT / "engraphis" / "static" / "dashboard.js" + +ORGANIZATION = "org_paying_team" +CONTROL_URL = "https://control.example.test" + +# The authoritative payload shape, mirrored from (read-only) +# engraphis-cloud/engraphis_cloud/entitlements.py ``EntitlementDTO`` / ``PLAN_FEATURES``. +SERVER_PLAN_FEATURES = { + "free": [], + "pro": ["analytics", "automation", "export", "sync"], + "team": ["analytics", "automation", "export", "sync", "team"], +} + + +def _entitlement_dto(plan: str, *, active: bool = True, + organization_id: str = ORGANIZATION) -> dict: + return { + "organization_id": organization_id, + "entitlement_id": "ent_1", + "plan": plan, + "status": "active" if active else "past_due", + "cloud_access_active": active, + "cloud_features": SERVER_PLAN_FEATURES[plan] if active else [], + "seat_limit": 5, + "seat_assignment_basis": "named_members", + "starts_at": "2026-01-01T00:00:00Z", + "expires_at": None, + "version": 3, + } + + +class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc_info): + self.close() + + +class _FakeControlPlane: + """One scripted control plane, recording exactly what the client sent it.""" + + def __init__(self, entitlement=None, *, error=None, raw=None, delay=None, + registration=None): + self.entitlement = entitlement + self.error = error + self.raw = raw + self.delay = delay + #: The entitlement fields a *current* control plane puts on every + #: ``DeviceRegistrationResponse``. ``None`` models a deployment that predates them. + self.registration = registration + self.requests = [] + self.threads = [] + + def opener(self, *handlers): + self.handlers = handlers + return self + + def open(self, request, timeout=None): + self.threads.append(threading.current_thread()) + self.requests.append({ + "url": request.full_url, + "method": request.get_method(), + "timeout": timeout, + "authorization": request.get_header("Authorization"), + }) + if request.full_url.endswith("/v1/tokens/refresh"): + body = { + "access_token": "eyJ-scoped-access", + "organization_id": ORGANIZATION, + "refresh_credential": "engr_rt_rotated_%d" % len(self.requests), + "refresh_expires_at": "2026-08-01T00:00:00Z", + "token_subject": "member", + } + body.update(self.registration or {}) + return _Response(json.dumps(body).encode("utf-8")) + if self.delay is not None: + self.delay.wait(timeout=10.0) + if self.error is not None: + raise self.error + if self.raw is not None: + return _Response(self.raw) + return _Response(json.dumps(self.entitlement).encode("utf-8")) + + +@pytest.fixture(autouse=True) +def _plan_resolution_isolation(monkeypatch): + """Keep every test hermetic: no inherited override, no leaked in-flight refresh.""" + + monkeypatch.delenv("ENGRAPHIS_CLOUD_PLAN", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_CONTROL_URL", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", raising=False) + monkeypatch.setattr(v2_api, "_entitlement_refreshing", False, raising=False) + yield + _drain_refresh() + v2_api._entitlement_refreshing = False + + +def _connect(monkeypatch, *, pinned_token: bool = True) -> None: + """Present this process as an onboarded installation. + + ``pinned_token=True`` uses the documented short-lived-access-token deployment, which + reaches the entitlement route in a single request. ``False`` exercises the ordinary + saved-refresh-credential path, which rotates a credential first. + """ + + monkeypatch.setenv("ENGRAPHIS_CLOUD_CONTROL_URL", CONTROL_URL) + if pinned_token: + monkeypatch.setenv("ENGRAPHIS_CLOUD_ACCESS_TOKEN", "eyJ-scoped-access") + monkeypatch.setenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", ORGANIZATION) + else: + monkeypatch.setenv("ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", "engr_rt_bootstrap") + + +def _serve(monkeypatch, cloud: _FakeControlPlane) -> None: + monkeypatch.setattr( + "engraphis.hosted_client.build_pinned_https_opener", cloud.opener + ) + monkeypatch.setattr(cloud_session, "build_pinned_https_opener", cloud.opener) + + +def _drain_refresh(timeout: float = 10.0) -> None: + """Block until the background refresh finishes, so assertions are deterministic.""" + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with v2_api._ENTITLEMENT_REFRESH_LOCK: + busy = v2_api._entitlement_refreshing + if not busy: + return + time.sleep(0.005) + raise AssertionError("the background entitlement refresh never finished") + + +def _entitlement_reads(cloud: _FakeControlPlane) -> list: + return [call for call in cloud.requests if "/v1/entitlements/" in call["url"]] + + +def _settled_license(monkeypatch) -> dict: + """Read ``/api/license`` once to schedule the refresh, then again once it lands.""" + + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0) + v2_api.get_license() + _drain_refresh() + # Stop scheduling further refreshes, so the recorded traffic is exactly one round and + # assertions about it are not racing a second background thread. + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 3600) + return v2_api.get_license() + + +# ── (1) a Team customer must be shown Team, with Team unlocked ──────────────── +def test_a_team_entitlement_produces_a_team_badge_with_the_team_feature_unlocked( + monkeypatch, +) -> None: + """The defect: a paying TEAM customer saw ``PRO`` and a lock on Team administration.""" + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "team" + assert payload["plan_source"] == "cloud" + assert "team" in payload["features"], "Team is still locked for a Team customer" + # The server folds these into ``automation``; the panel lists them separately, so a + # verbatim echo would leave a paying customer looking at unticked rows. + assert {"consolidation", "dreaming"} <= set(payload["features"]) + assert set(SERVER_PLAN_FEATURES["team"]) <= set(payload["features"]) + assert payload["cloud_access_active"] is True + assert payload["plan_checked_at"] > 0 + + +def test_a_team_entitlement_arrives_over_the_ordinary_refresh_credential_path( + monkeypatch, +) -> None: + """Not just the pinned-token deployment: the normal saved-session path works too.""" + + _connect(monkeypatch, pinned_token=False) + cloud = _FakeControlPlane(_entitlement_dto("team")) + _serve(monkeypatch, cloud) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "team" + assert "team" in payload["features"] + assert cloud.requests[0]["url"] == CONTROL_URL + "/v1/tokens/refresh" + reads = _entitlement_reads(cloud) + assert [call["url"] for call in reads] == [ + CONTROL_URL + "/v1/entitlements/" + ORGANIZATION + ] + + +def test_a_pro_entitlement_keeps_the_team_upsell_visible(monkeypatch) -> None: + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("pro"))) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "pro" + assert {"analytics", "automation"} <= set(payload["features"]) + assert "team" not in payload["features"] + + +def test_a_connected_free_organization_is_reported_as_the_local_core(monkeypatch) -> None: + """Being connected is not the same as having paid; the plan now says which.""" + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("free", active=False))) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "local" + assert payload["features"] == [] + + +def test_a_lapsed_subscription_keeps_its_plan_name_but_loses_its_features( + monkeypatch, +) -> None: + """A past-due Team customer needs the billing portal, not another trial offer. + + The dashboard offers "Open Team Cloud" for a hosted plan and "Start hosted Team trial" + otherwise, so collapsing a lapsed Team customer to ``local`` would send them to a trial + they have already consumed instead of to the page that restores their access. + """ + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team", active=False))) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "team" + assert payload["features"] == [] + assert payload["cloud_access_active"] is False + + +def test_the_dashboards_gated_navigation_unlocks_for_an_authoritative_team_plan( + monkeypatch, +) -> None: + """Close the loop on the shipped JS: ``locked = !features.includes(f)``.""" + + script = DASHBOARD_JS.read_text(encoding="utf-8") + block = script[script.index("function updateFeatureLocks()"):] + gated = re.findall(r"apply\('[^']+',\s*'([^']+)'", block[:block.index("\n}")]) + assert set(gated) == {"analytics", "automation", "team"}, gated + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + payload = _settled_license(monkeypatch) + + for feature in gated: + assert feature in payload["features"], "Team still renders a lock on %s" % feature + # Every advertised key must be renderable by the license panel. + assert set(payload["features"]) <= set(payload["known_features"]) + + +# ── (2) the boot path must not block, hang, or raise ────────────────────────── +def test_the_authoritative_read_never_runs_on_the_request_thread(monkeypatch) -> None: + """``/api/license`` is on the ``/api/bootstrap`` boot path.""" + + _connect(monkeypatch) + cloud = _FakeControlPlane(_entitlement_dto("team")) + _serve(monkeypatch, cloud) + + _settled_license(monkeypatch) + + assert cloud.threads, "the control plane was never contacted" + for thread in cloud.threads: + assert thread is not threading.main_thread() + assert thread.daemon, "a boot-path helper thread must never hold the process open" + + +def test_a_hanging_control_plane_does_not_delay_boot(monkeypatch) -> None: + """The regression that matters most: a stalled cloud must not stall the dashboard.""" + + _connect(monkeypatch) + release = threading.Event() + cloud = _FakeControlPlane(_entitlement_dto("team"), delay=release) + _serve(monkeypatch, cloud) + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0) + + try: + started = time.monotonic() + payload = v2_api.get_license() + elapsed = time.monotonic() - started + + assert elapsed < 1.0, "the boot path waited %.2fs on the cloud" % elapsed + # A connected installation is never shown the free local core while it waits. + assert payload["plan"] == "pro" + assert payload["plan_source"] == "connected" + assert payload["features"] + finally: + release.set() + + +def test_the_entitlement_read_is_bounded_authenticated_and_redirect_proof( + monkeypatch, +) -> None: + """The request carries a live bearer, so it needs a budget and no redirect following.""" + + _connect(monkeypatch) + cloud = _FakeControlPlane(_entitlement_dto("team")) + _serve(monkeypatch, cloud) + + _settled_license(monkeypatch) + + entitlement = _entitlement_reads(cloud)[0] + assert entitlement["method"] == "GET" + assert entitlement["authorization"] == "Bearer eyJ-scoped-access" + assert isinstance(entitlement["timeout"], (int, float)) + assert 0 < entitlement["timeout"] <= 15 + assert cloud.handlers, "the no-redirect handler must be installed" + assert any( + isinstance(handler, urllib.request.HTTPRedirectHandler) + and type(handler).__name__ == "_NoRedirect" + for handler in cloud.handlers + ) + + +def test_the_probe_uses_the_repos_vetted_connector() -> None: + """A credential-bearing call must not build an unvetted opener.""" + + source = (REPO_ROOT / "engraphis" / "routes" / "v2_api.py").read_text(encoding="utf-8") + + assert "build_pinned_https_opener" in source + assert "urllib.request.build_opener(" not in source + + +@pytest.mark.parametrize("control_url,resolves_to", [ + # The pinned opener replaces urllib's *HTTPS* handler only, so a cleartext endpoint + # would fall through to the plain handler and put a live bearer on the wire. + ("http://control.example.test", "93.184.216.34"), + ("https://user:secret@control.example.test", "93.184.216.34"), + ("https://control.example.test/v1?leak=1", "93.184.216.34"), + ("ftp://control.example.test", "93.184.216.34"), + ("https://internal.example.test", "10.0.0.5"), +]) +def test_an_unvetted_control_url_never_receives_the_bearer_token( + monkeypatch, control_url, resolves_to, +) -> None: + """A pinned access token short-circuits ``cloud_session``'s own URL validation. + + ``access_for_workspace`` validates the control URL on the saved-session path but + returns immediately when ``ENGRAPHIS_CLOUD_ACCESS_TOKEN`` is configured, so this path + has to vet the endpoint itself before it attaches a credential to it. + """ + + monkeypatch.setattr(socket, "getaddrinfo", lambda host, port, *a, **k: [( + socket.AF_INET, socket.SOCK_STREAM, 6, "", (resolves_to, port or 0))]) + monkeypatch.setenv("ENGRAPHIS_CLOUD_ACCESS_TOKEN", "eyJ-scoped-access") + monkeypatch.setenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", ORGANIZATION) + monkeypatch.setenv("ENGRAPHIS_CLOUD_CONTROL_URL", control_url) + cloud = _FakeControlPlane(_entitlement_dto("team")) + _serve(monkeypatch, cloud) + monkeypatch.setattr( + urllib.request, "build_opener", + lambda *a, **k: pytest.fail("an unvetted opener was built for " + control_url), + ) + + assert v2_api._fetch_authoritative_entitlement() is None + assert cloud.requests == [] + + +def test_a_vetted_https_control_url_is_still_reached(monkeypatch) -> None: + """The guard above must reject bad endpoints without breaking the good one.""" + + _connect(monkeypatch) + monkeypatch.setenv("ENGRAPHIS_CLOUD_CONTROL_URL", CONTROL_URL + "/") + cloud = _FakeControlPlane(_entitlement_dto("team")) + _serve(monkeypatch, cloud) + + assert _settled_license(monkeypatch)["plan"] == "team" + assert [call["url"] for call in _entitlement_reads(cloud)] == [ + CONTROL_URL + "/v1/entitlements/" + ORGANIZATION + ] + + +def test_the_entitlement_refresh_never_sends_a_null_workspace_id(monkeypatch) -> None: + """The compatibility fallback mints an *unbound* token, so it passes no workspace. + + ``access_for_workspace`` is annotated ``workspace_id: str`` but is called with ``None``, + which ``_post_refresh`` serialises as ``"workspace_id": null``. A control plane that + requires a non-null workspace id answers 4xx, ``_fetch_authoritative_entitlement`` + swallows it and returns ``None``, and the cached ``GET /v1/entitlements/{org}`` answer + that older control planes depend on is never written at all. Omit the key instead. + """ + + _connect(monkeypatch, pinned_token=False) + cloud = _FakeControlPlane(_entitlement_dto("team")) + sent = [] + record = cloud.open + + def _capture(request, timeout=None): + sent.append(request.data) + return record(request, timeout=timeout) + + monkeypatch.setattr(cloud, "open", _capture) + _serve(monkeypatch, cloud) + + v2_api._fetch_authoritative_entitlement() + + payloads = [json.loads(body.decode("utf-8")) for body in sent if body] + assert payloads, "the token refresh was never made" + for payload in payloads: + assert payload.get("workspace_id", "absent") is not None, payload + hints = typing.get_type_hints(cloud_session.access_for_workspace) + assert hints["workspace_id"] == typing.Optional[str] + + +@pytest.mark.parametrize("failure", [ + urllib.error.HTTPError(CONTROL_URL, 401, "unauthorized", {}, io.BytesIO(b"{}")), + urllib.error.HTTPError(CONTROL_URL, 402, "payment required", {}, io.BytesIO(b"{}")), + urllib.error.HTTPError(CONTROL_URL, 500, "server error", {}, io.BytesIO(b"{}")), + urllib.error.URLError("offline"), + TimeoutError("the read timed out"), + OSError("connection reset"), +]) +def test_a_cloud_failure_never_reaches_the_dashboard(monkeypatch, failure) -> None: + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(error=failure)) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "pro" # the connected fallback, not an exception + assert payload["features"] + assert v2_api._read_entitlement_cache() == {} + + +def test_an_unreadable_error_body_never_escapes_the_refresh_thread(monkeypatch) -> None: + """Draining an ``HTTPError`` body can itself raise; a sibling ``except`` misses it.""" + + class _Unreadable(io.BytesIO): + def read(self, *args, **kwargs): + raise TimeoutError("the read timed out") + + def close(self): + if self.closed: + return + # Model a reset after the descriptor was released so this deliberate + # cleanup failure does not recur from BytesIO's finalizer as a warning. + super().close() + raise OSError("the socket was already reset") + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(error=urllib.error.HTTPError( + CONTROL_URL, 503, "unavailable", {}, _Unreadable()))) + + assert _settled_license(monkeypatch)["plan"] == "pro" + + +@pytest.mark.parametrize("body", [ + b"{not json", + b"[1, 2, 3]", + b"null", + # A truncated body must not coerce to "free, no access" and cache a paying customer + # as the free local core: ``plan`` and ``cloud_access_active`` are required fields. + b'{"organization_id": "org_paying_team"}', + b'{"organization_id":"org_paying_team","plan":"team"}', + b'{"organization_id":"org_paying_team","plan":"","cloud_access_active":true}', + b'{"organization_id":"org_paying_team","plan":7,"cloud_access_active":true}', + b'{"organization_id":"org_paying_team","plan":"team","cloud_access_active":"yes"}', + json.dumps(_entitlement_dto("team", organization_id="org_someone_else")).encode(), + b'{"organization_id":"org_paying_team","plan":"team","cloud_access_active":true,' + + b'"pad":"' + b"x" * 70_000 + b'"}', +], ids=[ + "invalid-json", + "array", + "null", + "incomplete", + "missing-activity", + "empty-plan", + "numeric-plan", + "nonboolean-activity", + "wrong-organization", + "oversized", +]) +def test_a_malformed_or_misrouted_answer_never_relabels_the_plan(monkeypatch, body) -> None: + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(raw=body)) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "pro" + assert v2_api._read_entitlement_cache() == {} + + +# ── (3) offline: the cache is what keeps a paying customer correct ──────────── +def test_an_offline_client_still_boots_and_renders_its_last_known_plan( + monkeypatch, +) -> None: + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + assert _settled_license(monkeypatch)["plan"] == "team" + + # Every route to the cloud now fails, exactly as it does on a plane. The zero interval + # guarantees a refresh is actually attempted (and fails) during the read below. + _serve(monkeypatch, _FakeControlPlane(error=urllib.error.URLError("offline"))) + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0) + started = time.monotonic() + payload = v2_api.get_license() + elapsed = time.monotonic() - started + _drain_refresh() + + assert elapsed < 1.0 + assert payload["plan"] == "team" + assert "team" in payload["features"] + assert payload["plan_source"] == "cloud" + # A failed refresh must not erase the last good answer. + assert v2_api._read_entitlement_cache()["plan"] == "team" + + +def test_the_real_boot_route_serves_a_connected_offline_client(monkeypatch, tmp_path) -> None: + """The whole point, over real HTTP: ``/api/bootstrap`` must render with no cloud.""" + + pytest.importorskip("fastapi", reason="full-stack extra not installed") + pytest.importorskip("httpx", reason="httpx not installed") + from fastapi.testclient import TestClient + + from engraphis.config import settings + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + assert _settled_license(monkeypatch)["plan"] == "team" + + # Now cut the cloud off entirely, as an aeroplane or a captive portal would, and let + # the boot path try (and fail) to refresh while it serves. + _serve(monkeypatch, _FakeControlPlane(error=urllib.error.URLError("offline"))) + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0) + monkeypatch.setattr(settings, "db_path", str(tmp_path / "boot.db")) + monkeypatch.setattr(settings, "embed_model", "") + monkeypatch.setattr(settings, "embed_dim", 384) + monkeypatch.setattr(settings, "allowed_workspaces", []) + monkeypatch.setattr(settings, "api_token", "") + monkeypatch.setattr(v2_api, "_service", None) + + from engraphis.dashboard_app import create_app + + with TestClient(create_app(), client=("127.0.0.1", 50000)) as client: + started = time.monotonic() + boot = client.get("/api/bootstrap") + elapsed = time.monotonic() - started + page = client.get("/") + + assert boot.status_code == 200 + assert page.status_code == 200 + assert elapsed < 5.0, "the boot route waited %.2fs on an unreachable cloud" % elapsed + license_state = boot.json()["license"] + assert license_state["plan"] == "team" + assert "team" in license_state["features"] + + +def test_an_offline_unconnected_client_is_the_free_local_core(monkeypatch) -> None: + _serve(monkeypatch, _FakeControlPlane(error=urllib.error.URLError("offline"))) + + payload = v2_api.get_license() + + assert payload["plan"] == "local" + assert payload["features"] == [] + assert payload["plan_source"] == "local" + + +def test_an_unconnected_installation_starts_no_background_work(monkeypatch) -> None: + """No session, no thread, no state written — an offline gate must stay quiet.""" + + before = threading.active_count() + + for _ in range(3): + assert v2_api.get_license()["plan"] == "local" + + assert threading.active_count() <= before + assert v2_api._entitlement_refreshing is False + assert v2_api._read_entitlement_cache() == {} + + +def test_a_connected_installation_with_no_control_url_starts_no_background_work( + monkeypatch, +) -> None: + """Nothing to dial means nothing to schedule.""" + + monkeypatch.setenv("ENGRAPHIS_CLOUD_ACCESS_TOKEN", "eyJ-scoped-access") + monkeypatch.setenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", ORGANIZATION) + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0) + + before = threading.active_count() + assert v2_api.get_license()["plan"] == "pro" + + assert threading.active_count() <= before + assert v2_api._entitlement_refreshing is False + + +def test_a_corrupt_cache_is_discarded_rather_than_downgrading_a_paying_customer( + monkeypatch, +) -> None: + """Coercing a damaged value would quietly relabel a paying customer as free.""" + + _connect(monkeypatch) + path = v2_api._entitlement_cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + for blob in ("not json", + '{"schema": "someone-elses/v1", "plan": "team"}', + '{"schema": "engraphis-cloud-entitlement/v1", "plan": {"a": 1}}', + '{"schema": "engraphis-cloud-entitlement/v1", "plan": "enterprise"}'): + path.write_text(blob, encoding="utf-8") + + assert v2_api._read_entitlement_cache() == {} + assert v2_api._hosted_plan() == "pro" + + +def test_the_cache_is_written_owner_only_and_reread_across_processes( + monkeypatch, +) -> None: + """It lives beside the cloud session, so a partial or world-readable file is not ok.""" + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + _settled_license(monkeypatch) + + path = v2_api._entitlement_cache_path() + assert path.exists() + # Windows does not project ACLs through POSIX mode bits. The private-state helper + # still uses its race-safe write/read path there, but only POSIX can prove owner-only + # access with this portable ``st_mode`` check. + if os.name != "nt": + assert (path.stat().st_mode & 0o777) == 0o600 + saved = json.loads(path.read_text(encoding="utf-8")) + assert saved["schema"] == "engraphis-cloud-entitlement/v1" + assert saved["plan"] == "team" + assert saved["organization_id"] == ORGANIZATION + # No credential may be persisted alongside the presentation state. + assert "eyJ-scoped-access" not in path.read_text(encoding="utf-8") + + +def test_an_unwritable_state_directory_costs_freshness_not_the_dashboard( + monkeypatch, +) -> None: + def _refuse(*args, **kwargs): + raise OSError("read-only file system") + + monkeypatch.setattr("engraphis.private_state.atomic_private_text", _refuse) + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "pro" + assert payload["features"] + + +# ── (4) refresh scheduling ──────────────────────────────────────────────────── +def test_only_one_refresh_is_ever_in_flight(monkeypatch) -> None: + _connect(monkeypatch) + release = threading.Event() + cloud = _FakeControlPlane(_entitlement_dto("team"), delay=release) + _serve(monkeypatch, cloud) + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0) + + try: + for _ in range(8): + v2_api.get_license() + deadline = time.monotonic() + 5.0 + while not cloud.requests and time.monotonic() < deadline: + time.sleep(0.005) + entitlement_reads = [ + call for call in cloud.requests if "/v1/entitlements/" in call["url"] + ] + assert len(entitlement_reads) == 1, entitlement_reads + finally: + release.set() + + +def test_a_fresh_cache_is_not_refetched(monkeypatch) -> None: + _connect(monkeypatch) + cloud = _FakeControlPlane(_entitlement_dto("team")) + _serve(monkeypatch, cloud) + _settled_license(monkeypatch) + reads = len([c for c in cloud.requests if "/v1/entitlements/" in c["url"]]) + + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 3600) + for _ in range(5): + assert v2_api.get_license()["plan"] == "team" + _drain_refresh() + + assert len([c for c in cloud.requests if "/v1/entitlements/" in c["url"]]) == reads + + +def test_the_refresh_has_an_operator_kill_switch(monkeypatch) -> None: + _connect(monkeypatch) + cloud = _FakeControlPlane(_entitlement_dto("team")) + _serve(monkeypatch, cloud) + monkeypatch.setenv("ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH", "0") + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0) + + for _ in range(3): + assert v2_api.get_license()["plan"] == "pro" + _drain_refresh() + + assert cloud.requests == [] + + +def test_the_documented_kill_switch_and_override_are_in_the_example_config() -> None: + """P3: a config key the client reads must not be discoverable only from the source.""" + + example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8") + + assert "ENGRAPHIS_CLOUD_PLAN=" in example + assert "ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH=" in example + # Both must be commented out: neither is required for a correct badge. + assert "\nENGRAPHIS_CLOUD_PLAN=" not in example + assert "\nENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH=" not in example + # And ENGRAPHIS_CLOUD_PLAN must read as the escape hatch it now is, not as the way + # plans are normally resolved — that framing is what made it undiscoverable. + assert "ENGRAPHIS_CLOUD_PLAN is an override" in example + assert "The plan is resolved automatically" in example + + +# ── (4b) the plan now rides registration and refresh ────────────────────────── +# ``DeviceRegistrationResponse`` — returned by both ``/internal/devices/register`` and +# ``POST /v1/tokens/refresh`` — carries ``plan``, ``cloud_features`` and +# ``cloud_access_active``. The client consumes them instead of polling a second route for +# a fact those calls already deliver, while staying compatible with a cloud that omits them. +def _registration_entitlement(plan: str, *, active: bool = True) -> dict: + return { + "plan": plan, + "cloud_features": SERVER_PLAN_FEATURES[plan] if active else [], + "cloud_access_active": active, + } + + +def test_the_plan_arrives_on_the_refresh_the_client_already_makes(monkeypatch) -> None: + """The point of the change: one call, not two, and the answer is authoritative.""" + + _connect(monkeypatch, pinned_token=False) + cloud = _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team")) + _serve(monkeypatch, cloud) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "team" + assert payload["plan_source"] == "session" + assert "team" in payload["features"] + assert {"consolidation", "dreaming"} <= set(payload["features"]) + assert payload["cloud_access_active"] is True + assert payload["plan_checked_at"] > 0 + # The dedicated entitlements poll is redundant against this control plane and must not + # be made: the token refresh already answered. + assert _entitlement_reads(cloud) == [] + assert [call["url"] for call in cloud.requests] == [ + CONTROL_URL + "/v1/tokens/refresh" + ] + + +def test_registration_alone_makes_the_very_first_boot_correct(monkeypatch) -> None: + """Onboarding persists the plan, so boot needs no network to render Team.""" + + _connect(monkeypatch, pinned_token=False) + cloud_session.save_bootstrap( + dict({ + "refresh_credential": "engr_rt_bootstrap", + "organization_id": ORGANIZATION, + "installation_id": "inst_1", + "device_id": "dev_1", + "member_id": "mem_1", + "token_subject": "member", + }, **_registration_entitlement("team")), + control_url=CONTROL_URL, + compute_url=CONTROL_URL, + ) + cloud = _FakeControlPlane(_entitlement_dto("pro")) + _serve(monkeypatch, cloud) + + started = time.monotonic() + payload = v2_api.get_license() + elapsed = time.monotonic() - started + _drain_refresh() + + assert payload["plan"] == "team" + assert payload["plan_source"] == "session" + assert "team" in payload["features"] + # A just-registered answer is fresh, so boot touches the network not at all. + assert cloud.requests == [] + assert elapsed < 1.0 + + +def test_a_control_plane_without_the_new_fields_still_resolves_the_plan( + monkeypatch, +) -> None: + """The client must not require a newer server: the old route is the fallback.""" + + _connect(monkeypatch, pinned_token=False) + cloud = _FakeControlPlane(_entitlement_dto("team"), registration=None) + _serve(monkeypatch, cloud) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "team" + assert payload["plan_source"] == "cloud" + assert "team" in payload["features"] + assert [call["url"] for call in _entitlement_reads(cloud)] == [ + CONTROL_URL + "/v1/entitlements/" + ORGANIZATION + ] + # Nothing was persisted onto the session, so the fallback stays in charge. + assert cloud_session.saved_entitlement() == {} + + +def test_a_field_less_refresh_never_erases_a_plan_the_cloud_already_declared( + monkeypatch, +) -> None: + """A rollback of the server change must not downgrade a paying customer.""" + + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team"))) + assert _settled_license(monkeypatch)["plan"] == "team" + + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), registration=None)) + cloud_session.access_for_workspace(None, require_compute=False) + + assert cloud_session.saved_entitlement()["plan"] == "team" + assert v2_api.get_license()["plan"] == "team" + + +def test_a_downgrade_drops_the_previous_plans_features_from_the_resolved_answer( + monkeypatch, +) -> None: + """Team -> Pro must lock the Team tab, not leave it open on a stale grant list. + + ``_declared_entitlement`` omits ``cloud_features`` when the refresh body carries no + feature list, so merging it onto the saved record swapped ``plan`` while the *old* list + stayed behind. ``/api/license`` then answered ``pro`` with ``team`` still in + ``features``, and the customer kept the Team administration they had stopped paying for. + """ + + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team"))) + assert "team" in _settled_license(monkeypatch)["features"] + + # The downgrade as a control plane that names the plan but no features reports it. + _serve(monkeypatch, _FakeControlPlane( + _entitlement_dto("pro"), + registration={"plan": "pro", "cloud_access_active": True}, + )) + cloud_session.access_for_workspace(None, require_compute=False) + + saved = cloud_session.saved_entitlement() + assert saved["plan"] == "pro" + assert "team" not in saved.get("cloud_features", []) + + payload = v2_api.get_license() + + assert payload["plan"] == "pro" + assert payload["plan_source"] == "session" + assert "team" not in payload["features"], "Team is still unlocked on a Pro plan" + # A stale list is dropped, not blanked: everything Pro does grant is still granted. + assert set(SERVER_PLAN_FEATURES["pro"]) <= set(payload["features"]) + + +def test_a_billing_denial_stops_the_session_claiming_paid_access(monkeypatch) -> None: + """A 402 is an answer, not an outage. + + The saved session outranks the entitlement cache, so folding the control plane's 402 + in with offline/transport failures left ``cloud_access_active`` true and kept the + dashboard advertising paid features indefinitely -- while every hosted call was + denied. The plan name is kept so the UI can still say which plan lapsed. + """ + + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team"))) + assert _settled_license(monkeypatch)["cloud_access_active"] is True + + def _lapsed(*_args, **_kwargs): + raise cloud_session.CloudSessionError("Subscription is not active.", status=402) + + monkeypatch.setattr(cloud_session, "access_for_workspace", _lapsed) + assert v2_api._fetch_authoritative_entitlement() is None + + record = cloud_session._load() + assert record.get("cloud_access_active") is False + assert record.get("cloud_features") == [] + assert record.get("plan") == "team", "the lapsed plan is still named for the UI" + + payload = v2_api.get_license() + assert payload["cloud_access_active"] is False + assert payload["features"] == [] + + +def test_a_transport_failure_is_not_mistaken_for_a_billing_denial(monkeypatch) -> None: + """Only 402 clears access. An outage must never look like a cancellation.""" + + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team"))) + assert _settled_license(monkeypatch)["cloud_access_active"] is True + + def _offline(*_args, **_kwargs): + raise cloud_session.CloudSessionError( + "Engraphis Cloud is temporarily unreachable.", status=503, transient=True + ) + + monkeypatch.setattr(cloud_session, "access_for_workspace", _offline) + assert v2_api._fetch_authoritative_entitlement() is None + + record = cloud_session._load() + assert record.get("cloud_access_active") is True, "an outage revoked a paying customer" + assert "team" in record.get("cloud_features", []) + + +def test_a_billing_denial_also_settles_the_compatibility_cache(monkeypatch) -> None: + """An older control plane leaves the session planless, so the cache must settle too. + + ``record_billing_denial`` writes the denial onto a session that carries no entitlement + fields, so ``saved_entitlement()`` still answers ``{}`` and the resolver falls through + to the compatibility cache. Unless that cache is settled as well, the dashboard keeps + advertising the paid plan while every cloud operation is denied. + """ + + _connect(monkeypatch, pinned_token=False) + # registration={} models the older control plane: no entitlement on the session. + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), registration={})) + assert _settled_license(monkeypatch)["cloud_access_active"] is True + assert cloud_session.saved_entitlement() == {}, "the session must be planless here" + assert v2_api._read_entitlement_cache()["cloud_access_active"] is True + + def _lapsed(*_args, **_kwargs): + raise cloud_session.CloudSessionError("Subscription is not active.", status=402) + + monkeypatch.setattr(cloud_session, "access_for_workspace", _lapsed) + assert v2_api._fetch_authoritative_entitlement() is None + + cached = v2_api._read_entitlement_cache() + assert cached["cloud_access_active"] is False, "the cache still advertises paid access" + assert cached["features"] == [] + assert cached["plan"] == "team", "the lapsed plan is still named for the UI" + + +def _age_session_clock(seconds: float = 3600.0) -> None: + """Push the saved session's entitlement stamp past any refresh interval.""" + + record = cloud_session._load() + record["entitlement_checked_at"] = time.time() - seconds + cloud_session._save(record) + + +def _lapsed_control_plane(monkeypatch) -> list: + """Answer every token refresh with the control plane's authoritative 402. + + Returns the list the attempts are appended to, so a test can count how many times the + single-use refresh credential was spent. + """ + + attempts = [] + + def _lapsed(*_args, **_kwargs): + attempts.append(time.time()) + raise cloud_session.CloudSessionError("Subscription is not active.", status=402) + + monkeypatch.setattr(cloud_session, "access_for_workspace", _lapsed) + # The real dial, not the 0 that ``_settled_license`` leaves behind: the guard under + # test is precisely that a just-recorded denial suppresses the next attempt. + monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 15 * 60) + return attempts + + +def test_a_billing_denial_is_stamped_so_the_next_refresh_is_suppressed( + monkeypatch, +) -> None: + """A 402 is an authoritative read and has to advance the clock that throttles. + + ``record_billing_denial`` cleared the grants but left ``entitlement_checked_at`` + untouched, so ``_session_entitlement`` kept reporting the old ``fetched_at``, the + 15-minute interval in ``_refresh_entitlement_in_background`` never matched, and every + subsequent ``/api/license`` -- in every worker -- spent and rotated the refresh + credential again against an account the control plane had already refused. Unbounded + credential rotation on a lapsed subscription. + """ + + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team"))) + assert _settled_license(monkeypatch)["cloud_access_active"] is True + + attempts = _lapsed_control_plane(monkeypatch) + _age_session_clock() + + v2_api.get_license() + _drain_refresh() + assert len(attempts) == 1, "the aged session did not schedule the first re-check" + + settled = v2_api.get_license() + _drain_refresh() + assert settled["cloud_access_active"] is False + assert settled["features"] == [] + assert len(attempts) == 1, ( + "the denial left a stale clock, so the very next request rotated the credential " + "again against a control plane that had already answered 402" + ) + assert settled["plan_checked_at"] > time.time() - 60, "the denial was never stamped" + + v2_api.get_license() + _drain_refresh() + assert len(attempts) == 1, "the suppression did not hold for a second request" + + +def test_a_repeat_billing_denial_still_advances_the_refresh_clock(monkeypatch) -> None: + """The already-denied session is the steady state, and it wrote nothing at all. + + ``record_billing_denial`` returned early once ``cloud_access_active`` was already + ``False`` with no grants left, so the timestamp froze at whatever the last *successful* + read wrote. Every request after that saw an aged entitlement and refreshed again. + """ + + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team"))) + assert _settled_license(monkeypatch)["cloud_access_active"] is True + + # The steady state: already denied, and aged past the refresh interval. + record = cloud_session._load() + record["cloud_access_active"] = False + record["cloud_features"] = [] + record["entitlement_checked_at"] = time.time() - 3600.0 + cloud_session._save(record) + + attempts = _lapsed_control_plane(monkeypatch) + + v2_api.get_license() + _drain_refresh() + assert len(attempts) == 1, "an aged denial must still be re-checked once" + + v2_api.get_license() + _drain_refresh() + v2_api.get_license() + _drain_refresh() + assert len(attempts) == 1, ( + "a repeat denial wrote nothing, so the clock never advanced and the lapsed " + "account kept being re-checked on every request" + ) + assert cloud_session._load()["plan"] == "team", "the lapsed plan is still named" + + +def test_a_repeat_denial_also_advances_the_compatibility_cache_clock(monkeypatch) -> None: + """The same freeze one layer down, on the control plane that has no session fields. + + ``_deny_entitlement_cache`` returned without writing once the cache was already + settled, so ``_plan_entitlement`` served a stale ``fetched_at`` and the refresh was + re-scheduled on every request -- the identical unbounded rotation, reached by the + older-control-plane path instead. + """ + + _connect(monkeypatch, pinned_token=False) + # registration={} models the older control plane: no entitlement on the session. + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), registration={})) + assert _settled_license(monkeypatch)["cloud_access_active"] is True + assert cloud_session.saved_entitlement() == {}, "the session must be planless here" + + attempts = _lapsed_control_plane(monkeypatch) + + # Already denied, and aged past the interval. + cached = v2_api._read_entitlement_cache() + cached["cloud_access_active"] = False + cached["features"] = [] + cached["fetched_at"] = time.time() - 3600.0 + v2_api._write_entitlement_cache(cached) + + v2_api.get_license() + _drain_refresh() + assert len(attempts) == 1, "an aged cache must still be re-checked once" + + v2_api.get_license() + _drain_refresh() + assert len(attempts) == 1, ( + "the repeat denial left the cached clock stale, so the credential kept rotating" + ) + settled = v2_api._read_entitlement_cache() + assert settled["cloud_access_active"] is False + assert settled["plan"] == "team", "the lapsed plan is still named for the UI" + + +def test_a_lapsed_subscription_declared_on_the_refresh_keeps_its_name( + monkeypatch, +) -> None: + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane( + _entitlement_dto("team", active=False), + registration=_registration_entitlement("team", active=False), + )) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "team" + assert payload["features"] == [] + assert payload["cloud_access_active"] is False + + +def test_a_downgrade_to_free_is_reported_as_the_local_core(monkeypatch) -> None: + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane( + _entitlement_dto("free", active=False), + registration=_registration_entitlement("free", active=False), + )) + + payload = _settled_license(monkeypatch) + + assert payload["plan"] == "local" + assert payload["features"] == [] + + +@pytest.mark.parametrize("response", [ + {}, + {"plan": ""}, + {"plan": None}, + {"plan": 7}, + {"cloud_features": ["analytics"], "cloud_access_active": True}, +]) +def test_the_registration_entitlement_fields_are_optional(response) -> None: + """An older control plane omits them; a partial body must not invent an answer.""" + + assert cloud_session._declared_entitlement(response) == {} + + +def test_a_plan_without_an_activity_flag_is_not_treated_as_lapsed() -> None: + """Absent is not ``False``: defaulting it would re-lock a paying customer.""" + + declared = cloud_session._declared_entitlement({"plan": "Pro"}) + + assert declared["plan"] == "pro" + assert declared["cloud_access_active"] is True + assert "cloud_features" not in declared + assert cloud_session._declared_entitlement({"plan": "free"})[ + "cloud_access_active"] is False + + +def test_an_oversized_provider_entitlement_cannot_brick_the_saved_session() -> None: + """The session record is read back under a 64 KiB cap; a huge value must be bounded. + + Growing the file past that cap would make ``_load`` raise on every subsequent call and + take cloud access down with it, so the persisted presentation strings are trimmed. + """ + + declared = cloud_session._declared_entitlement({ + "plan": "team" + "x" * 100_000, + "cloud_features": ["f%d" % index + "y" * 10_000 for index in range(5_000)], + "cloud_access_active": True, + }) + + assert len(declared["plan"]) <= 64 + assert len(declared["cloud_features"]) <= 32 + assert all(len(feature) <= 64 for feature in declared["cloud_features"]) + assert len(json.dumps(declared)) < 8 * 1024 + + +def test_a_session_plan_for_another_organization_is_refused(monkeypatch) -> None: + """A pinned deployment may point at a different organization than it registered for. + + Serving the saved session's plan there would relabel one customer with another's. + """ + + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team"))) + assert _settled_license(monkeypatch)["plan_source"] == "session" + + monkeypatch.setenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", "org_somebody_else") + + assert v2_api._session_entitlement() == {} + # Nothing was destroyed: the record is still correct for the organization it names. + monkeypatch.setenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", ORGANIZATION) + assert v2_api._session_entitlement()["plan"] == "team" + + +def test_a_cached_entitlement_for_another_organization_is_refused(monkeypatch) -> None: + """The same guard, on the compatibility cache — which the session path already had. + + The cache file lives in the state directory, which outlives a reconnect and is shared + by every organization the installation is ever pointed at. Serving it unchecked showed + the PREVIOUS organization's Team badge and Team grant for a whole refresh interval, and + for ever with the refresh disabled. + """ + + _connect(monkeypatch) + path = v2_api._entitlement_cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + v2_api._write_entitlement_cache({ + "plan": "team", "features": v2_api.entitled_features("team"), + "cloud_access_active": True, "organization_id": ORGANIZATION, + "fetched_at": time.time(), + }) + assert v2_api._read_entitlement_cache()["plan"] == "team" + + # Re-pinned to somebody else's organization, reusing the very same state directory. + # The kill switch models the worst case: nothing will ever correct a served stale answer. + monkeypatch.setenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", "org_somebody_else") + monkeypatch.setenv("ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH", "0") + + assert v2_api._read_entitlement_cache() == {} + payload = v2_api.get_license() + assert payload["plan_source"] != "cloud" + assert "team" not in payload["features"], "another organization's Team grant was served" + + # Nothing was destroyed: the file is still correct for the organization it names. + monkeypatch.setenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", ORGANIZATION) + assert v2_api._read_entitlement_cache()["plan"] == "team" + + +def test_a_cached_entitlement_is_refused_when_the_organization_is_unknown( + monkeypatch, +) -> None: + """An unknown current organization is not permission to trust the cache either. + + A fresh bootstrap carries only a refresh credential and a control URL, so + ``_configured_organization_id()`` is empty until the first refresh names the + organization. Rejecting only a *mismatch* left that window serving whatever the reused + state directory still held — the previous organization's paid plan, and for ever with + the refresh disabled. + """ + + _connect(monkeypatch) + path = v2_api._entitlement_cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + v2_api._write_entitlement_cache({ + "plan": "team", "features": v2_api.entitled_features("team"), + "cloud_access_active": True, "organization_id": ORGANIZATION, + "fetched_at": time.time(), + }) + assert v2_api._read_entitlement_cache()["plan"] == "team" + + # The bootstrap window: connected, but nothing has named the organization yet. + monkeypatch.setattr(v2_api, "_configured_organization_id", lambda: "") + monkeypatch.setenv("ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH", "0") + + assert v2_api._read_entitlement_cache() == {}, ( + "a cache was trusted before the organization was known" + ) + + +def test_a_cached_entitlement_follows_a_reconnected_session_not_just_a_pin( + monkeypatch, +) -> None: + """No pin at all: reconnecting the installation rebinds it, and the cache must follow.""" + + _connect(monkeypatch, pinned_token=False) + cloud_session.save_bootstrap( + {"refresh_credential": "engr_rt_bootstrap", "organization_id": ORGANIZATION, + "installation_id": "inst_1", "device_id": "dev_1", "token_subject": "member"}, + control_url=CONTROL_URL, compute_url=CONTROL_URL, + ) + v2_api._write_entitlement_cache({ + "plan": "team", "features": v2_api.entitled_features("team"), + "cloud_access_active": True, "organization_id": ORGANIZATION, + "fetched_at": time.time(), + }) + assert v2_api._read_entitlement_cache()["plan"] == "team" + + # The installation is connected again, this time to a different organization. + cloud_session.save_bootstrap( + {"refresh_credential": "engr_rt_bootstrap", "organization_id": "org_second_tenant", + "installation_id": "inst_1", "device_id": "dev_1", "token_subject": "member"}, + control_url=CONTROL_URL, compute_url=CONTROL_URL, + ) + + assert v2_api._read_entitlement_cache() == {} + + +def test_an_unreadable_session_yields_no_entitlement_rather_than_raising( + monkeypatch, +) -> None: + """``saved_entitlement`` is on the boot path and may never raise.""" + + def _boom() -> dict: + raise cloud_session.CloudSessionError("unsafe state file", status=409) + + monkeypatch.setattr(cloud_session, "_load", _boom) + + assert cloud_session.saved_entitlement() == {} + assert v2_api._session_entitlement() == {} + + +# ── (5) precedence ──────────────────────────────────────────────────────────── +def test_the_session_plan_outranks_the_cached_entitlement(monkeypatch) -> None: + """Two persisted answers, one documented winner — never a silent disagreement.""" + + _connect(monkeypatch, pinned_token=False) + # The compatibility cache holds a stale Pro; the session holds the current Team. + v2_api._write_entitlement_cache({ + "plan": "pro", "features": v2_api.entitled_features("pro"), + "cloud_access_active": True, "organization_id": ORGANIZATION, + "fetched_at": time.time(), + }) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("pro"), + registration=_registration_entitlement("team"))) + cloud_session.access_for_workspace(None, require_compute=False) + + payload = v2_api.get_license() + + assert payload["plan"] == "team" + assert payload["plan_source"] == "session" + assert "team" in payload["features"] + # The stale compatibility cache is untouched, simply outranked. + assert v2_api._read_entitlement_cache()["plan"] == "pro" + + +def test_the_environment_override_outranks_the_session_plan(monkeypatch) -> None: + _connect(monkeypatch, pinned_token=False) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"), + registration=_registration_entitlement("team"))) + assert _settled_license(monkeypatch)["plan_source"] == "session" + + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "pro") + payload = v2_api.get_license() + + assert payload["plan"] == "pro" + assert payload["plan_source"] == "environment" + + +def test_the_environment_override_outranks_the_cached_entitlement(monkeypatch) -> None: + """An air-gapped or pinned-token deployment must still be able to state its plan.""" + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("pro"))) + assert _settled_license(monkeypatch)["plan"] == "pro" + + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "team") + payload = v2_api.get_license() + + assert payload["plan"] == "team" + assert payload["plan_source"] == "environment" + assert "team" in payload["features"] + + +@pytest.mark.parametrize("declared,expected", [ + ("pro", "pro"), ("team", "team"), ("free", "local"), ("TEAM", "team"), (" pro ", "pro"), +]) +def test_the_override_vocabulary_is_unchanged(monkeypatch, declared, expected) -> None: + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", declared) + + assert v2_api._hosted_plan() == expected + + +def test_an_unrecognised_override_falls_through_instead_of_locking_a_customer_out( + monkeypatch, +) -> None: + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "enterprise") + + assert _settled_license(monkeypatch)["plan"] == "team" + + +# ── (6) the two license surfaces must agree ─────────────────────────────────── +def test_both_license_surfaces_report_the_same_plan(monkeypatch) -> None: + """P2: ``/memory/license`` hardcoded ``local`` while ``/api/license`` reported Team.""" + + import asyncio + + from engraphis.routes import memory as memory_routes + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + v2 = _settled_license(monkeypatch) + v1 = asyncio.new_event_loop().run_until_complete(memory_routes.get_license())["data"] + + assert v1["plan"] == v2["plan"] == "team" + assert v1["features"] == v2["features"] + assert "team" in v1["features"] + # The legacy envelope and its disclosure fields are unchanged. + assert v1["cloud_managed"] is True + assert v1["trial_seconds"] == 259_200 + assert v1["grace_seconds"] == 86_400 + assert v1["grace_extends_cloud_access"] is False + assert v1["upgrade_url"] + + +def test_the_legacy_surface_still_reports_local_for_an_unconnected_installation() -> None: + """The pinned v1 contract (tests/test_v1_licensing.py) describes *this* case.""" + + import asyncio + + from engraphis.routes import memory as memory_routes + + v1 = asyncio.new_event_loop().run_until_complete(memory_routes.get_license())["data"] + + assert v1["plan"] == "local" + assert v1["features"] == [] + + +# ── (6b) the boot path resolves the plan once, and the override seam still works ── +def test_the_license_route_resolves_the_entitlement_exactly_once(monkeypatch) -> None: + """``_plan_entitlement`` reads the cloud session and the entitlement cache off disk and + can schedule a background refresh. ``hosted_plan_summary`` called it directly *and* + again through ``_hosted_plan``, doubling all of that on every ``/api/license`` — and + therefore on every ``/api/bootstrap``. + """ + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + monkeypatch.setenv("ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH", "0") + resolutions = [] + resolve = v2_api._plan_entitlement + + def _counted() -> dict: + resolutions.append(1) + return resolve() + + monkeypatch.setattr(v2_api, "_plan_entitlement", _counted) + + payload = v2_api.get_license() + + assert payload["plan"] == "pro" + assert len(resolutions) == 1, "the plan was resolved %d times per request" % len( + resolutions + ) + + +def test_one_resolution_also_covers_the_legacy_license_surface(monkeypatch) -> None: + """``/memory/license`` renders the same summary, so it inherited the same double read.""" + + import asyncio + + from engraphis.routes import memory as memory_routes + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + monkeypatch.setenv("ENGRAPHIS_CLOUD_ENTITLEMENT_REFRESH", "0") + reads = [] + read_cache = v2_api._read_entitlement_cache + + def _counted() -> dict: + reads.append(1) + return read_cache() + + monkeypatch.setattr(v2_api, "_read_entitlement_cache", _counted) + + asyncio.new_event_loop().run_until_complete(memory_routes.get_license()) + + assert len(reads) == 1, "the entitlement cache was read %d times" % len(reads) + + +def test_a_replaced_hosted_plan_still_overrides_the_resolved_entitlement( + monkeypatch, +) -> None: + """The override seam is load-bearing (tests/test_client_launch.py patches it). + + Resolving once must not mean ignoring a caller that replaced ``_hosted_plan``: its + answer still wins, and the feature list still falls back to this client's plan table. + """ + + _connect(monkeypatch) + _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team"))) + assert _settled_license(monkeypatch)["plan"] == "team" + + monkeypatch.setattr(v2_api, "_hosted_plan", lambda: "local") + payload = v2_api.get_license() + + assert payload["plan"] == "local" + assert payload["features"] == [] + assert payload["plan_source"] == "override" + assert payload["cloud_access_active"] is False + + monkeypatch.setattr(v2_api, "_hosted_plan", lambda: "team") + upgraded = v2_api.get_license() + + assert upgraded["plan"] == "team" + assert "team" in upgraded["features"] + + +# ── (7) the shipped modules must still build on the supported floor ─────────── +@pytest.mark.parametrize("relative", [ + "engraphis/routes/v2_api.py", + "engraphis/routes/memory.py", + "engraphis/cloud_session.py", +]) +def test_the_touched_modules_still_parse_on_python_39(relative) -> None: + ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8"), feature_version=(3, 9)) diff --git a/tests/test_licensing_launch.py b/tests/test_licensing_launch.py new file mode 100644 index 0000000..a129618 --- /dev/null +++ b/tests/test_licensing_launch.py @@ -0,0 +1,479 @@ +"""Launch guards for the paid-customer path: entitlement copy, secrets, and hangs. + +Every test here pins a behaviour a paying Pro/Team customer depends on at launch: + +* a lapsed or revoked subscription must be told how to fix billing, not shown an outage; +* an offline or flaky control plane must degrade to a retryable error, never a traceback + and never a permanent "your configuration is invalid"; +* a bearer token must not be renderable from a client object; and +* one caller-supplied timeout must bound the whole dial, not each resolved address. +""" +from __future__ import annotations + +import io +import json +import socket +import urllib.error + +import pytest + +from engraphis import cloud_features, cloud_session, hosted_client, licensing +from engraphis.cloud_features import CloudFeatureClient, CloudFeatureError +from engraphis.cloud_session import CloudSessionError + +# ── the private control plane's vocabulary, mirrored from (read-only) ────────── +# engraphis-cloud/engraphis_cloud/entitlements.py:20-28 and security.py:37-38. +# A mismatch here means a purchased feature silently never activates on the client. +SERVER_PLANS = {"free", "pro", "team"} +SERVER_PAID_PLANS = {"pro", "team"} +SERVER_PLAN_FEATURES = { + "free": set(), + "pro": {"analytics", "automation", "export", "sync"}, + "team": {"analytics", "automation", "export", "sync", "team"}, +} +SERVER_TRIAL_DURATION_SECONDS = 259_200 +SERVER_WORKSPACE_WRITE_GRACE_MAX_SECONDS = 86_400 + + +@pytest.fixture() +def _upgrade_url(monkeypatch): + monkeypatch.setenv("ENGRAPHIS_UPGRADE_URL", "https://account.example.test/billing") + monkeypatch.delenv("ENGRAPHIS_PRO_UPGRADE_URL", raising=False) + monkeypatch.delenv("ENGRAPHIS_TEAM_UPGRADE_URL", raising=False) + return "https://account.example.test/billing" + + +def _http_error(status: int, body: bytes = b"{}") -> urllib.error.HTTPError: + return urllib.error.HTTPError( + "https://control.example.test/v1/tokens/refresh", + status, + "failure", + {}, + io.BytesIO(body), + ) + + +def _opener_raising(error): + class _Opener: + def open(self, request, timeout=None): + raise error + + return lambda *handlers: _Opener() + + +# ── (c) credentials must never be renderable ────────────────────────────────── +def test_cloud_client_repr_never_renders_the_bearer_token() -> None: + """A dataclass __repr__ prints every field, so the token would reach any log.""" + + client = CloudFeatureClient( + "https://compute.example.test", "org_1", "eyJ-live-bearer-token" + ) + + assert "eyJ-live-bearer-token" not in repr(client) + assert "eyJ-live-bearer-token" not in str(client) + assert "eyJ-live-bearer-token" not in "%r" % (client,) + # The token is still carried; only its rendering is suppressed. + assert client.access_token == "eyJ-live-bearer-token" + + +def test_cloud_client_repr_keeps_the_non_secret_fields() -> None: + client = CloudFeatureClient("https://compute.example.test", "org_1", "token") + + assert "compute.example.test" in repr(client) + assert "org_1" in repr(client) + + +# ── (b) a flaky control plane must not produce a traceback ──────────────────── +def test_refresh_survives_an_error_body_that_fails_to_read(monkeypatch) -> None: + """Draining an HTTPError body can itself time out. + + A sibling ``except`` clause does not cover an exception raised inside the HTTPError + handler, so an unguarded ``exc.read()`` escaped as an unhandled traceback and became + an opaque dashboard 500 exactly when the cloud was flaky. + """ + + class _Unreadable(io.BytesIO): + def read(self, *args, **kwargs): + raise TimeoutError("the read timed out") + + def close(self): + if self.closed: + return + # Model a reset after the descriptor was released. Leaving BytesIO open + # would make its finalizer repeat the synthetic failure as an unraisable + # warning after the behaviour under test has already handled it. + super().close() + raise OSError("the socket was already reset") + + error = urllib.error.HTTPError( + "https://control.example.test/v1/tokens/refresh", 500, "failure", {}, _Unreadable() + ) + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", _opener_raising(error) + ) + + with pytest.raises(CloudSessionError) as caught: + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + assert caught.value.status == 503 + + +@pytest.mark.parametrize("status", [400, 404, 409, 418, 500, 502, 503]) +def test_every_refresh_status_degrades_to_a_structured_error(monkeypatch, status) -> None: + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", _opener_raising(_http_error(status)) + ) + + with pytest.raises(CloudSessionError) as caught: + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + assert 400 <= caught.value.status <= 599 + + +def test_malformed_refresh_json_is_a_structured_error(monkeypatch) -> None: + class _Response: + def read(self, size): + return b"{not json" + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + class _Opener: + def open(self, request, timeout=None): + return _Response() + + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", lambda *handlers: _Opener() + ) + + with pytest.raises(CloudSessionError, match="invalid session response"): + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + +@pytest.mark.parametrize( + "error", + [ + urllib.error.URLError(ConnectionRefusedError("connection refused")), + urllib.error.URLError(socket.gaierror("name resolution failed")), + TimeoutError("timed out"), + ], +) +def test_transport_failures_report_a_retryable_outage(monkeypatch, error) -> None: + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", _opener_raising(error) + ) + + with pytest.raises(CloudSessionError, match="temporarily unreachable"): + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + +# ── (a)/(6) billing and authorization copy must be actionable ───────────────── +def test_lapsed_subscription_is_not_reported_as_an_outage(monkeypatch, _upgrade_url): + """402 is the control plane's "no active paid entitlement". + + Reporting it with the generic 503 outage copy made a past_due customer retry forever + instead of being sent to the one page that restores their features. + """ + + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", _opener_raising(_http_error(402)) + ) + + with pytest.raises(CloudSessionError) as caught: + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + assert caught.value.status == 402 + assert _upgrade_url in str(caught.value) + assert "billing" in str(caught.value).lower() + + +@pytest.mark.parametrize("status", [401, 403]) +def test_revoked_session_still_asks_for_a_clean_reconnect(monkeypatch, status) -> None: + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", _opener_raising(_http_error(status)) + ) + + with pytest.raises(CloudSessionError, match="connect again") as caught: + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + assert caught.value.status == status + + +def test_rate_limited_refresh_keeps_its_own_status(monkeypatch) -> None: + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", _opener_raising(_http_error(429)) + ) + + with pytest.raises(CloudSessionError) as caught: + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + assert caught.value.status == 429 + assert "Try again shortly" in str(caught.value) + + +def test_unregistered_installation_is_a_reconnect_not_an_outage(monkeypatch) -> None: + """404 means the org/entitlement row is gone; retrying forever cannot fix that.""" + + monkeypatch.setattr( + cloud_session, "build_pinned_https_opener", _opener_raising(_http_error(404)) + ) + + with pytest.raises(CloudSessionError, match="connect again") as caught: + cloud_session._post_refresh("https://control.example.test", "r", "ws", "member") + + assert caught.value.status == 409 + + +def test_feature_gate_402_points_at_billing(_upgrade_url) -> None: + message, transient = cloud_features._public_http_error(402) + + assert _upgrade_url in message + assert transient is False + + +def test_untrusted_provider_bodies_are_still_never_reflected(monkeypatch) -> None: + secret = "provider-secret https://internal.service/trace" + error = urllib.error.HTTPError( + "https://compute.example.test/private", + 402, + "failure", + {}, + io.BytesIO(json.dumps({"detail": secret}).encode("utf-8")), + ) + + class _Opener: + def open(self, request, timeout): + raise error + + monkeypatch.setattr( + cloud_features.urllib.request, "build_opener", lambda *handlers: _Opener() + ) + client = CloudFeatureClient("https://compute.example.test", "org_1", "token") + + with pytest.raises(CloudFeatureError) as caught: + client._request("GET", "/private") + + assert secret not in str(caught.value) + assert caught.value.status == 402 + + +# ── (b) offline must not read as "your configuration is invalid" ────────────── +def _configure_cloud(monkeypatch) -> None: + monkeypatch.setenv("ENGRAPHIS_CLOUD_CONTROL_URL", "https://control.example.test") + monkeypatch.setenv("ENGRAPHIS_CLOUD_COMPUTE_URL", "https://compute.example.test") + monkeypatch.setenv("ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", "saved-refresh") + + +def test_offline_customer_gets_a_retryable_outage_not_a_config_error(monkeypatch): + """A broken resolver and a bad URL both raised ValueError from URL validation. + + The caller turned that into a permanent, non-retryable 409 "configuration is + invalid", which is wrong and unactionable for a paying customer on a plane. + """ + + _configure_cloud(monkeypatch) + + def _unresolvable(*args, **kwargs): + raise socket.gaierror("Name or service not known") + + monkeypatch.setattr(socket, "getaddrinfo", _unresolvable) + + with pytest.raises(CloudFeatureError) as caught: + CloudFeatureClient.from_environment("ws_1") + + assert caught.value.status == 503 + assert caught.value.transient is True + assert "configuration is invalid" not in str(caught.value) + + +def test_unresolvable_host_stays_a_value_error_for_direct_callers(monkeypatch) -> None: + """The new error narrows the type without breaking ``except ValueError`` callers.""" + + def _unresolvable(*args, **kwargs): + raise socket.gaierror("Name or service not known") + + monkeypatch.setattr(socket, "getaddrinfo", _unresolvable) + + assert issubclass(hosted_client.CloudUrlUnresolved, ValueError) + with pytest.raises(ValueError, match="could not be resolved"): + hosted_client.validate_cloud_base_url("https://cloud.example.test") + + +def test_a_genuinely_invalid_url_is_still_a_permanent_config_error(monkeypatch) -> None: + monkeypatch.setenv("ENGRAPHIS_CLOUD_CONTROL_URL", "ftp://control.example.test") + monkeypatch.setenv("ENGRAPHIS_CLOUD_COMPUTE_URL", "https://compute.example.test") + monkeypatch.setenv("ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", "saved-refresh") + + with pytest.raises(CloudFeatureError) as caught: + CloudFeatureClient.from_environment("ws_1") + + assert caught.value.status == 409 + + +@pytest.mark.parametrize( + ("status", "transient"), + [(401, False), (402, False), (409, False), (429, True), (503, True)], +) +def test_session_failures_carry_a_usable_retry_signal(monkeypatch, status, transient): + def _fail(*args, **kwargs): + raise CloudSessionError("private local state detail", status=status) + + monkeypatch.setattr(cloud_features, "access_for_workspace", _fail) + + with pytest.raises(CloudFeatureError) as caught: + CloudFeatureClient.from_environment("ws_1") + + assert caught.value.status == status + assert caught.value.transient is transient + # Session text can quote local state paths and must not cross this boundary. + assert "private local state detail" not in str(caught.value) + + +def test_unconnected_installation_is_told_to_connect(monkeypatch) -> None: + def _fail(*args, **kwargs): + raise CloudSessionError("connect first", status=401) + + monkeypatch.setattr(cloud_features, "access_for_workspace", _fail) + + with pytest.raises(CloudFeatureError, match="Connect this installation"): + CloudFeatureClient.from_environment("ws_1") + + +# ── (b) one timeout budget must bound the whole dial ────────────────────────── +def test_one_timeout_budget_is_shared_across_every_resolved_address(monkeypatch): + """Handing each vetted address the full timeout multiplied the stated budget. + + ``cloud_session`` dials while holding an exclusive cross-process refresh lock, so a + multi-homed endpoint that blackholes traffic stalled every worker for N x timeout. + """ + + addresses = ["93.184.216.34", "93.184.216.35", "93.184.216.36", "93.184.216.37"] + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 443)) for ip in addresses + ], + ) + + class _Clock: + now = 0.0 + + def monotonic(self): + return self.now + + clock = _Clock() + monkeypatch.setattr(hosted_client, "time", clock) + + handed = [] + + def _connect(address, timeout, source): + handed.append(timeout) + clock.now += timeout # a blackholed address burns its whole allowance + raise OSError("connection timed out") + + connection = hosted_client.PinnedHTTPSConnection("cloud.example", timeout=10.0) + connection._create_connection = _connect + + with pytest.raises(OSError): + connection.connect() + + assert handed, "the dial must attempt at least one vetted address" + assert sum(handed) <= 10.0 + hosted_client.MIN_ATTEMPT_TIMEOUT_SECONDS + assert len(handed) < len(addresses) + + +def test_a_fast_failure_still_tries_every_vetted_address(monkeypatch) -> None: + """Bounding the budget must not cost the existing dual-stack failover.""" + + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:2800:220:1::1", 443)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)), + ], + ) + tried = [] + + class _Context: + def wrap_socket(self, sock, *, server_hostname): + return sock + + def _connect(address, timeout, source): + tried.append(address[0]) + if ":" in address[0]: + raise OSError("IPv6 route is unavailable") + return object() + + connection = hosted_client.PinnedHTTPSConnection("cloud.example", timeout=10.0) + connection._context = _Context() + connection._create_connection = _connect + + connection.connect() + + assert tried == ["2606:2800:220:1::1", "93.184.216.34"] + + +def test_pinned_connection_never_inherits_a_blocking_default() -> None: + """urllib supplies ``_GLOBAL_DEFAULT_TIMEOUT`` when a caller omits ``timeout=``.""" + + default = hosted_client.DEFAULT_CONNECT_TIMEOUT_SECONDS + + assert hosted_client.PinnedHTTPSConnection("cloud.example").timeout == default + assert hosted_client.PinnedHTTPSConnection( + "cloud.example", timeout=None + ).timeout == default + assert hosted_client.PinnedHTTPSConnection( + "cloud.example", timeout=socket._GLOBAL_DEFAULT_TIMEOUT + ).timeout == default + # An explicit budget is still honoured exactly. + assert hosted_client.PinnedHTTPSConnection( + "cloud.example", timeout=7.5 + ).timeout == 7.5 + + +# ── (d) plan drift against the private control plane ────────────────────────── +def test_client_plan_names_match_the_control_plane() -> None: + from engraphis.commercial import expected_checkout_targets, manifest + + assert set(manifest()["plans"]) == SERVER_PLANS + assert {plan for plan, _ in expected_checkout_targets()} == SERVER_PAID_PLANS + + +def test_every_server_feature_key_advertises_the_plan_that_grants_it() -> None: + """A feature the server grants only to Team must not advertise Pro, and vice versa.""" + + for feature in sorted(SERVER_PLAN_FEATURES["team"]): + granted_by_pro = feature in SERVER_PLAN_FEATURES["pro"] + expected = "pro" if granted_by_pro else "team" + assert licensing.required_plan(feature) == expected, feature + assert licensing.required_plan(feature) in SERVER_PAID_PLANS + + +def test_plan_lookups_are_case_and_whitespace_insensitive(_upgrade_url, monkeypatch): + """The control plane emits lowercase plans; a caller must not be able to drift.""" + + monkeypatch.setenv("ENGRAPHIS_TEAM_UPGRADE_URL", "https://account.example.test/team") + + for spelling in ("team", "Team", "TEAM", " team "): + assert licensing.required_plan(spelling) == "team" + assert licensing.upgrade_url(spelling) == "https://account.example.test/team" + for spelling in ("sync", "Sync", " SYNC "): + assert licensing.required_plan(spelling) == "pro" + + +def test_trial_and_grace_windows_match_the_control_plane() -> None: + assert licensing.TRIAL_SECONDS == SERVER_TRIAL_DURATION_SECONDS + assert ( + licensing.MAX_LOCAL_WRITE_GRACE_SECONDS + == SERVER_WORKSPACE_WRITE_GRACE_MAX_SECONDS + ) + + +def test_unknown_features_never_advertise_a_plan_the_server_cannot_sell() -> None: + for feature in ("", " ", "not-a-real-feature", None): + assert licensing.required_plan(feature) in SERVER_PAID_PLANS diff --git a/tests/test_packaging.py b/tests/test_packaging.py index d46da52..c24bfdf 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -213,4 +213,7 @@ def test_customer_hosting_docs_do_not_claim_private_cloud_authority(): assert "license issuer" in combined assert "ENGRAPHIS_CLOUD_CONTROL_URL" in hosting assert "ENGRAPHIS_CLOUD_COMPUTE_URL" in hosting - assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1" in combined + # Managed compute follows the cloud account now. The hosting docs may still document + # the operator override, but they must never present hand-setting it as the way a + # customer turns managed compute on. + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1" not in combined diff --git a/tests/test_release_infrastructure.py b/tests/test_release_infrastructure.py index 7202315..52d3b17 100644 --- a/tests/test_release_infrastructure.py +++ b/tests/test_release_infrastructure.py @@ -28,8 +28,11 @@ def test_published_image_and_railway_template_fail_safe_to_customer_mode(): assert local_api["value"] == "${{ secret(48) }}" assert local_api["secret"] is True assert local_api["required"] is True + # Managed-compute consent travels with the cloud account. A template that shipped a + # hard-coded value would override that for every deployment made from it -- "0" would + # silently opt a connected deployment back out -- so the default must stay blank. managed_consent = template["variables"]["ENGRAPHIS_MANAGED_COMPUTE_CONSENT"] - assert managed_consent["value"] == "0" + assert not managed_consent["value"] assert managed_consent["required"] is False for removed in ( "ENGRAPHIS_DEPLOYMENT_TOKEN", diff --git a/tests/test_update.py b/tests/test_update.py index e406da1..d5eb5c6 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,5 +1,10 @@ """The updater must never embed a stale release or select prerelease-like refs.""" import json +import os +import shutil +import subprocess +import sys +import time from pathlib import Path from types import SimpleNamespace @@ -8,6 +13,39 @@ from scripts import update +class _FakeProcess: + """A ``Popen`` stand-in: the updater reads ``pid``/``returncode`` and drains once. + + Every step now spawns through ``subprocess.Popen`` — that is the only way to hold on to + a handle for the *process tree* long enough to destroy it at the budget — so a fake + that intercepted ``subprocess.run`` would no longer see the call at all, and the real + ``pip``/``git`` would run instead. + """ + + def __init__(self, returncode=0, stdout=""): + self.pid = 4242 + self.returncode = returncode + self._stdout = stdout + self.drains = [] + + def communicate(self, timeout=None): + self.drains.append(timeout) + return self._stdout, None + + +def _spawner(handler, calls=None): + """Build a ``Popen`` fake from a ``cmd -> _FakeProcess`` *handler*.""" + + def fake_popen(command, **kwargs): + command = list(command) + process = handler(command) + if calls is not None: + calls.append((command, kwargs, process)) + return process + + return fake_popen + + def test_select_latest_stable_semver_tag(): assert update._select_latest_tag([ "v0.9.7", "v1.0.0", "v0.10.0", "v1.0.0rc1", "release/v9.0.0", @@ -32,10 +70,11 @@ def test_requested_version_must_be_a_stable_semver(value): def test_pypi_version_pin_is_applied_to_the_install_target(monkeypatch): calls = [] - monkeypatch.setattr(update.subprocess, "run", lambda command, **_kwargs: calls.append(command)) + monkeypatch.setattr(update.subprocess, "Popen", + _spawner(lambda _cmd: _FakeProcess(), calls)) monkeypatch.setattr(update, "LATEST_TAG", "v1.2.3") update._pip_update("pypi") - assert calls == [[ + assert [command for command, _kwargs, _proc in calls] == [[ update.sys.executable, "-m", "pip", "install", "--upgrade", "engraphis[server]==1.2.3", ]] @@ -45,11 +84,9 @@ def test_detects_noneditable_git_install_from_pep610_metadata(monkeypatch): monkeypatch.delenv("ENGRAPHIS_DOCKER", raising=False) monkeypatch.setattr(update.Path, "exists", lambda self: False) monkeypatch.setattr( - update.subprocess, - "run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, stdout="Name: engraphis\nLocation: /site-packages\n" - ), + update.subprocess, "Popen", + _spawner(lambda _cmd: _FakeProcess( + stdout="Name: engraphis\nLocation: /site-packages\n")), ) distribution = SimpleNamespace(read_text=lambda name: json.dumps({ "url": "https://github.com/Coding-Dev-Tools/engraphis.git", @@ -65,28 +102,23 @@ def test_noneditable_git_update_preserves_recorded_fork(monkeypatch): monkeypatch.setattr(update, "_installed_git_url", lambda: fork) monkeypatch.setattr(update, "LATEST_TAG", "v1.2.3") monkeypatch.setattr(update.shutil, "which", lambda _name: "git") - monkeypatch.setattr( - update.subprocess, - "run", - lambda command, **_kwargs: calls.append(command) - or SimpleNamespace(returncode=0, stdout=""), - ) + monkeypatch.setattr(update.subprocess, "Popen", + _spawner(lambda _cmd: _FakeProcess(), calls)) update._pip_update("git") - assert calls[-1][-1] == f"git+{fork}@v1.2.3#egg=engraphis" - assert update.REPO_URL not in calls[-1][-1] + last = calls[-1][0] + assert last[-1] == f"git+{fork}@v1.2.3#egg=engraphis" + assert update.REPO_URL not in last[-1] def test_non_git_pep610_install_is_not_misclassified(monkeypatch): monkeypatch.delenv("ENGRAPHIS_DOCKER", raising=False) monkeypatch.setattr(update.Path, "exists", lambda self: False) monkeypatch.setattr( - update.subprocess, - "run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, stdout="Name: engraphis\nLocation: /site-packages\n" - ), + update.subprocess, "Popen", + _spawner(lambda _cmd: _FakeProcess( + stdout="Name: engraphis\nLocation: /site-packages\n")), ) distribution = SimpleNamespace(read_text=lambda name: json.dumps({ "url": "https://example.com/archive", @@ -104,33 +136,29 @@ def test_failed_editable_reinstall_restores_original_branch(monkeypatch, tmp_pat calls = [] install_attempts = 0 - def fake_run(command, **kwargs): + def handler(command): nonlocal install_attempts - calls.append(command) if command[:4] == [update.sys.executable, "-m", "pip", "show"]: - return SimpleNamespace( - returncode=0, - stdout=f"Editable project location: {project}\n", - ) + return _FakeProcess(stdout=f"Editable project location: {project}\n") if "rev-parse" in command: - return SimpleNamespace(returncode=0, stdout="old-sha\n") + return _FakeProcess(stdout="old-sha\n") if "symbolic-ref" in command: - return SimpleNamespace(returncode=0, stdout="main\n") + return _FakeProcess(stdout="main\n") if "rev-list" in command: - return SimpleNamespace(returncode=0, stdout="new-sha\n") + return _FakeProcess(stdout="new-sha\n") if "status" in command: - return SimpleNamespace(returncode=0, stdout="") + return _FakeProcess(stdout="") if command[:4] == [update.sys.executable, "-m", "pip", "install"]: install_attempts += 1 if install_attempts == 1: - raise update.subprocess.CalledProcessError(1, command) - return SimpleNamespace(returncode=0, stdout="") + return _FakeProcess(returncode=1) + return _FakeProcess() - monkeypatch.setattr(update.subprocess, "run", fake_run) + monkeypatch.setattr(update.subprocess, "Popen", _spawner(handler, calls)) with pytest.raises(update.subprocess.CalledProcessError): update._git_update() - assert ["git", "-C", str(project), "checkout", "main"] in calls + assert ["git", "-C", str(project), "checkout", "main"] in [c for c, _k, _p in calls] assert install_attempts == 2 @@ -157,3 +185,338 @@ def test_docker_update_does_not_claim_a_nonexistent_registry_image(capsys): output = capsys.readouterr().out assert "does not publish a managed container image" in output assert "ghcr.io" not in output + + +def test_a_stalled_install_probe_is_reported_instead_of_crashing(monkeypatch, capsys): + """``_detect_install`` re-raises ``UpdateTimeout`` so the user gets actionable copy. + + The call used to sit *outside* ``main()``'s ``try``, so the crafted message was thrown + away and a stalled `pip show` printed a traceback — the one outcome the exception was + written to prevent. + """ + + def _stall(): + raise update.UpdateTimeout( + "Reading the installed Engraphis metadata timed out after 60s. Check your " + "network connection, proxy settings, and package index, then run " + "`engraphis-update` again." + ) + + monkeypatch.setattr(update, "_detect_install", _stall) + + with pytest.raises(SystemExit) as exc: + update.main([]) + + assert exc.value.code == 1 + captured = capsys.readouterr() + assert "timed out" in captured.err + assert "engraphis-update" in captured.err + assert "Traceback" not in captured.err + + +# ── a budget is only real if nothing can outlive it ─────────────────────────── +def test_every_git_step_closes_stdin_and_refuses_a_credential_prompt(monkeypatch, tmp_path): + """An expired token must fail the step, not open a prompt nobody is there to answer. + + ``git`` otherwise reads a username from the terminal (or raises the Git Credential + Manager dialog on Windows) and blocks past every budget above it, with no network + fault to diagnose. + """ + + project = tmp_path / "clone" + (project / ".git").mkdir(parents=True) + monkeypatch.setattr(update.shutil, "which", lambda _name: "git") + monkeypatch.setattr(update, "LATEST_TAG", "v1.2.3") + calls = [] + + def handler(command): + if command[:4] == [sys.executable, "-m", "pip", "show"]: + return _FakeProcess(stdout=f"Editable project location: {project}\n") + if "rev-parse" in command: + return _FakeProcess(stdout="old-sha\n") + if "symbolic-ref" in command: + return _FakeProcess(stdout="main\n") + if "rev-list" in command: + return _FakeProcess(stdout="new-sha\n") + return _FakeProcess() + + monkeypatch.setattr(update.subprocess, "Popen", _spawner(handler, calls)) + + update._git_update(check_only=True) + + git_calls = [entry for entry in calls if entry[0][0] == "git"] + assert git_calls, "expected the editable path to shell out to git" + for command, kwargs, _process in git_calls: + assert kwargs["stdin"] is subprocess.DEVNULL, command + assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0", command + assert kwargs["env"]["GCM_INTERACTIVE"] == "never", command + # Every step — not only the parsed ones — is spawned somewhere a whole tree can be + # killed from, or a descendant outlives the budget that was supposed to bound it. + for keyword, value in update._OWN_PROCESS_GROUP.items(): + assert kwargs.get(keyword) == value, command + # The fetch is displayed, not parsed: no pipe, and its budget rides on the drain. + fetches = [(kwargs, process) for cmd, kwargs, process in git_calls if "fetch" in cmd] + assert fetches and fetches[0][0].get("stdout") is None + assert fetches[0][1].drains == [update._GIT_FETCH_TIMEOUT_S] + + +def test_the_remote_tag_query_parses_stdout_through_the_bounded_reader(monkeypatch): + """It must keep its stdout — so it may not simply stop capturing; it changes *how*.""" + + monkeypatch.setattr( + update.subprocess, "Popen", + lambda *a, **k: pytest.fail("the refs query must go through the bounded reader"), + ) + monkeypatch.setattr( + update.subprocess, "run", + lambda *a, **k: pytest.fail("nothing in the updater may spawn through subprocess.run"), + ) + seen = {} + + def fake_captured(cmd, what, timeout, env=None): + seen.update(cmd=list(cmd), timeout=timeout, env=env) + return SimpleNamespace( + returncode=0, + stdout="a\trefs/tags/v0.9.0\nb\trefs/tags/v1.0.0\nc\trefs/heads/main\n", + ) + + monkeypatch.setattr(update, "_run_captured", fake_captured) + + assert update._remote_latest_tag("git", "https://example.test/e.git") == "v1.0.0" + assert seen["timeout"] == update._GIT_LS_REMOTE_TIMEOUT_S + assert seen["env"]["GIT_TERMINAL_PROMPT"] == "0" + + +def test_the_bounded_reader_pipes_only_stdout_and_never_prompts(monkeypatch): + """Fewer pipes is fewer handles a grandchild can hold open; stderr goes to the user.""" + + captured = {} + + class _Process: + pid = 4321 + returncode = 0 + + def communicate(self, timeout=None): + captured["drain_timeout"] = timeout + return "abc\trefs/tags/v2.0.0\n", None + + def fake_popen(cmd, **kwargs): + captured["cmd"] = list(cmd) + captured["kwargs"] = kwargs + return _Process() + + monkeypatch.setattr(update.subprocess, "Popen", fake_popen) + + result = update._run_captured(["git", "ls-remote"], "Listing", 60, env=update._git_env()) + + assert result.returncode == 0 + assert "v2.0.0" in result.stdout + assert captured["drain_timeout"] == 60 + assert captured["kwargs"]["stdout"] is subprocess.PIPE + assert captured["kwargs"]["stdin"] is subprocess.DEVNULL + assert "stderr" not in captured["kwargs"], "stderr must stay on the terminal" + assert captured["kwargs"]["env"]["GIT_TERMINAL_PROMPT"] == "0" + assert captured["kwargs"]["env"]["GCM_INTERACTIVE"] == "never" + + +# A child that outlives its parent and inherits the same stdout pipe — exactly the shape of +# ``git`` forking ``git-remote-https``. ``subprocess.run(capture_output=True, timeout=N)`` +# waits for this grandchild to exit no matter what ``N`` says. +_HOLDS_THE_PIPE = ( + "import subprocess, sys, time; " + "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)']); " + "time.sleep(120)" +) + + +def test_the_budget_holds_even_when_a_grandchild_still_owns_the_pipe(): + """The measured defect: a 5s budget returned after 21s, or never. Now it returns.""" + + started = time.monotonic() + with pytest.raises(update.UpdateTimeout) as exc: + update._run_captured([sys.executable, "-c", _HOLDS_THE_PIPE], "Stalled query", 2) + elapsed = time.monotonic() - started + + assert "timed out after 2s" in str(exc.value) + # Generous, because the point is the difference between "bounded" and "120 seconds". + assert elapsed < 30, "the budget was not enforced: waited %.1fs" % elapsed + + +def test_a_stalled_uncaptured_step_takes_its_descendants_with_it(tmp_path): + """The budget has to bound the *tree*, not just the process we happen to hold. + + ``pip install``, ``pipx`` and ``git fetch`` are displayed rather than parsed, so + ``subprocess.run(timeout=...)`` did return on time for them — and left the descendants + running. pip's vendored resolver or ``git-remote-https`` then keeps writing to the + environment and the repository *while the caller has already started a rollback or a + retry*, which is exactly the guarantee the budget is supposed to buy. Real processes, + because the whole class of bug is that mocks hid the platform's actual kill semantics. + """ + + sentinel = tmp_path / "descendant-outlived-the-budget" + grandchild = tmp_path / "grandchild.py" + grandchild.write_text( + "import sys, time\n" + "time.sleep(3)\n" + "open(sys.argv[1], 'w').write('still here')\n", + encoding="utf-8", + ) + child = tmp_path / "child.py" + child.write_text( + "import subprocess, sys, time\n" + "subprocess.Popen([sys.executable, sys.argv[1], sys.argv[2]])\n" + "time.sleep(60)\n", + encoding="utf-8", + ) + + started = time.monotonic() + with pytest.raises(update.UpdateTimeout) as exc: + update._run([sys.executable, str(child), str(grandchild), str(sentinel)], + "Installing the update", 2) + assert "timed out after 2s" in str(exc.value) + assert time.monotonic() - started < 30, "the call itself was not bounded" + + # Well past the moment the grandchild would have written, had it survived the kill. + deadline = time.monotonic() + 6 + while time.monotonic() < deadline and not sentinel.exists(): + time.sleep(0.2) + assert not sentinel.exists(), ( + "a descendant outlived the budget and was still touching the environment while " + "the updater had already moved on to rollback" + ) + + +# ── the destructive step belongs inside the rollback boundary ───────────────── +@pytest.fixture +def real_clone(tmp_path, monkeypatch): + """A real clone, with a real ``origin`` and a real release tag ahead of its HEAD. + + Real git, because the failure modes under test are git's own: what a checkout does + when ``index.lock`` exists, and what it leaves behind when it is killed holding one. + """ + + git = shutil.which("git") + if not git: # pragma: no cover - git is present everywhere this suite runs + pytest.skip("git is not installed") + + # Neutralise the developer's own git configuration: a global hooksPath, commit + # signing or a different init.defaultBranch would otherwise decide this test. + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(home / "gitconfig")) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + monkeypatch.setenv("GIT_AUTHOR_NAME", "Engraphis Test") + monkeypatch.setenv("GIT_AUTHOR_EMAIL", "test@example.test") + monkeypatch.setenv("GIT_COMMITTER_NAME", "Engraphis Test") + monkeypatch.setenv("GIT_COMMITTER_EMAIL", "test@example.test") + + def run(*args, cwd): + subprocess.run([git, *args], cwd=str(cwd), check=True, env=dict(os.environ), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + upstream = tmp_path / "upstream" + upstream.mkdir() + run("-c", "init.defaultBranch=main", "init", "-q", ".", cwd=upstream) + (upstream / "f.txt").write_text("one\n", encoding="utf-8") + run("add", "f.txt", cwd=upstream) + run("commit", "-qm", "one", cwd=upstream) + + clone = tmp_path / "clone" + run("clone", "-q", str(upstream), str(clone), cwd=tmp_path) + + (upstream / "f.txt").write_text("two\n", encoding="utf-8") + run("commit", "-qam", "two", cwd=upstream) + run("tag", "v9.9.9", cwd=upstream) + + return SimpleNamespace(git=git, clone=clone) + + +def _branch_of(git, project): + return subprocess.run([git, "-C", str(project), "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True).stdout.strip() + + +def _pip_router(project, pip_calls): + """Let every git command run for real; answer pip without installing anything.""" + + real_run = update._run + + def routed(cmd, what, timeout, **kwargs): + cmd = list(cmd) + if cmd[:3] == [sys.executable, "-m", "pip"]: + pip_calls.append(cmd) + stdout = ("Editable project location: %s\n" % project + if cmd[3] == "show" else "") + return SimpleNamespace(returncode=0, stdout=stdout) + return real_run(cmd, what, timeout, **kwargs) + + return routed + + +def test_a_killed_checkout_clears_its_own_lock_and_is_rolled_back( + real_clone, monkeypatch, capsys): + """``git checkout tags/`` is the destructive step, so it needs the same guard. + + It used to run *above* the ``try:`` that performs the rollback: a checkout that blew + its 120s budget raised straight past the restore, leaving an editable install partially + switched while the CLI reported nothing but a timeout — and the git we killed left + ``.git/index.lock`` behind, which then blocks every subsequent git command in the clone. + """ + + project = real_clone.clone + lock = project / ".git" / "index.lock" + monkeypatch.setattr(update, "LATEST_TAG", "v9.9.9") + monkeypatch.setattr(update.shutil, "which", lambda _name: real_clone.git) + + pip_calls = [] + routed = _pip_router(project, pip_calls) + + def stalled_checkout(cmd, what, timeout, **kwargs): + cmd = list(cmd) + if cmd[-1] == "tags/v9.9.9": + lock.write_text("", encoding="utf-8") # what a killed checkout leaves behind + raise update.UpdateTimeout("Checking out the release tag timed out after 120s.") + return routed(cmd, what, timeout, **kwargs) + + monkeypatch.setattr(update, "_run", stalled_checkout) + + with pytest.raises(update.UpdateTimeout): + update._git_update() + + err = capsys.readouterr().err + assert "Restoring the previous checkout" in err, "the checkout skipped the rollback" + assert not lock.exists(), "our own killed checkout's lock still wedges the clone" + assert "Removed the index lock" in err and str(lock) in err + # The restore really ran, against a real repository. + assert _branch_of(real_clone.git, project) == "main" + assert pip_calls[-1][:5] == [sys.executable, "-m", "pip", "install", "-e"] + + +def test_a_lock_this_updater_did_not_create_is_named_not_deleted( + real_clone, monkeypatch, capsys): + """A pre-existing lock may belong to a *live* git; deleting it is worse than the wedge. + + Here the real ``git checkout`` really fails on the real lock, and so does the restore. + Both of those must be visible: the exact path the user has to deal with, and the fact + that the rollback did not succeed — which the unchecked restore used to swallow while + ``main()`` still claimed "the previous installation was restored when possible". + """ + + project = real_clone.clone + lock = project / ".git" / "index.lock" + lock.write_text("", encoding="utf-8") # left by something else, before we start + + monkeypatch.setattr(update, "LATEST_TAG", "v9.9.9") + monkeypatch.setattr(update.shutil, "which", lambda _name: real_clone.git) + pip_calls = [] + monkeypatch.setattr(update, "_run", _pip_router(project, pip_calls)) + + with pytest.raises(subprocess.CalledProcessError): + update._git_update() + + err = capsys.readouterr().err + assert "Restoring the previous checkout" in err, "the checkout skipped the rollback" + assert lock.exists(), "a lock this updater did not create must not be deleted" + assert "did not create" in err and str(lock) in err + assert "Rollback FAILED" in err, "a failed restore must not be reported as a success" + assert _branch_of(real_clone.git, project) == "main"