From 3cf76bee76289fe764f4b4974de89756825ad38c Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 01:26:17 +0000 Subject: [PATCH 01/12] Fix licensing gating, cloud failure modes and update hangs for launch Closes defects that denied paying customers what they bought, hung or crashed the client when the cloud was unreachable, and leaked a credential into logs. Each fix carries a regression test. Paying customers were denied what they bought - /api/license always returned an empty feature list, so Analytics, Automation and Team rendered with PRO/TEAM lock badges for customers who had paid for them. It now reports the entitled feature set. - The plan is read from the registration and refresh handshake, which now discloses it, with an explicit precedence: ENGRAPHIS_CLOUD_PLAN override, then the persisted session plan, then the cached entitlement, then inference. A Team customer previously had to set an undocumented environment variable or be shown PRO. - Team upgrade links pointed at the Pro page. - A 409 consent_required is no longer treated as a billing answer. It was folded into the entitlement check, so a paying customer who had not set ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1 was shown a panel telling them to buy the plan they already owned. Consent is now routed ahead of entitlement at every hosted call site. - loadSyncStatus rendered the purchase panel for every failure, including a transient network error or a 5xx, so a dropped connection told a paying customer to buy Pro. - /memory/license reported plan "local" for a connected paying customer, so the two license surfaces contradicted each other. Cloud failure modes - Draining an HTTP error body inside the except block let a timeout escape as an unhandled TimeoutError, surfacing as an opaque 500 with a traceback exactly when the cloud was flaky. - A DNS or resolution failure raised the same error as a genuinely malformed URL and was reported to the customer as a non-transient configuration problem with no retry. - Lapsed billing was indistinguishable from an outage: 402, 429 and 404 all collapsed into one 503, so a past_due customer retried forever instead of being sent to billing. - Connection attempts shared one deadline instead of giving each vetted address the full timeout, which turned a 10s refresh budget into N x 10s while holding the exclusive cross-process refresh lock. - PinnedHTTPSConnection pins a default connect timeout rather than inheriting urllib's block-forever default. Credential handling - The access token is excluded from the client's repr. The dataclass default printed a live bearer token into any traceback frame or debug log. - update_check.py uses the pinned HTTPS opener, so the one credential-adjacent request with a user-controllable endpoint is no longer exempt from the repo's SSRF and DNS-rebinding vetting. Updater - scripts/update.py had nineteen untimed network subprocess calls, all with captured output, so engraphis-update hung silently and indefinitely against a stalled index or unreachable remote. A stall mid-upgrade left the tree checked out to the new tag with the rollback unreached. Every call is now bounded and the rollback runs on timeout. Test suite: ~1209 -> 1363 passing, plus 11 end-to-end tests. The end-to-end suite previously only ever exercised a free customer, which is why a paying user seeing a lock badge survived this long; it now covers Pro, Team and lapsed Team. --- .env.example | 25 +- engraphis/cloud_features.py | 57 +- engraphis/cloud_session.py | 162 +++- engraphis/hosted_client.py | 55 +- engraphis/routes/memory.py | 22 +- engraphis/routes/v2_api.py | 570 ++++++++++++- engraphis/static/dashboard.css | 1 + engraphis/static/dashboard.js | 36 +- engraphis/update_check.py | 11 +- scripts/update.py | 158 +++- tests/e2e/commercial.spec.js | 173 +++- tests/test_client_launch.py | 373 +++++++++ tests/test_dashboard_auth_placement.py | 214 ++++- tests/test_hosted_plan_resolution.py | 1032 ++++++++++++++++++++++++ tests/test_licensing_launch.py | 473 +++++++++++ 15 files changed, 3255 insertions(+), 107 deletions(-) create mode 100644 tests/test_client_launch.py create mode 100644 tests/test_hosted_plan_resolution.py create mode 100644 tests/test_licensing_launch.py diff --git a/.env.example b/.env.example index 9a3a72b..5d9e504 100644 --- a/.env.example +++ b/.env.example @@ -161,7 +161,30 @@ 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 diff --git a/engraphis/cloud_features.py b/engraphis/cloud_features.py index 2235517..64c7984 100644 --- a/engraphis/cloud_features.py +++ b/engraphis/cloud_features.py @@ -14,12 +14,12 @@ 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.hosted_client import build_pinned_https_opener, upgrade_url SNAPSHOT_SCHEMA = "engraphis-managed-snapshot/v1" MAX_RESPONSE_BYTES = 16 * 1024 * 1024 @@ -67,7 +67,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 +88,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 @@ -297,7 +341,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 +352,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..63fb21b 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,81 @@ 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 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,10 +309,45 @@ 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 _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: str, token_subject: str) -> dict: payload = json.dumps({ @@ -240,16 +371,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: @@ -294,7 +430,7 @@ def access_for_workspace( 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 +459,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 +483,9 @@ 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. + updated.update(_declared_entitlement(body)) _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..e93d50c 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 @@ -1686,17 +1690,577 @@ 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 _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 + 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": str(value.get("organization_id") or ""), + "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 _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: # noqa: BLE001 - offline, lapsed, revoked, invalid: all "not now" + 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"] + + +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. + 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..d9264f0 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')} @@ -181,11 +184,21 @@ function barRow(label,val,peak,color){const tone=color==='var(--green)'?' analyt 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 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)+'
'}}} +function managedConsentRequired(error){return error&&CURRENT_VIEW!=='analytics'&&CURRENT_VIEW!=='automation'&&error.status===409&&error.detail&&error.detail.code==='consent_required'} +/* Entitlement failure — and only that. 401 unauthenticated, 402 subscription not active, + 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. + 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 has not set + ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1 (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. */ +function hostedFeatureUnavailable(error){return !!error&&((CURRENT_VIEW==='analytics'||CURRENT_VIEW==='automation')||error.status===401||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 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(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('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')}} @@ -444,7 +457,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..dd63031 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..4748a69 100644 --- a/scripts/update.py +++ b/scripts/update.py @@ -34,8 +34,41 @@ ) -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. The query steps capture +# their output, so an unbounded call against a stalled package index or an unreachable git +# remote is an indefinite and *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. +_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 + + +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 _run(cmd: list[str], what: str, timeout: int, check: bool = False, + capture: bool = True) -> subprocess.CompletedProcess: + """Run *cmd* under an explicit budget; a stall raises instead of hanging forever.""" + try: + return subprocess.run(cmd, capture_output=capture, text=True, check=check, + timeout=timeout) + except subprocess.TimeoutExpired: + raise UpdateTimeout( + "%s timed out after %ds. Check your network connection, proxy settings, and " + "package index, then run `engraphis-update` again." % (what, timeout) + ) from None def _select_latest_tag(tags) -> str: @@ -51,9 +84,9 @@ def _select_latest_tag(tags) -> str: def _remote_latest_tag(git: str, repo_url: str = REPO_URL) -> str: - result = subprocess.run( + result = _run( [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, ) if result.returncode: return "" @@ -96,9 +129,9 @@ 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) if result.returncode == 0: info = result.stdout if "Editable project location:" in info: @@ -111,6 +144,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 +157,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) except subprocess.CalledProcessError: print("Engraphis is not installed.", file=sys.stderr) sys.exit(1) @@ -146,26 +184,29 @@ 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( + # Announce the network step: it captures its output, so without this line a slow or + # unreachable origin looks like a frozen terminal until the budget below expires. + 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, ) 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).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, ) original_ref = branch_result.stdout.strip() if branch_result.returncode == 0 else local tag = LATEST_TAG if not tag: - tags = subprocess.run( + tags = _run( [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, ) if tags.returncode: print("Could not list release tags from origin; no update was applied.", @@ -180,9 +221,9 @@ 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, ) remote_sha = remote.stdout.strip() if remote.returncode == 0 else "" @@ -200,29 +241,50 @@ 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, ) 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) + _run([git, "-C", str(project_dir), "checkout", f"tags/{tag}"], + "Checking out the release tag", _GIT_CHECKOUT_TIMEOUT_S, + check=True, capture=False) + print(f"Reinstalling from {project_dir}...") try: - subprocess.run( + _run( [sys.executable, "-m", "pip", "install", "-e", str(project_dir)], - check=True, - ) - 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, + "Reinstalling the editable checkout", _PIP_INSTALL_TIMEOUT_S, + check=True, capture=False, ) + except (subprocess.CalledProcessError, UpdateTimeout): + # A failed *or stalled* reinstall must not strand a previously working editable + # checkout at a half-applied detached release. The tree is already on the new tag + # by this point, so a bare hang here is what wedges the install; catching the + # timeout is what lets this rollback run at all. Restore the original branch (or + # exact commit when it started detached) and best-effort reinstall, then + # propagate the original failure. + print("Restoring the previous checkout...", file=sys.stderr) + try: + _run([git, "-C", str(project_dir), "checkout", original_ref], + "Restoring the previous checkout", _GIT_CHECKOUT_TIMEOUT_S, + capture=False) + _run( + [sys.executable, "-m", "pip", "install", "-e", str(project_dir)], + "Reinstalling the previous checkout", _PIP_INSTALL_TIMEOUT_S, + 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. 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), + file=sys.stderr, + ) raise print(f"Updated to {tag}.") @@ -243,22 +305,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 +331,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: @@ -332,6 +400,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..08b5550 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,6 +281,101 @@ test('local dashboard exposes hosted Pro and Team CTAs without local commercial expect(errors).toEqual([]); }); +// ── 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 [401, 402, 501]) { test(`Analytics and Automation defer to cloud proxy status ${cloudStatus}`, async ({ page }) => { const errors = recordBrowserErrors(page); @@ -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'); diff --git a/tests/test_client_launch.py b/tests/test_client_launch.py new file mode 100644 index 0000000..28ef100 --- /dev/null +++ b/tests/test_client_launch.py @@ -0,0 +1,373 @@ +"""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 + +from engraphis import update_check +from engraphis.routes import v2_api + +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 ─────────────────────── +def test_the_updater_runs_no_unbounded_subprocess() -> None: + """Every shell-out is routed through the one helper that always passes a budget.""" + + tree = ast.parse(UPDATER.read_text(encoding="utf-8")) + unbounded = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + target = ast.unparse(node.func) + if target in ("subprocess.run", "subprocess.call", "subprocess.check_output", + "subprocess.check_call", "subprocess.Popen"): + if not any(kw.arg == "timeout" for kw in node.keywords): + 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 + + timeout = inspect.signature(updater._run).parameters["timeout"] + assert timeout.default is inspect.Parameter.empty + + 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", "") == "_run" + ] + 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.""" + + import scripts.update as updater + + seen = {} + + def _stall(cmd, **kwargs): + seen["timeout"] = kwargs.get("timeout") + raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout")) + + monkeypatch.setattr(updater.subprocess, "run", _stall) + + with pytest.raises(updater.UpdateTimeout) as excinfo: + updater._remote_latest_tag("/usr/bin/git", "https://example.test/engraphis.git") + + assert isinstance(seen["timeout"], (int, float)) and seen["timeout"] > 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") + + def _stall(cmd, **kwargs): + raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout")) + + monkeypatch.setattr(updater.subprocess, "run", _stall) + + 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} + + def _fake_run(cmd, **kwargs): + cmd = list(cmd) + calls.append(cmd) + assert kwargs.get("timeout"), "every step must carry a budget: %s" % cmd + + def done(stdout=""): + return subprocess.CompletedProcess(cmd, 0, stdout, "") + + if cmd[:4] == [sys.executable, "-m", "pip", "show"]: + return done("Editable project location: %s\n" % project) + if cmd[:5] == [sys.executable, "-m", "pip", "install", "-e"]: + reinstalls["n"] += 1 + if reinstalls["n"] == 1: # the upgrade reinstall stalls + raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout")) + return done() # the rollback reinstall succeeds + if "rev-parse" in cmd: + return done("a" * 40 + "\n") + if "symbolic-ref" in cmd: + return done("main\n") + if "ls-remote" in cmd: + return done("%s\trefs/tags/v9.9.9\n" % ("b" * 40)) + if "rev-list" in cmd: + return done("b" * 40 + "\n") + if "status" in cmd: + return done("") + return done() + + monkeypatch.setattr(updater.subprocess, "run", _fake_run) + + 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_dashboard_auth_placement.py b/tests/test_dashboard_auth_placement.py index d0c50f7..096e22c 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,214 @@ 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 + 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 only has to set one environment variable. +# +# ``_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_a_purchase_panel( + tmp_path, view, +): + """Analytics and Automation are Pro sales surfaces, not error consoles.""" + + rendered = _route(tmp_path, [{ + "name": "consent", "view": view, + "error": {"status": 409, "detail": {"code": "consent_required"}}, + }])["consent"] + + assert 'class="upgrade-panel"' in rendered["html"] + assert "Purchase Pro license" in rendered["html"] + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1" not 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, 402, 500, 501]) +def test_a_genuine_entitlement_failure_still_renders_the_upgrade_panel( + tmp_path, view, status, +): + """Every failed hosted request renders the Pro upgrade panel in these tabs.""" + + 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"]) +def test_an_internal_hosted_conflict_is_answered_with_a_purchase_panel(tmp_path, view): + """Backend failures never expose internal messages in hosted tabs.""" + + 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"' in rendered["html"] + assert "Purchase Pro license" in rendered["html"] + assert "managed snapshot generation must advance" not in rendered["html"] + + +def test_analytics_and_automation_suppress_hosted_error_details(): + """Only these sales surfaces redirect every failed hosted request to Pro.""" + + helper = _dashboard_function("hostedFeatureUnavailable") + assert "CURRENT_VIEW==='analytics'" in helper + assert "CURRENT_VIEW==='automation'" in helper + for entitlement_status in ("401", "402", "501"): + assert entitlement_status in helper + consent = _dashboard_function("managedConsentRequired") + assert "CURRENT_VIEW!=='analytics'" in consent + assert "CURRENT_VIEW!=='automation'" 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_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py new file mode 100644 index 0000000..77ee5d4 --- /dev/null +++ b/tests/test_hosted_plan_resolution.py @@ -0,0 +1,1032 @@ +"""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 re +import socket +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +from engraphis import cloud_session +from engraphis.routes import v2_api + +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 + ] + + +@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): + 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'"}', +]) +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() + 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_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_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"] == [] + + +# ── (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..4826e45 --- /dev/null +++ b/tests/test_licensing_launch.py @@ -0,0 +1,473 @@ +"""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): + 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 From b35a48de6ef15d49d08c820dbbd504f2ec01b7ff Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 01:41:25 +0000 Subject: [PATCH 02/12] fix(tests): skip FastAPI-dependent launch tests on the numpy-only core floor tests/test_client_launch.py and tests/test_hosted_plan_resolution.py import engraphis.routes.v2_api, which imports FastAPI. The core-py39 CI job installs numpy and pytest only, so both modules raised ModuleNotFoundError at collection and interrupted the whole run rather than skipping. Guard both with pytest.importorskip("fastapi"), matching the convention already used by tests/test_agent_connect.py and tests/test_app_auth.py. Verified against a numpy+pytest-only virtualenv: the suite collects and passes with these two modules skipped, and the full extras install is unchanged at 1363 passed, 7 skipped. --- tests/test_client_launch.py | 8 ++++++-- tests/test_hosted_plan_resolution.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_client_launch.py b/tests/test_client_launch.py index 28ef100..1a6abd6 100644 --- a/tests/test_client_launch.py +++ b/tests/test_client_launch.py @@ -29,8 +29,12 @@ import pytest -from engraphis import update_check -from engraphis.routes import v2_api +# ``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" diff --git a/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py index 77ee5d4..d00330b 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -34,8 +34,12 @@ import pytest -from engraphis import cloud_session -from engraphis.routes import v2_api +# ``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" From b28417327ec3ee9aa9ce188c8db7889a7c490e79 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sat, 25 Jul 2026 22:56:28 -0400 Subject: [PATCH 03/12] test: make licensing launch coverage portable --- tests/test_hosted_plan_resolution.py | 23 ++++++++++++++++++++++- tests/test_licensing_launch.py | 6 ++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py index d00330b..3b87423 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -24,6 +24,7 @@ import ast import io import json +import os import re import socket import threading @@ -446,6 +447,11 @@ 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) @@ -469,6 +475,17 @@ def close(self): 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) @@ -613,7 +630,11 @@ def test_the_cache_is_written_owner_only_and_reread_across_processes( path = v2_api._entitlement_cache_path() assert path.exists() - assert (path.stat().st_mode & 0o777) == 0o600 + # 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" diff --git a/tests/test_licensing_launch.py b/tests/test_licensing_launch.py index 4826e45..a129618 100644 --- a/tests/test_licensing_launch.py +++ b/tests/test_licensing_launch.py @@ -97,6 +97,12 @@ 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( From 09d44826ddb3ca0e768e017f7c07ccfc0e0bfdbb Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 04:13:34 -0400 Subject: [PATCH 04/12] feat(cloud): carry managed-compute consent on the cloud account Customers were told to hand-edit ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1 in a .env file before Analytics or Automation would work. Consent now travels with the cloud account: connecting an installation accepts the terms that cover managed compute, so a connected installation is allowed by default and the variable is never shown to a user. An installation with no cloud session is still never allowed -- there is no account, so there is no agreement to rely on. The variable survives as an operator override only; =0 opts a connected installation back out. Also fixes two predicates that were inverted against their own comment. managedConsentRequired() was guarded on CURRENT_VIEW!=='analytics' && CURRENT_VIEW!=='automation', but its only four call sites run in exactly those two views, so it could never return true. hostedFeatureUnavailable() returned true for any error in those views regardless of status. Together a paying Pro customer hitting a 409, a 500, or a dropped connection was shown a panel selling Pro -- the exact regression the comment above them describes. Both are now status-only. The upload disclosure is deliberately kept: what is uploaded, the 16 MiB cap, and that it travels over HTTPS without end-to-end encryption. Two tests asserted the defect as intended behaviour and are replaced; the weakened predicate assertion is restored and mutation-tested. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 14 ++-- CHANGELOG.md | 10 ++- README.md | 22 ++++--- SECURITY.md | 11 +++- deploy/railway-template.json | 4 +- docs/HOSTING_RAILWAY.md | 8 ++- docs/RAILWAY_TEMPLATE.md | 7 +- engraphis/cloud_features.py | 33 +++++++--- engraphis/static/dashboard.js | 21 +++--- tests/e2e/commercial.spec.js | 22 ++++--- tests/test_cloud_features.py | 82 +++++++++++++++++++++++- tests/test_dashboard_auth_placement.py | 88 +++++++++++++++++++------- tests/test_dashboard_dream_ui.py | 4 +- tests/test_dashboard_v2.py | 5 +- tests/test_packaging.py | 5 +- tests/test_release_infrastructure.py | 5 +- 16 files changed, 262 insertions(+), 79 deletions(-) diff --git a/.env.example b/.env.example index 5d9e504..7be5648 100644 --- a/.env.example +++ b/.env.example @@ -191,11 +191,15 @@ ENGRAPHIS_LLM_API_KEY=sk-your-key-here # 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 64c7984..2c27c63 100644 --- a/engraphis/cloud_features.py +++ b/engraphis/cloud_features.py @@ -19,6 +19,7 @@ from urllib.parse import quote from engraphis.cloud_session import CloudSessionError, access_for_workspace +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" @@ -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]: @@ -235,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) @@ -246,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", ) diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index d9264f0..a45916c 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -183,24 +183,27 @@ 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 managedConsentRequired(error){return error&&CURRENT_VIEW!=='analytics'&&CURRENT_VIEW!=='automation'&&error.status===409&&error.detail&&error.detail.code==='consent_required'} +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'} /* Entitlement failure — and only that. 401 unauthenticated, 402 subscription not active, 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. 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 has not set - ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1 (managedConsentRequired, above), and the rest are + 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. */ -function hostedFeatureUnavailable(error){return !!error&&((CURRENT_VIEW==='analytics'||CURRENT_VIEW==='automation')||error.status===401||error.status===402||error.status===501)} + 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===401||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(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('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; diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index 08b5550..5fe4327 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -423,21 +423,26 @@ for (const cloudStatus of [401, 402, 501]) { }); } -test('Analytics explains the local managed-compute consent step', async ({ page }) => { +// 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('/'); @@ -446,8 +451,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_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_dashboard_auth_placement.py b/tests/test_dashboard_auth_placement.py index 096e22c..01649f6 100644 --- a/tests/test_dashboard_auth_placement.py +++ b/tests/test_dashboard_auth_placement.py @@ -76,6 +76,9 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses(): for body in (analytics, automation): 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===401||error.status===402||error.status===501" in script assert "error.status===409" in script assert "Purchase Pro license" in script @@ -83,7 +86,8 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses(): # ── 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 only has to set one environment variable. +# 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 @@ -173,29 +177,36 @@ def _route(tmp_path, cases): @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_a_purchase_panel( +def test_a_consent_required_conflict_is_answered_with_the_consent_panel( tmp_path, view, ): - """Analytics and Automation are Pro sales surfaces, not error consoles.""" + """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 'class="upgrade-panel"' in rendered["html"] - assert "Purchase Pro license" in rendered["html"] - assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1" not in rendered["html"] - assert rendered["pill"] == "PRO" + 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", [400, 401, 402, 500, 501]) +@pytest.mark.parametrize("status", [401, 402, 501]) def test_a_genuine_entitlement_failure_still_renders_the_upgrade_panel( tmp_path, view, status, ): - """Every failed hosted request renders the Pro upgrade panel in these tabs.""" + """401 unauthenticated, 402 not subscribed, 501 not offered: all billing answers.""" rendered = _route(tmp_path, [{ "name": "unentitled", "view": view, "error": {"status": status}, @@ -209,8 +220,29 @@ def test_a_genuine_entitlement_failure_still_renders_the_upgrade_panel( @pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") @pytest.mark.parametrize("view", ["analytics", "automation"]) -def test_an_internal_hosted_conflict_is_answered_with_a_purchase_panel(tmp_path, view): - """Backend failures never expose internal messages in hosted tabs.""" +@pytest.mark.parametrize("status", [400, 500, 503]) +def test_a_non_billing_failure_shows_the_error_instead_of_selling_pro( + tmp_path, view, status, +): + """A bad request or a cloud outage is not an unpaid invoice.""" + + 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, @@ -218,22 +250,34 @@ def test_an_internal_hosted_conflict_is_answered_with_a_purchase_panel(tmp_path, "error": {"status": 409, "detail": {"error": "generation conflict"}}, }])["conflict"] - assert 'class="upgrade-panel"' in rendered["html"] - assert "Purchase Pro license" in rendered["html"] - assert "managed snapshot generation must advance" not in rendered["html"] + 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_analytics_and_automation_suppress_hosted_error_details(): - """Only these sales surfaces redirect every failed hosted request to Pro.""" +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 "CURRENT_VIEW==='analytics'" in helper - assert "CURRENT_VIEW==='automation'" in helper - for entitlement_status in ("401", "402", "501"): - assert entitlement_status in helper + assert "error.status===401||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))) == ["401", "402", "501"] + consent = _dashboard_function("managedConsentRequired") - assert "CURRENT_VIEW!=='analytics'" in consent - assert "CURRENT_VIEW!=='automation'" in consent + 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", 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..932eb5e 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -263,8 +263,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): 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", From 1422ecd3e9223f40653817d7a033ed35a7dc0b0a Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 04:13:35 -0400 Subject: [PATCH 05/12] fix(cloud): drop stale grants when the plan changes access_for_workspace merged the refresh answer onto the saved record with dict.update. _declared_entitlement deliberately omits cloud_features when the body carries no feature list, so a downgrade replaced plan while the previous plan's grants survived. A Team -> Pro downgrade therefore reported plan "pro" with "team" still in features, leaving the Team tab unlocked indefinitely. Fixed at the merge site so _declared_entitlement keeps its tested contract. Co-Authored-By: Claude Opus 5 (1M context) --- engraphis/cloud_session.py | 14 ++++- tests/test_cloud_session.py | 80 ++++++++++++++++++++++++++++ tests/test_hosted_plan_resolution.py | 36 +++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index 63fb21b..83fa8da 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -486,6 +486,18 @@ def access_for_workspace( # 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. - updated.update(_declared_entitlement(body)) + 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/tests/test_cloud_session.py b/tests/test_cloud_session.py index 533604b..1bc9853 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") diff --git a/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py index 3b87423..7c43a24 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -833,6 +833,42 @@ def test_a_field_less_refresh_never_erases_a_plan_the_cloud_already_declared( 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_lapsed_subscription_declared_on_the_refresh_keeps_its_name( monkeypatch, ) -> None: From e3a23f25482fd5b98fa90b766aaf103f2276a757 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 04:13:35 -0400 Subject: [PATCH 06/12] fix(update): make the subprocess budget hold on Windows subprocess.run(capture_output=True, timeout=N) does not honour N on Windows: after TimeoutExpired, CPython calls communicate() with no timeout, and the reader threads block until every inherited pipe write handle closes -- including grandchildren. git fetch and git ls-remote always spawn git-remote-https, so the three network calls stayed unbounded. Measured with a grandchild holding the pipe: a 3s budget returned after 30.2s before, and raises at 3.1s now. Network calls that need stdout go through a bounded Popen reader that kills the process tree and re-drains with a cap; the rest stop capturing. Every git call now gets stdin=DEVNULL and GIT_TERMINAL_PROMPT=0 so an expired token cannot park the updater on a credential prompt. Also moves _detect_install() inside main()'s try, so a stalled pip show prints its crafted message instead of a traceback. Three existing tests encoded the belief that timeout= alone was sufficient and are rewritten. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/update.py | 158 ++++++++++++++++++++++++++++++------ tests/test_client_launch.py | 113 ++++++++++++++++++++++---- tests/test_update.py | 152 ++++++++++++++++++++++++++++++++++ 3 files changed, 382 insertions(+), 41 deletions(-) diff --git a/scripts/update.py b/scripts/update.py index 4748a69..3e157da 100644 --- a/scripts/update.py +++ b/scripts/update.py @@ -21,6 +21,7 @@ import os import re import shutil +import signal import subprocess import sys from pathlib import Path @@ -34,11 +35,12 @@ ) -# Every step below runs with an explicit, differentiated budget. The query steps capture -# their output, so an unbounded call against a stalled package index or an unreachable git -# remote is an indefinite and *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. +# 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 @@ -47,6 +49,14 @@ _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. +_OWN_PROCESS_GROUP = {} if os.name == "nt" else {"start_new_session": True} class UpdateTimeout(RuntimeError): @@ -58,17 +68,106 @@ class UpdateTimeout(RuntimeError): """ +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 _run(cmd: list[str], what: str, timeout: int, check: bool = False, - capture: bool = True) -> subprocess.CompletedProcess: - """Run *cmd* under an explicit budget; a stall raises instead of hanging forever.""" + capture: bool = False, env: dict | None = None) -> subprocess.CompletedProcess: + """Run *cmd* under an explicit budget; a stall raises instead of hanging forever. + + ``capture`` is opt-in and deliberately defaults to *off*. ``subprocess.run`` honours + ``timeout`` only when it has no pipes left to drain: once the budget expires CPython + kills the direct child and then calls ``communicate()`` with **no** timeout, so the + reader threads keep waiting until every inherited write handle on those pipes closes — + grandchildren included. Use ``capture=True`` only for local commands that fork nothing + (``pip show``, git plumbing against an existing clone). Anything that reaches the + network must go through :func:`_run_captured`, which kills the whole tree first. + + ``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. + """ try: return subprocess.run(cmd, capture_output=capture, text=True, check=check, - timeout=timeout) + timeout=timeout, stdin=subprocess.DEVNULL, env=env) except subprocess.TimeoutExpired: - raise UpdateTimeout( - "%s timed out after %ds. Check your network connection, proxy settings, and " - "package index, then run `engraphis-update` again." % (what, timeout) - ) from None + raise _timed_out(what, timeout) from None + + +def _run_captured(cmd: list[str], what: str, timeout: int, + env: dict | None = 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. Only stdout is piped — stderr stays on the terminal, which + both surfaces git's own explanation of a failure and leaves one fewer inherited write + handle for a grandchild to hold open. On expiry the entire process tree is destroyed + *before* the pipe is drained, and even that drain is bounded, so the call returns on + schedule instead of waiting on ``git-remote-https``. + """ + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, 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 _select_latest_tag(tags) -> str: @@ -84,9 +183,10 @@ def _select_latest_tag(tags) -> str: def _remote_latest_tag(git: str, repo_url: str = REPO_URL) -> str: - result = _run( + result = _run_captured( [git, "ls-remote", "--tags", "--refs", repo_url, "v*"], "Listing release tags from the Git remote", _GIT_LS_REMOTE_TIMEOUT_S, + env=_git_env(), ) if result.returncode: return "" @@ -131,7 +231,8 @@ def _detect_install() -> str: try: result = _run( [sys.executable, "-m", "pip", "show", "engraphis"], - "Reading the installed Engraphis metadata", _PIP_METADATA_TIMEOUT_S) + "Reading the installed Engraphis metadata", _PIP_METADATA_TIMEOUT_S, + capture=True) if result.returncode == 0: info = result.stdout if "Editable project location:" in info: @@ -160,7 +261,7 @@ def _git_update(check_only: bool = False) -> None: result = _run( [sys.executable, "-m", "pip", "show", "engraphis"], "Reading the installed Engraphis metadata", _PIP_METADATA_TIMEOUT_S, - check=True) + check=True, capture=True) except subprocess.CalledProcessError: print("Engraphis is not installed.", file=sys.stderr) sys.exit(1) @@ -184,29 +285,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. - # Announce the network step: it captures its output, so without this line a slow or - # unreachable origin looks like a frozen terminal until the budget below expires. + # 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"], "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 = _run([git, "-C", str(project_dir), "rev-parse", "HEAD"], - "Reading the current revision", _GIT_LOCAL_TIMEOUT_S).stdout.strip() + "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"], "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 = _run( + tags = _run_captured( [git, "-C", str(project_dir), "ls-remote", "--tags", "--refs", "origin", "v*"], "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.", @@ -224,6 +329,7 @@ def _git_update(check_only: bool = False) -> None: remote = _run( [git, "-C", str(project_dir), "rev-list", "-n", "1", tag], "Resolving the release tag", _GIT_LOCAL_TIMEOUT_S, + capture=True, env=_git_env(), ) remote_sha = remote.stdout.strip() if remote.returncode == 0 else "" @@ -244,6 +350,7 @@ def _git_update(check_only: bool = False) -> None: dirty = _run( [git, "-C", str(project_dir), "status", "--porcelain"], "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) @@ -251,7 +358,7 @@ def _git_update(check_only: bool = False) -> None: print(f"Checking out release {tag}...") _run([git, "-C", str(project_dir), "checkout", f"tags/{tag}"], "Checking out the release tag", _GIT_CHECKOUT_TIMEOUT_S, - check=True, capture=False) + check=True, capture=False, env=_git_env()) print(f"Reinstalling from {project_dir}...") try: _run( @@ -270,7 +377,7 @@ def _git_update(check_only: bool = False) -> None: try: _run([git, "-C", str(project_dir), "checkout", original_ref], "Restoring the previous checkout", _GIT_CHECKOUT_TIMEOUT_S, - capture=False) + capture=False, env=_git_env()) _run( [sys.executable, "-m", "pip", "install", "-e", str(project_dir)], "Reinstalling the previous checkout", _PIP_INSTALL_TIMEOUT_S, @@ -378,10 +485,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": diff --git a/tests/test_client_launch.py b/tests/test_client_launch.py index 1a6abd6..403a699 100644 --- a/tests/test_client_launch.py +++ b/tests/test_client_launch.py @@ -54,19 +54,53 @@ # ── (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 routed through the one helper that always passes a budget.""" + """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 in ("subprocess.run", "subprocess.call", "subprocess.check_output", - "subprocess.check_call", "subprocess.Popen"): - if not any(kw.arg == "timeout" for kw in node.keywords): - unbounded.append(target) + 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 @@ -76,13 +110,15 @@ def test_the_updater_helper_makes_a_timeout_impossible_to_omit() -> None: import scripts.update as updater - timeout = inspect.signature(updater._run).parameters["timeout"] - assert timeout.default is inspect.Parameter.empty + 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", "") == "_run" + 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: @@ -91,22 +127,51 @@ def test_the_updater_helper_makes_a_timeout_impossible_to_omit() -> None: def test_a_stalled_remote_aborts_instead_of_waiting_forever(monkeypatch) -> None: - """The failing call is abandoned at its budget and reported, not awaited.""" + """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 = {} + seen = {"drains": []} - def _stall(cmd, **kwargs): - seen["timeout"] = kwargs.get("timeout") - raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout")) + class _Stalled: + pid = 4242 + returncode = None - monkeypatch.setattr(updater.subprocess, "run", _stall) + 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["timeout"], (int, float)) and seen["timeout"] > 0 + 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 @@ -170,15 +235,29 @@ def done(stdout=""): return done("a" * 40 + "\n") if "symbolic-ref" in cmd: return done("main\n") - if "ls-remote" in cmd: - return done("%s\trefs/tags/v9.9.9\n" % ("b" * 40)) if "rev-list" in cmd: return done("b" * 40 + "\n") if "status" in cmd: return done("") + assert "ls-remote" not in cmd, "the refs query must use the bounded reader" return done() + class _Refs: + pid = 909 + returncode = 0 + + def communicate(self, timeout=None): + assert timeout, "the bounded drain must carry a budget" + return "%s\trefs/tags/v9.9.9\n" % ("b" * 40), None + + def _fake_popen(cmd, **kwargs): + cmd = list(cmd) + calls.append(cmd) + assert "ls-remote" in cmd, "only the parsed network query needs a pipe: %s" % cmd + return _Refs() + monkeypatch.setattr(updater.subprocess, "run", _fake_run) + monkeypatch.setattr(updater.subprocess, "Popen", _fake_popen) with pytest.raises(updater.UpdateTimeout): updater._git_update() diff --git a/tests/test_update.py b/tests/test_update.py index e406da1..a82928d 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,5 +1,8 @@ """The updater must never embed a stale release or select prerelease-like refs.""" import json +import subprocess +import sys +import time from pathlib import Path from types import SimpleNamespace @@ -157,3 +160,152 @@ 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 fake_run(command, **kwargs): + calls.append((list(command), kwargs)) + if command[:4] == [sys.executable, "-m", "pip", "show"]: + return SimpleNamespace( + returncode=0, stdout=f"Editable project location: {project}\n") + if "rev-parse" in command: + return SimpleNamespace(returncode=0, stdout="old-sha\n") + if "symbolic-ref" in command: + return SimpleNamespace(returncode=0, stdout="main\n") + if "rev-list" in command: + return SimpleNamespace(returncode=0, stdout="new-sha\n") + return SimpleNamespace(returncode=0, stdout="") + + monkeypatch.setattr(update.subprocess, "run", fake_run) + + update._git_update(check_only=True) + + git_calls = [(cmd, kwargs) for cmd, kwargs in calls if cmd[0] == "git"] + assert git_calls, "expected the editable path to shell out to git" + for command, kwargs 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 + # The fetch's own budget is only honoured while there is no pipe left to drain. + fetches = [kwargs for cmd, kwargs in git_calls if "fetch" in cmd] + assert fetches and fetches[0]["capture_output"] is False + assert fetches[0]["timeout"] == 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, "run", + lambda *a, **k: pytest.fail("a network query must not use the unenforceable path"), + ) + 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 From 8abc7eb484ba26fa64cc625ceb0830b5824110e1 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 04:49:40 -0400 Subject: [PATCH 07/12] fix(cloud): scope the entitlement cache to its organization Addresses three review findings on PR #73. The cached entitlement was served without comparing its stored organization_id against the organization the installation is bound to now. Reconnecting or re-pinning to a different organization while reusing the same state directory therefore displayed the PREVIOUS organization's plan and features -- for up to the refresh interval, or indefinitely when refresh is disabled. The session path already rejected this mismatch; the cache now does too, and fails closed so a rejected cache immediately schedules the correcting fetch. hosted_plan_summary() resolved _plan_entitlement() twice, once directly and once via _hosted_plan(), doubling the cloud-session and cache reads -- and the chance of scheduling a background refresh -- on every /api/license and /api/bootstrap. The _hosted_plan override seam that tests rely on is preserved: when a caller rebinds it, its answer still wins. access_for_workspace() was annotated workspace_id: str while the org-scoped entitlement read passes None, so the refresh body carried "workspace_id": null. A control plane that requires a string would 4xx, and the whole compatibility fallback silently returns None, meaning the cache from GET /v1/entitlements/{org} would never be written for older control planes. The key is now omitted rather than sent as null. Also passes a _NoRedirect instance rather than the class in update_check, matching every other caller. Verified behaviour was already identical -- build_opener instantiates a passed class -- so this is consistency only. Co-Authored-By: Claude Opus 5 (1M context) --- engraphis/cloud_session.py | 22 ++-- engraphis/routes/v2_api.py | 62 ++++++++-- engraphis/update_check.py | 2 +- tests/test_hosted_plan_resolution.py | 175 +++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 16 deletions(-) diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index 83fa8da..6876176 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -348,13 +348,15 @@ def _refresh_http_error(status: int) -> CloudSessionError: return CloudSessionError("Engraphis Cloud could not refresh this session.") -def _post_refresh(control_url: str, refresh: str, workspace_id: str, +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, @@ -422,9 +424,13 @@ 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() diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index e93d50c..590da38 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -1830,6 +1830,30 @@ def _cloud_control_url() -> str: 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.""" @@ -1931,12 +1955,24 @@ def _read_entitlement_cache() -> dict: 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. + organization_id = str(value.get("organization_id") or "") + current = _configured_organization_id() + if current and 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": str(value.get("organization_id") or ""), + "organization_id": organization_id, "fetched_at": fetched_at, } @@ -2200,6 +2236,14 @@ def _hosted_plan() -> str: 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. @@ -2209,12 +2253,16 @@ def hosted_plan_summary() -> dict: 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. - 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} + # 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"]), diff --git a/engraphis/update_check.py b/engraphis/update_check.py index dd63031..d5d711e 100644 --- a/engraphis/update_check.py +++ b/engraphis/update_check.py @@ -168,7 +168,7 @@ def _fetch(url: str, timeout: float) -> Optional[dict]: # 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) + 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/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py index 7c43a24..46b79c0 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -29,6 +29,7 @@ import socket import threading import time +import typing import urllib.error import urllib.request from pathlib import Path @@ -420,6 +421,38 @@ def test_a_vetted_https_control_url_is_still_reached(monkeypatch) -> None: ] +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"{}")), @@ -961,6 +994,68 @@ def test_a_session_plan_for_another_organization_is_refused(monkeypatch) -> None 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_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: @@ -1083,6 +1178,86 @@ def test_the_legacy_surface_still_reports_local_for_an_unconnected_installation( 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", From d1f9670753584003718a1bcbd20ea413561bc232 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 04:49:41 -0400 Subject: [PATCH 08/12] fix(update): bound every step and roll back a failed checkout Addresses two more review findings on PR #73. _run() still reached subprocess.run(timeout=...) for non-captured steps, which kills only the immediate child. pip's resolver or git-remote-https could outlive the budget and keep touching the repository while the caller had already started rollback. Every step now goes through one spawn path with process-tree termination and a bounded post-kill drain, so capture selects whether stdout is piped rather than whether the budget is enforceable at all. git checkout tags/ -- the destructive step -- ran above the rollback try, so a timed-out or non-zero checkout raised straight past it and left an editable install partially switched while reporting only a timeout. It now sits inside the same boundary. A killed checkout can also strand .git/index.lock, which blocks every later git command: the lock is removed only when it was absent before our checkout started and the checkout we killed is the plausible author, and otherwise it is named for the user rather than deleted, so a lock held by another live git process is never touched. The rollback checkout now runs with check=True. A failed restore used to be silent while main() still reported the previous installation restored. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/update.py | 194 ++++++++++++++++------ tests/test_client_launch.py | 74 +++++---- tests/test_update.py | 311 ++++++++++++++++++++++++++++++------ 3 files changed, 444 insertions(+), 135 deletions(-) diff --git a/scripts/update.py b/scripts/update.py index 3e157da..0eff71e 100644 --- a/scripts/update.py +++ b/scripts/update.py @@ -55,7 +55,9 @@ # ``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. +# 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} @@ -120,43 +122,35 @@ def _kill_process_tree(process: subprocess.Popen) -> None: pass -def _run(cmd: list[str], what: str, timeout: int, check: bool = False, - capture: bool = False, env: dict | None = None) -> subprocess.CompletedProcess: - """Run *cmd* under an explicit budget; a stall raises instead of hanging forever. +def _bounded_call(cmd: list[str], what: str, timeout: int, capture: bool, + env: dict | None) -> subprocess.CompletedProcess: + """Run *cmd* under a budget that **nothing in its process tree** can outlive. - ``capture`` is opt-in and deliberately defaults to *off*. ``subprocess.run`` honours - ``timeout`` only when it has no pipes left to drain: once the budget expires CPython - kills the direct child and then calls ``communicate()`` with **no** timeout, so the - reader threads keep waiting until every inherited write handle on those pipes closes — - grandchildren included. Use ``capture=True`` only for local commands that fork nothing - (``pip show``, git plumbing against an existing clone). Anything that reaches the - network must go through :func:`_run_captured`, which kills the whole tree first. - - ``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. - """ - try: - return subprocess.run(cmd, capture_output=capture, text=True, check=check, - timeout=timeout, stdin=subprocess.DEVNULL, env=env) - except subprocess.TimeoutExpired: - raise _timed_out(what, timeout) from None + 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. -def _run_captured(cmd: list[str], what: str, timeout: int, - env: dict | None = None) -> subprocess.CompletedProcess: - """Run *cmd* for its stdout under a budget that is actually enforced. + 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. - For the steps that must be *parsed* rather than merely displayed, so simply not - capturing is not an option. Only stdout is piped — stderr stays on the terminal, which - both surfaces git's own explanation of a failure and leaves one fewer inherited write - handle for a grandchild to hold open. On expiry the entire process tree is destroyed - *before* the pipe is drained, and even that drain is bounded, so the call returns on - schedule instead of waiting on ``git-remote-https``. + 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, stdin=subprocess.DEVNULL, text=True, env=env, - **_OWN_PROCESS_GROUP, + cmd, stdout=subprocess.PIPE if capture else None, stdin=subprocess.DEVNULL, + text=True, env=env, **_OWN_PROCESS_GROUP, ) try: stdout, _ = process.communicate(timeout=timeout) @@ -170,6 +164,90 @@ def _run_captured(cmd: list[str], what: str, timeout: int, 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: dict | None = 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: dict | None = 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: """Return the highest stable ``vMAJOR.MINOR.PATCH`` tag, ignoring other refs.""" parsed = [] @@ -356,42 +434,56 @@ def _git_update(check_only: bool = False) -> None: print("Refusing to update a working tree with uncommitted changes.", file=sys.stderr) sys.exit(1) print(f"Checking out release {tag}...") - _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()) - print(f"Reinstalling from {project_dir}...") + # 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: + _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)], "Reinstalling the editable checkout", _PIP_INSTALL_TIMEOUT_S, check=True, capture=False, ) - except (subprocess.CalledProcessError, UpdateTimeout): - # A failed *or stalled* reinstall must not strand a previously working editable - # checkout at a half-applied detached release. The tree is already on the new tag - # by this point, so a bare hang here is what wedges the install; catching the - # timeout is what lets this rollback run at all. Restore the original branch (or - # exact commit when it started detached) and best-effort reinstall, then - # propagate the original failure. + 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, - capture=False, env=_git_env()) + 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, - capture=False, + 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. 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), - file=sys.stderr, - ) + 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}.") diff --git a/tests/test_client_launch.py b/tests/test_client_launch.py index 403a699..b8ae237 100644 --- a/tests/test_client_launch.py +++ b/tests/test_client_launch.py @@ -185,10 +185,15 @@ def test_main_reports_a_stalled_step_and_exits(monkeypatch, capsys) -> None: monkeypatch.setattr(updater, "_detect_install", lambda: "pypi") - def _stall(cmd, **kwargs): - raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout")) + class _Stalled: + pid = 5150 + returncode = None + + def communicate(self, timeout=None): + raise subprocess.TimeoutExpired("pip", timeout) - monkeypatch.setattr(updater.subprocess, "run", _stall) + 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([]) @@ -216,48 +221,49 @@ def test_a_stalled_reinstall_still_rolls_back_the_checkout(monkeypatch, tmp_path calls = [] reinstalls = {"n": 0} - def _fake_run(cmd, **kwargs): - cmd = list(cmd) - calls.append(cmd) - assert kwargs.get("timeout"), "every step must carry a budget: %s" % cmd + class _Proc: + pid = 909 + + def __init__(self, returncode=0, stdout="", stall=False): + self.returncode = returncode + self._stdout = stdout + self._stall = stall - def done(stdout=""): - return subprocess.CompletedProcess(cmd, 0, stdout, "") + 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 done("Editable project location: %s\n" % project) + return _Proc(stdout="Editable project location: %s\n" % project) if cmd[:5] == [sys.executable, "-m", "pip", "install", "-e"]: reinstalls["n"] += 1 - if reinstalls["n"] == 1: # the upgrade reinstall stalls - raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout")) - return done() # the rollback reinstall succeeds + # 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 done("a" * 40 + "\n") + return _Proc(stdout="a" * 40 + "\n") if "symbolic-ref" in cmd: - return done("main\n") + return _Proc(stdout="main\n") if "rev-list" in cmd: - return done("b" * 40 + "\n") + return _Proc(stdout="b" * 40 + "\n") if "status" in cmd: - return done("") - assert "ls-remote" not in cmd, "the refs query must use the bounded reader" - return done() + return _Proc(stdout="") + return _Proc() - class _Refs: - pid = 909 - returncode = 0 - - def communicate(self, timeout=None): - assert timeout, "the bounded drain must carry a budget" - return "%s\trefs/tags/v9.9.9\n" % ("b" * 40), None - - def _fake_popen(cmd, **kwargs): - cmd = list(cmd) - calls.append(cmd) - assert "ls-remote" in cmd, "only the parsed network query needs a pipe: %s" % cmd - return _Refs() - - monkeypatch.setattr(updater.subprocess, "run", _fake_run) 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() diff --git a/tests/test_update.py b/tests/test_update.py index a82928d..d5eb5c6 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,5 +1,7 @@ """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 @@ -11,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", @@ -35,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", ]] @@ -48,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", @@ -68,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", @@ -107,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 @@ -204,41 +229,47 @@ def test_every_git_step_closes_stdin_and_refuses_a_credential_prompt(monkeypatch monkeypatch.setattr(update, "LATEST_TAG", "v1.2.3") calls = [] - def fake_run(command, **kwargs): - calls.append((list(command), kwargs)) + def handler(command): if command[:4] == [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 SimpleNamespace(returncode=0, stdout="") + return _FakeProcess(stdout="new-sha\n") + return _FakeProcess() - monkeypatch.setattr(update.subprocess, "run", fake_run) + monkeypatch.setattr(update.subprocess, "Popen", _spawner(handler, calls)) update._git_update(check_only=True) - git_calls = [(cmd, kwargs) for cmd, kwargs in calls if cmd[0] == "git"] + 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 in git_calls: + 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 - # The fetch's own budget is only honoured while there is no pipe left to drain. - fetches = [kwargs for cmd, kwargs in git_calls if "fetch" in cmd] - assert fetches and fetches[0]["capture_output"] is False - assert fetches[0]["timeout"] == update._GIT_FETCH_TIMEOUT_S + # 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("a network query must not use the unenforceable path"), + lambda *a, **k: pytest.fail("nothing in the updater may spawn through subprocess.run"), ) seen = {} @@ -309,3 +340,183 @@ def test_the_budget_holds_even_when_a_grandchild_still_owns_the_pipe(): 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" From eab8a4c5c0f41f394b74ca238c620b8084bda97f Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 05:09:40 -0400 Subject: [PATCH 09/12] fix(cloud): let a billing denial clear the cached paid access Addresses a review finding on PR #73. A lapsed subscription answers 402 on token refresh. That is the control plane's authoritative billing answer, but it was caught by the same broad handler as offline/revoked/invalid and discarded. Because the saved session outranks the entitlement cache, cloud_access_active stayed true and the dashboard kept advertising paid features indefinitely while every hosted operation was denied -- the same two-surfaces-disagree failure this branch set out to fix. record_billing_denial() persists the denial: the access flag and the grants are cleared, the plan name is kept so the UI can still say which plan lapsed. Only 402 does this; an outage must never look like a cancellation. Regression tests both ways -- verified that the denial test fails without the fix (cloud_access_active stayed true with the full feature list) and that a 503 leaves a paying customer's access intact. Co-Authored-By: Claude Opus 5 (1M context) --- engraphis/cloud_session.py | 31 +++++++++++++++++ engraphis/routes/v2_api.py | 14 +++++++- tests/test_hosted_plan_resolution.py | 51 ++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index 6876176..6c93c47 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -287,6 +287,37 @@ def saved_entitlement() -> dict: 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. Returns whether anything changed. Never raises: + this runs on the boot path. + """ + + try: + saved = _load() + if not saved: + return False + already_denied = ( + saved.get("cloud_access_active") is False and not saved.get("cloud_features") + ) + if already_denied: + return False + saved["cloud_access_active"] = False + saved["cloud_features"] = [] + _save(saved) + return True + 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.""" diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index 590da38..4fffdb0 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -2042,7 +2042,19 @@ def _fetch_authoritative_entitlement() -> Optional[dict]: access_token, organization_id, _ = access_for_workspace( None, require_compute=False ) - except Exception: # noqa: BLE001 - offline, lapsed, revoked, invalid: all "not now" + 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 return None if not access_token or not organization_id: return None diff --git a/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py index 46b79c0..f44987b 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -902,6 +902,57 @@ def test_a_downgrade_drops_the_previous_plans_features_from_the_resolved_answer( 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_lapsed_subscription_declared_on_the_refresh_keeps_its_name( monkeypatch, ) -> None: From 0cfc1e52e58c99b1a37251b872b5604c4b98b73e Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 05:28:49 -0400 Subject: [PATCH 10/12] fix(cloud): settle the compatibility cache on a billing denial too Follow-up to eab8a4c, which settled only the session record. Against an older control plane the refresh carries no entitlement fields, so that session stays planless: saved_entitlement() keeps answering {} and _plan_entitlement falls through to the compatibility cache, which went on advertising the paid plan while every cloud operation was denied. The denial has to settle both layers or it settles neither. _deny_entitlement_cache() clears the cached grants and access flag on 402 and keeps the plan name so the UI can still say which plan lapsed. Verified the new test fails without it -- the cache still reported cloud_access_active true. Co-Authored-By: Claude Opus 5 (1M context) --- engraphis/routes/v2_api.py | 33 ++++++++++++++++++++++++++++ tests/test_hosted_plan_resolution.py | 28 +++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index 4fffdb0..5726379 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -2003,6 +2003,34 @@ def _write_entitlement_cache(entitlement: dict) -> None: 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. + """ + + try: + cached = _read_entitlement_cache() + if not cached: + return + if not cached.get("cloud_access_active") and not cached.get("features"): + 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. @@ -2055,6 +2083,11 @@ def _fetch_authoritative_entitlement() -> Optional[dict]: 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 diff --git a/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py index f44987b..70935b4 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -953,6 +953,34 @@ def _offline(*_args, **_kwargs): 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 test_a_lapsed_subscription_declared_on_the_refresh_keeps_its_name( monkeypatch, ) -> None: From 8da56a277de1086476baa948da155e7c3738955d Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 06:02:47 -0400 Subject: [PATCH 11/12] fix(cloud): treat 401 as reconnect, and surface real cloud errors Addresses four more review findings on PR #73. 401 was drawing the purchase panel. The cloud maps it to "the cloud session expired or was revoked; connect again" -- a credential problem, not a billing one -- so an already-paying customer whose refresh credential lapsed was sold the plan they already own. Only 402 and 501 are billing answers now; 401 falls through to the error branch where the reconnect instruction is what the customer reads. _managed_call flattened every CloudFeatureError into "managed cloud operation failed", so 429, 5xx and a non-consent 409 were indistinguishable and the actionable copy from _public_http_error / _public_session_error never reached anyone. Those messages are fixed local literals -- audited all 19 construction sites in cloud_features, the only interpolation being upgrade_url() and one template whose substituted value is one of two fixed strings -- so they pass through, bounded and control-character-checked, with the old placeholder kept as the fallback. An AST test now pins that invariant so a future f-string or reflected response field fails. Billing denials went unstamped: entitlement_checked_at never advanced and a repeat denial wrote nothing, so the 15-minute interval never suppressed the next attempt and every request rotated the credential again against a control plane that had already answered 402. Every denial is now stamped, including the repeat, in both the session record and the compatibility cache. Also replaces three PEP 604 dict | None annotations in scripts/update.py with Optional[dict]. Contrary to the finding these did not break import -- from __future__ import annotations keeps them as strings, verified on real 3.9.25 -- but typing.get_type_hints() did raise there, and the house style is 3.9-compatible syntax. Co-Authored-By: Claude Opus 5 (1M context) --- engraphis/cloud_session.py | 19 ++- engraphis/routes/v2_api.py | 47 +++++++- engraphis/static/dashboard.js | 12 +- scripts/update.py | 8 +- tests/e2e/commercial.spec.js | 23 +++- tests/test_cloud_session.py | 41 +++++++ tests/test_dashboard_auth_placement.py | 23 ++-- tests/test_dashboard_v2.py | 161 +++++++++++++++++++++++-- tests/test_hosted_plan_resolution.py | 143 ++++++++++++++++++++++ 9 files changed, 442 insertions(+), 35 deletions(-) diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index 6c93c47..3da5107 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -297,8 +297,18 @@ def record_billing_denial() -> bool: 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. Returns whether anything changed. Never raises: - this runs on the boot path. + 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: @@ -308,12 +318,11 @@ def record_billing_denial() -> bool: already_denied = ( saved.get("cloud_access_active") is False and not saved.get("cloud_features") ) - if already_denied: - return False saved["cloud_access_active"] = False saved["cloud_features"] = [] + saved["entitlement_checked_at"] = time.time() _save(saved) - return True + return not already_denied except Exception: return False diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index 5726379..fec6971 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -142,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 @@ -151,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 @@ -2014,14 +2049,20 @@ def _deny_entitlement_cache() -> None: 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 - if not cached.get("cloud_access_active") and not cached.get("features"): - return denied = dict(cached) denied["cloud_access_active"] = False denied["features"] = [] diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index a45916c..902f0e0 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -185,9 +185,13 @@ function statMini(v,l,color){const tone=color==='var(--red)'?' tone-red':(color= 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)} 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'} -/* Entitlement failure — and only that. 401 unauthenticated, 402 subscription not active, - 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. +/* 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 @@ -197,7 +201,7 @@ function managedConsentRequired(error){return error&&error.status===409&&error.d 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===401||error.status===402||error.status===501)} +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) ── */ diff --git a/scripts/update.py b/scripts/update.py index 0eff71e..74fa5db 100644 --- a/scripts/update.py +++ b/scripts/update.py @@ -15,6 +15,8 @@ """ from __future__ import annotations +from typing import Optional + import importlib.metadata import json @@ -123,7 +125,7 @@ def _kill_process_tree(process: subprocess.Popen) -> None: def _bounded_call(cmd: list[str], what: str, timeout: int, capture: bool, - env: dict | None) -> subprocess.CompletedProcess: + 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 @@ -165,7 +167,7 @@ def _bounded_call(cmd: list[str], what: str, timeout: int, capture: bool, def _run(cmd: list[str], what: str, timeout: int, check: bool = False, - capture: bool = False, env: dict | None = None) -> subprocess.CompletedProcess: + 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 @@ -181,7 +183,7 @@ def _run(cmd: list[str], what: str, timeout: int, check: bool = False, def _run_captured(cmd: list[str], what: str, timeout: int, - env: dict | None = None) -> subprocess.CompletedProcess: + 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 diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index 5fe4327..c6607e4 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -376,7 +376,7 @@ test('a lapsed Team subscription is sent to billing, not to a spent trial', asyn expect(errors).toEqual([]); }); -for (const cloudStatus of [401, 402, 501]) { +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); @@ -423,6 +423,27 @@ for (const cloudStatus of [401, 402, 501]) { }); } +// 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 diff --git a/tests/test_cloud_session.py b/tests/test_cloud_session.py index 1bc9853..dfbf612 100644 --- a/tests/test_cloud_session.py +++ b/tests/test_cloud_session.py @@ -392,3 +392,44 @@ 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 diff --git a/tests/test_dashboard_auth_placement.py b/tests/test_dashboard_auth_placement.py index 01649f6..da32b1d 100644 --- a/tests/test_dashboard_auth_placement.py +++ b/tests/test_dashboard_auth_placement.py @@ -78,7 +78,7 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses(): 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===401||error.status===402||error.status===501" in script + assert "error.status===402||error.status===501" in script assert "error.status===409" in script assert "Purchase Pro license" in script @@ -202,11 +202,15 @@ def test_a_consent_required_conflict_is_answered_with_the_consent_panel( @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", [401, 402, 501]) +@pytest.mark.parametrize("status", [402, 501]) def test_a_genuine_entitlement_failure_still_renders_the_upgrade_panel( tmp_path, view, status, ): - """401 unauthenticated, 402 not subscribed, 501 not offered: all billing answers.""" + """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}, @@ -220,11 +224,16 @@ def test_a_genuine_entitlement_failure_still_renders_the_upgrade_panel( @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, 500, 503]) +@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 or a cloud outage is not an unpaid invoice.""" + """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, @@ -267,10 +276,10 @@ def test_only_an_entitlement_status_may_draw_the_purchase_panel(): """ helper = _dashboard_function("hostedFeatureUnavailable") - assert "error.status===401||error.status===402||error.status===501" in helper + 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))) == ["401", "402", "501"] + assert sorted(set(re.findall(r"status\s*===\s*(\d+)", helper))) == ["402", "501"] consent = _dashboard_function("managedConsentRequired") assert "error.status===409" in consent diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index 932eb5e..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, } @@ -336,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 index 70935b4..83e18ca 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -981,6 +981,149 @@ def _lapsed(*_args, **_kwargs): 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: From e3f9f78c9bacac9e2463b8633da6ee2cc130ef41 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 06:25:40 -0400 Subject: [PATCH 12/12] fix(cloud): serialize the denial write and distrust an unknown org Two review findings, both on code added earlier in this branch. The organization guard rejected only a mismatch. A fresh bootstrap carries just a refresh credential and a control URL, so the current organization is unknown until the first refresh names it -- and in that window a reused state directory served whatever it still held, i.e. the previous organization's paid plan, indefinitely with the refresh disabled. A cache scoped to an organization now requires a positive match, not merely the absence of a contradiction. record_billing_denial did an unguarded load-modify-save on the shared session file. Another worker rotating the credential concurrently could have its new value overwritten with the stale one; the control plane treats a spent credential as replay and revokes the whole family, so a lapsed subscription would have become a forced reconnect. The read, modify and save now happen inside the same _refresh_lock() that access_for_workspace rotates under. Verified the 402 reaches the handler with its status intact (a real 402 propagates out of access_for_workspace unrewritten) and that the lock is not re-entered on that path -- it has already unwound when the handler runs, which matters because _refresh_lock is not re-entrant. Co-Authored-By: Claude Opus 5 (1M context) --- engraphis/cloud_session.py | 32 +++++++++++------ engraphis/routes/v2_api.py | 8 ++++- tests/test_cloud_session.py | 53 ++++++++++++++++++++++++++++ tests/test_hosted_plan_resolution.py | 31 ++++++++++++++++ 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index 3da5107..b539afe 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -312,17 +312,27 @@ def record_billing_denial() -> bool: """ try: - 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() - _save(saved) - return not already_denied + # 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 diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index fec6971..baf2b0c 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -1998,9 +1998,15 @@ def _read_entitlement_cache() -> dict: # 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 current and organization_id != current: + if not current or organization_id != current: return {} plan = _normalized_plan(stored_plan) return { diff --git a/tests/test_cloud_session.py b/tests/test_cloud_session.py index dfbf612..c5334e3 100644 --- a/tests/test_cloud_session.py +++ b/tests/test_cloud_session.py @@ -433,3 +433,56 @@ def test_record_billing_denial_stamps_every_denial_including_the_repeat() -> Non "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_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py index 83e18ca..8dbedf3 100644 --- a/tests/test_hosted_plan_resolution.py +++ b/tests/test_hosted_plan_resolution.py @@ -1250,6 +1250,37 @@ def test_a_cached_entitlement_for_another_organization_is_refused(monkeypatch) - 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: