From ad26b5e5520cf7de5364e68263dc80688af66c85 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 02:21:10 -0400 Subject: [PATCH 01/11] fix: restore managed compute consent and diagnostics --- .env.example | 6 ++++++ .gitignore | 5 +++++ CHANGELOG.md | 9 ++++---- README.md | 14 +++++++++++-- SECURITY.md | 5 +++-- demo/record_screen_demo.mjs | 23 +++++++++++++-------- deploy/railway-template.json | 5 +++++ docs/RAILWAY_TEMPLATE.md | 4 ++-- engraphis/cloud_features.py | 31 ++++++++++++++++++++-------- engraphis/core/consolidate.py | 23 +++++++++++++++------ engraphis/service.py | 26 ++++++++++++++++------- engraphis/static/dashboard.js | 2 +- pyproject.toml | 8 +++++++ tests/test_cloud_features.py | 26 ++++++++++++++++++++--- tests/test_consolidate.py | 29 ++++++++++++++++++++++++++ tests/test_dashboard_dream_ui.py | 3 ++- tests/test_dashboard_v2.py | 3 ++- tests/test_packaging.py | 1 + tests/test_proactive_context.py | 18 ++++++++++++++++ tests/test_release_infrastructure.py | 3 +++ tests/test_service_graph.py | 26 +++++++++++++++++++++++ 21 files changed, 223 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index d2811e3..9a3a72b 100644 --- a/.env.example +++ b/.env.example @@ -168,5 +168,11 @@ 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 + # Optional credential-redacted JSON logs for hosted customer deployments. # ENGRAPHIS_JSON_LOGS=1 diff --git a/.gitignore b/.gitignore index f06f9c1..5f53528 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,11 @@ internal/ /demo/generated/ /demo/output/ +# Local demo variants and large media assets are working files, not package inputs. +/demo/*_killer.html +/demo/*_social_demo.html +/demo/assets/ + # FUSE mount artifacts: orphaned unlink-while-open handles from the Cowork # mount layer, not real content. Safe to ignore; delete host-side once the # holding process releases the handle (rm from the sandbox gets EPERM). diff --git a/CHANGELOG.md b/CHANGELOG.md index fee5355..f9e8d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,8 +68,8 @@ Public 1.0.1 client reliability release. configured destination is public, preventing unresolved hosts from bypassing the SSRF guard. - Hosted Automation and maintenance requests now use the selected workspace end to end rather than silently falling back to the first workspace. -- The Automation tab has one proposal action, clear managed-upload disclosure, and automatic - entitlement-gated managed-compute availability while preserving snapshot redaction and limits. +- The Automation tab has one proposal action, clear managed-upload disclosure, and explicit + managed-compute consent in addition to entitlement checks, snapshot redaction, and limits. - Commercial metadata now describes Pro as one owner account across that owner's local installations, matching the hosted entitlement model; Team remains billed per named seat. - API error responses and provider logs no longer expose arbitrary exception or configuration @@ -124,8 +124,9 @@ Public 1.0.0 open-core GA release. implementations from the public source and package artifacts. - Bare memory IDs, shared-workspace controls, graph entities, statistics, snapshots, exports, audit rows, and keyword fallbacks cannot cross authenticated session or workspace boundaries. -- Managed uploads are capped at 16 MiB and 100,000 rows, omit all session-scoped and - secret-class memories, and surface only fixed client-safe provider errors. +- Managed uploads require explicit customer consent, are capped at 16 MiB and 100,000 rows, + omit all session-scoped and secret-class memories, and surface only fixed client-safe + provider errors. - Customer credentials remain owner-only, redirect-safe, serialized during rotation, and are never substituted with an unproven local machine identifier. diff --git a/README.md b/README.md index b336093..592ce25 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ a color palette and layout preset; or change the colors used for each type of no | **Team Cloud** *(hosted Team)* | Open the hosted organization dashboard for invitations, roles, named seats, scoped credentials, and team audit history | | **Settings** | Hosted-plan and Cloud Sync status, LLM provider setup/test, a live structured-extraction switch and activity viewer, appearance, and local engine/store info | +### 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. + 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. @@ -608,8 +616,9 @@ 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 is enabled automatically for authorized customers. A managed proposal -does not silently rewrite the local database. +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. Manual consolidation can also use schema-validated LLM output through `MemoryService.consolidate`, `POST /api/consolidate`, `engraphis_consolidate`, or @@ -658,6 +667,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 | See `.env.example` for the full customer-runtime and managed-service client options. diff --git a/SECURITY.md b/SECURITY.md index ddeb5d6..3053a46 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -114,8 +114,9 @@ them back as `expected_head` / `expected_count` when independent evidence is req no paid-key parser, signer, issuer, local feature gate, or long-lived-key relay exchange. - **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 boundaries:** Analytics, Auto Dreaming, and Auto Consolidation upload a - bounded snapshot for authorized customers. Secret-class memories are excluded before +- **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. - **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, diff --git a/demo/record_screen_demo.mjs b/demo/record_screen_demo.mjs index 1fba2b5..891cd42 100644 --- a/demo/record_screen_demo.mjs +++ b/demo/record_screen_demo.mjs @@ -10,12 +10,17 @@ const repoRoot = resolve(demoDir, ".."); const generatedDir = join(demoDir, "generated"); const outputDir = join(demoDir, "output"); const payload = join(generatedDir, "screen_demo_payload.json"); -const webm = join(outputDir, "engraphis-memory-demo.webm"); -const mp4 = join(outputDir, "engraphis-memory-demo.mp4"); +const html = "engraphis_screen_demo.html"; +const baseName = "engraphis-memory-demo"; +const width = 1920; +const height = 1080; +const durationMs = 56_500; +const webm = join(outputDir, `${baseName}.webm`); +const mp4 = join(outputDir, `${baseName}.mp4`); const port = 8790; const demoAssets = new Map([ - ["/", join(demoDir, "engraphis_screen_demo.html")], - ["/engraphis_screen_demo.html", join(demoDir, "engraphis_screen_demo.html")], + ["/", join(demoDir, html)], + [`/${html}`, join(demoDir, html)], ["/generated/screen_demo_payload.json", payload], ]); @@ -49,15 +54,15 @@ await new Promise((resolveServer) => server.listen(port, "127.0.0.1", resolveSer const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ - viewport: { width: 1920, height: 1080 }, - recordVideo: { dir: outputDir, size: { width: 1920, height: 1080 } }, + viewport: { width, height }, + recordVideo: { dir: outputDir, size: { width, height } }, deviceScaleFactor: 1, }); const page = await context.newPage(); -await page.goto(`http://127.0.0.1:${port}/engraphis_screen_demo.html?autoplay=1`, { waitUntil: "networkidle" }); +await page.goto(`http://127.0.0.1:${port}/${html}?autoplay=1`, { waitUntil: "networkidle" }); // Keep the capture clock independent from requestAnimationFrame throttling in -// headless environments; the page itself still stops its progress bar at 56s. -await page.waitForTimeout(56_500); +// headless environments and leave a small tail after the page's 56-second animation. +await page.waitForTimeout(durationMs); await context.close(); await browser.close(); server.close(); diff --git a/deploy/railway-template.json b/deploy/railway-template.json index e751c40..b2b83e3 100644 --- a/deploy/railway-template.json +++ b/deploy/railway-template.json @@ -48,6 +48,11 @@ "ENGRAPHIS_CLOUD_COMPUTE_URL": { "value": "https://compute.engraphis.com", "required": false + }, + "ENGRAPHIS_MANAGED_COMPUTE_CONSENT": { + "value": "0", + "prompt": "Change to 1 only after reviewing the managed snapshot upload boundary.", + "required": false } } } diff --git a/docs/RAILWAY_TEMPLATE.md b/docs/RAILWAY_TEMPLATE.md index 20dbe61..0c2cf8f 100644 --- a/docs/RAILWAY_TEMPLATE.md +++ b/docs/RAILWAY_TEMPLATE.md @@ -14,8 +14,8 @@ 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 is enabled for -authorized customers automatically. +must be injected as a secret or mounted owner-only state file. Managed compute requires +explicit opt-in via `ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1`. ## Publish gate diff --git a/engraphis/cloud_features.py b/engraphis/cloud_features.py index acda9fe..f703f9b 100644 --- a/engraphis/cloud_features.py +++ b/engraphis/cloud_features.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import os import re import time import urllib.error @@ -26,6 +27,10 @@ MAX_TEXT_CHARS = 100_000 +def _truthy(value: str | None) -> bool: + return value is not None and value.strip().lower() in ("1", "true", "yes", "on") + + class CloudFeatureError(RuntimeError): """A bounded, redacted managed-cloud failure suitable for an HTTP/UI boundary.""" @@ -42,13 +47,13 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): def managed_compute_consent() -> bool: - """Return whether managed compute is enabled for the customer client. + """Return whether the customer explicitly enabled managed snapshot uploads. - Managed compute is enabled automatically for every user with a valid cloud entitlement. - This compatibility helper intentionally no longer reads an environment variable. + 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``. """ - - return True + return _truthy(os.environ.get("ENGRAPHIS_MANAGED_COMPUTE_CONSENT")) def _public_http_error(status: int) -> tuple[str, bool]: @@ -179,18 +184,26 @@ def build_managed_snapshot(service: Any, workspace: str, *, def _build_managed_snapshot_locked(service: Any, workspace: str, *, - consent: Optional[bool] = None, - generation: Optional[int] = None) -> tuple[str, dict]: + consent: Optional[bool] = None, + generation: Optional[int] = None) -> tuple[str, dict]: """Build the bounded client-side transport document for one local workspace. - Secret-classified rows are omitted before serialization. The ``consent`` parameter remains - accepted for source compatibility, but managed compute is enabled automatically. + 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. """ clean_workspace = service._clean_ws(workspace) workspace_id = service._lookup_workspace(clean_workspace) if not workspace_id: raise CloudFeatureError("The selected workspace does not exist.", status=404) + 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.", + status=409, + ) snapshot_generation = _reserve_snapshot_generation( service, workspace_id, requested=generation ) diff --git a/engraphis/core/consolidate.py b/engraphis/core/consolidate.py index 5e3fc23..e8b9229 100644 --- a/engraphis/core/consolidate.py +++ b/engraphis/core/consolidate.py @@ -23,6 +23,7 @@ import hashlib import json +import logging import re import time from dataclasses import replace as _replace @@ -32,6 +33,8 @@ from engraphis.core.interfaces import MemoryRecord, MemoryType, Scope, SearchFilter from engraphis.core.textutil import estimate_tokens, jaccard, tokenize +logger = logging.getLogger(__name__) + # Cluster admission: same-subject signal, deliberately the resolver's threshold. SUBJECT_JACCARD = 0.40 # Minimum recurrences before an episodic pattern is worth a semantic digest. @@ -222,8 +225,10 @@ def consolidate(engine, *, workspace_id: str, repo_id: Optional[str] = None, reason=f"retention {r:.4f} below {archive_below} (consolidation sweep)") try: engine.index.delete([m.id]) - except Exception: - pass + except Exception as exc: + logger.warning( + "index delete failed for memory %s (%s)", m.id, type(exc).__name__ + ) # ── compaction summary: the payoff of the sweep, as a number ───────────── report["compaction"] = { @@ -368,8 +373,10 @@ def _loads_lenient(raw: Any) -> Any: if match: try: return json.loads(match.group(1)) - except Exception: - pass + except Exception as exc: + logger.warning( + "structured output fallback parse failed (%s)", type(exc).__name__ + ) return {} @@ -642,8 +649,12 @@ def _write_structured_digests(engine, cluster: list[MemoryRecord], facts: list[d memory.id, at=now, actor="consolidation", reason=reason) try: engine.index.delete([memory.id]) - except Exception: - pass + except Exception as exc: + logger.warning( + "index delete failed for memory %s (%s)", + memory.id, + type(exc).__name__, + ) return ids diff --git a/engraphis/service.py b/engraphis/service.py index c501052..8736a22 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -1938,10 +1938,14 @@ def proactive_context(self, *, workspace: str, repo: Optional[str] = None, if query: try: recalled = self.recall(query, workspace=workspace, repo=repo, k=k, - reinforce=False) + reinforce=False) memories.extend(recalled.get("memories") or []) - except Exception: - pass + except Exception as exc: + logger.warning( + "proactive_context recall failed for workspace %s (%s)", + workspace, + type(exc).__name__, + ) llm = None if synthesize: try: @@ -5368,16 +5372,24 @@ def _lazy_backfill_graph(self, wid: str) -> None: repo_id=r["repo_id"], title=r["title"] or "", extractor=StructuredMetadataGraphExtractor(meta), provenance={"source": "structured_backfill", "memory_id": r["id"]}) - except Exception: - pass + except Exception as exc: + logger.warning( + "structured graph backfill failed for memory %s (%s)", + r["id"], + type(exc).__name__, + ) if self.engine.graph_extractor is not None: try: _graph_feed(self.store, r["content"] or "", workspace_id=wid, repo_id=r["repo_id"], title=r["title"] or "", extractor=self.engine.graph_extractor, provenance={"source": "lazy_backfill", "memory_id": r["id"]}) - except Exception: - pass + except Exception as exc: + logger.warning( + "lazy graph backfill failed for memory %s (%s)", + r["id"], + type(exc).__name__, + ) # ── introspection ─────────────────────────────────────────────────────────── def stats(self, *, workspace: Optional[str] = None) -> dict: diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 8de80a1..b78d12d 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -179,7 +179,7 @@ function renderAnalytics(a,isPortfolio){const t=a.totals||{},f=a.decay_forecast| 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{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)}
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(e.status===401||e.status===402||e.status===501){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}} async function saveAutomation(){const body={enabled:document.getElementById('au-enabled').checked,cadence_hours:Number(document.getElementById('au-cadence').value)||24,consolidate:document.getElementById('au-consolidate').checked,min_cluster:Number(document.getElementById('au-mincluster').value)||3,archive_below:Number(document.getElementById('au-archive').value)||0.05,dream_enabled:document.getElementById('au-dream').checked,dream_min_new:Number(document.getElementById('au-dream-min').value)||20,dream_idle_minutes:Number(document.getElementById('au-dream-idle').value)};try{await api('/automation?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Hosted policy saved','ok');loadAutomation()}catch(e){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='
'+esc(e.message)+'
';toast((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message,'err')}} diff --git a/pyproject.toml b/pyproject.toml index 4efedf0..bf03c0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -200,3 +200,11 @@ filterwarnings = [ # dependency bump — silence just this one message so the suite output stays clean. "ignore:Using `httpx` with `starlette.testclient` is deprecated", ] + +[tool.coverage.run] +source = ["engraphis"] +omit = ["tests/*"] + +[tool.coverage.report] +show_missing = true +skip_covered = true diff --git a/tests/test_cloud_features.py b/tests/test_cloud_features.py index ed2d590..f3b00fd 100644 --- a/tests/test_cloud_features.py +++ b/tests/test_cloud_features.py @@ -34,13 +34,32 @@ def _service() -> MemoryService: return service -def test_snapshot_is_enabled_without_a_user_opt_in() -> None: - _, snapshot = build_managed_snapshot(_service(), "acme", consent=False) +def test_snapshot_requires_explicit_consent(monkeypatch) -> None: + monkeypatch.delenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", raising=False) + + assert cloud_features.managed_compute_consent() is False + with pytest.raises(CloudFeatureError, match="Managed compute is off") as captured: + build_managed_snapshot(_service(), "acme") + + assert captured.value.status == 409 + + +def test_snapshot_accepts_environment_opt_in(monkeypatch) -> None: + monkeypatch.setenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", "yes") + + _, snapshot = build_managed_snapshot(_service(), "acme") assert cloud_features.managed_compute_consent() is True assert snapshot["managed_compute_consent"] is True +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"): + build_managed_snapshot(_service(), "acme", consent=False) + + def test_snapshot_excludes_secret_rows_before_serialization() -> None: service = _service() workspace_id, snapshot = build_managed_snapshot( @@ -198,7 +217,8 @@ def run_job(self, workspace_id: str, kind: str, generation: int, *, } -def test_run_managed_job_only_sends_the_protocol_snapshot() -> None: +def test_run_managed_job_only_sends_the_protocol_snapshot(monkeypatch) -> None: + monkeypatch.setenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", "1") cloud = _FakeCloud() result = run_managed_job( _service(), "acme", "analytics", client=cloud, wait_seconds=0 diff --git a/tests/test_consolidate.py b/tests/test_consolidate.py index 632345d..6adc190 100644 --- a/tests/test_consolidate.py +++ b/tests/test_consolidate.py @@ -501,6 +501,35 @@ def test_archive_pass_sees_transients_behind_newer_semantic_rows(monkeypatch): assert [row["id"] for row in report["archived"]] == [stale] +def test_archive_logs_index_cleanup_failure_without_leaking_exception_text( + monkeypatch, caplog +): + eng = MemoryEngine.create(":memory:") + wid = eng.store.get_or_create_workspace("w") + stale = eng.remember( + "Scratch note from an old session.", + workspace_id=wid, + mtype=MemoryType.WORKING, + resolve_conflicts=False, + ) + eng.store.conn.execute( + "UPDATE memories SET stability=0.01, last_access=? WHERE id=?", + (time.time() - 86_400, stale), + ) + eng.store.conn.commit() + + def fail_delete(ids): + raise RuntimeError("credential-like index detail") + + monkeypatch.setattr(eng.index, "delete", fail_delete) + with caplog.at_level("WARNING", logger="engraphis.core.consolidate"): + report = consolidate(eng, workspace_id=wid) + + assert [row["id"] for row in report["archived"]] == [stale] + assert "RuntimeError" in caplog.text + assert "credential-like index detail" not in caplog.text + + # ── explicit local consolidation command ───────────────────────────────────── from scripts.consolidate import main as consolidate_main # noqa: E402 diff --git a/tests/test_dashboard_dream_ui.py b/tests/test_dashboard_dream_ui.py index 504649e..903abf7 100644 --- a/tests/test_dashboard_dream_ui.py +++ b/tests/test_dashboard_dream_ui.py @@ -14,7 +14,8 @@ 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 "Requesting managed work uploads the selected workspace’s normal and sensitive memory content" in script + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1" in script + assert "uploads the selected workspace’s normal and sensitive memory content" in script def test_save_body_posts_dream_fields(): diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index c4a81d2..fcbe199 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -241,7 +241,8 @@ 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 "Requesting managed work uploads the selected workspace’s normal and sensitive memory content" in source + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1" in source + assert "uploads the selected workspace’s normal and sensitive memory content" 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 d3d8118..d46da52 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -213,3 +213,4 @@ 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 diff --git a/tests/test_proactive_context.py b/tests/test_proactive_context.py index d6ed5e3..7849377 100644 --- a/tests/test_proactive_context.py +++ b/tests/test_proactive_context.py @@ -46,6 +46,24 @@ def test_service_proactive_context_is_deterministic_and_cited(): assert any("Storage backend" in q or "persistence" in q for q in out["suggested_queries"]) +def test_service_proactive_context_logs_recall_failure_without_exception_text( + monkeypatch, caplog +): + svc = MemoryService.create(":memory:", embed_model="") + svc.store.get_or_create_workspace("acme") + + def fail_recall(*args, **kwargs): + raise RuntimeError("credential-like provider detail") + + monkeypatch.setattr(svc, "recall", fail_recall) + with caplog.at_level("WARNING", logger="engraphis.service"): + out = svc.proactive_context(workspace="acme", task="resume work", k=5) + + assert out["workspace"] == "acme" + assert "RuntimeError" in caplog.text + assert "credential-like provider detail" not in caplog.text + + def test_ai_context_treats_string_open_threads_as_one_query(): out = build_proactive_context( memories=[], last_session={"open_threads": "finish the migration"}) diff --git a/tests/test_release_infrastructure.py b/tests/test_release_infrastructure.py index a7322c4..2d2756e 100644 --- a/tests/test_release_infrastructure.py +++ b/tests/test_release_infrastructure.py @@ -28,6 +28,9 @@ 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_consent = template["variables"]["ENGRAPHIS_MANAGED_COMPUTE_CONSENT"] + assert managed_consent["value"] == "0" + assert managed_consent["required"] is False for removed in ( "ENGRAPHIS_DEPLOYMENT_TOKEN", "ENGRAPHIS_LICENSE_KEY", diff --git a/tests/test_service_graph.py b/tests/test_service_graph.py index 4e7bec5..df5adcf 100644 --- a/tests/test_service_graph.py +++ b/tests/test_service_graph.py @@ -297,6 +297,32 @@ def test_graph_lazy_backfills_structured_metadata_without_regex_extractor(): "label": "stores_in", "layer": "semantic"} in g["edges"] +def test_graph_lazy_backfill_logs_failure_without_exception_text(monkeypatch, caplog): + from engraphis.backends import graph_extractor + + svc = MemoryService.create(":memory:", graph_extractor="none") + wid = svc.store.get_or_create_workspace("acme") + svc.store.add_memory(MemoryRecord( + id="", + content="Engraphis stores memories in SQLite.", + workspace_id=wid, + scope=Scope.WORKSPACE, + mtype=MemoryType.SEMANTIC, + metadata={"entities": ["Engraphis", "SQLite"]}, + )) + + def fail_feed(*args, **kwargs): + raise RuntimeError("credential-like graph detail") + + monkeypatch.setattr(graph_extractor, "feed", fail_feed) + with caplog.at_level("WARNING", logger="engraphis.service"): + graph = svc.graph(workspace="acme") + + assert graph["nodes"] == [] + assert "RuntimeError" in caplog.text + assert "credential-like graph detail" not in caplog.text + + def test_graph_lazy_backfills_preexisting_memories(): """Memories written while extraction was OFF have no entities. When extraction is later enabled (an update), the first Graph-tab open backfills that From 8ac6149c42e7750c765f6c06e81fdaa134a77eed Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 03:43:07 -0400 Subject: [PATCH 02/11] fix: harden cloud sync release recovery --- CHANGELOG.md | 78 ++++++++++++----------------------- docs/SYNC.md | 5 ++- engraphis/routes/v2_api.py | 28 ++++++++++--- engraphis/static/dashboard.js | 4 +- playwright.config.js | 12 ++++-- tests/e2e/commercial.spec.js | 47 +++++++++++++++++---- tests/test_sync_dashboard.py | 48 +++++++++++++++++++++ 7 files changed, 150 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e8d39..c7312be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,54 +5,7 @@ All notable changes to Engraphis are documented here. Format loosely follows ## [Unreleased] -### Added - -- A search-first Analytical Galaxy Graph Explorer backed by canonical, evidence-weighted - scenes; deterministic hierarchical system gravity; compact edge backbones and aggregate - bridges; exact-ID evidence/history inspection; strongest-evidence paths; synchronized - accessible tables; responsive list-first mobile behavior; saved/shareable scene state; and - local PNG, JSON, and CSV exports. -- Additive schema-v4 canonical identity and bi-temporal edge-support records, deterministic - `/api/graph/scene`, `/api/graph/suggest`, `/api/graph/entities/{canonical_id}`, and - `/api/graph/path` contracts with repository, memory-type, evidence-time, historical, and - weak-co-occurrence filtering, plus a persisted graph-index job with dry-run, progress, - cancellation, generation state, bounded error reporting, audit records, and tamper-evident - receipts. -- Galaxy Explorer: a new Simple view (the default) renders a clean glowing galactic core with - soft bloom, plus an Advanced view toggle that keeps the fully decorated explorer available. - Default galaxy rotation is now ~10x slower for readability, and a new Freeze control - pauses/resumes the live rotation. - -### Changed - -- The legacy graph view now defaults to deterministic community islands, keeps sparse - influence bridges visually subordinate, and renders bounded direct A-MEM links even when - entity extraction is disabled. A reproducible repository-local screen-demo workflow exercises - session handoff, bi-temporal supersession, recall evidence, and history without external - services. -- The public distribution is now structurally customer-only. License issuance, billing, - fulfillment, Team identity, hosted relay, managed compute, worker execution, vendor - administration, and commercial operations tooling moved to a private service repository; - the Apache package retains local core functionality and customer-side protocols. -- Analytics, Cloud Sync service operation, Auto Dreaming, Auto Consolidation, automation - scheduling, and Team administration are now hosted-service capabilities. The public dashboard - keeps status, consent, and launch surfaces plus the free manual consolidation action; it no - longer ships local premium algorithms, schedulers, Team accounts, invitations, roles, or seats. -- The hosted no-card trial remains **exactly 3 active days** after email confirmation. A distinct - `workspace_write_grace` may preserve ordinary writes to an already provisioned local workspace - for at most 24 hours; it never extends the trial or any cloud/paid access. -- Documentation now states the legal boundary directly: Apache-2.0 rights in already published - releases and forks cannot be clawed back. Future proprietary value is protected through the - private hosted implementation and service authorization boundary. -- Recall graph seeding now matches all entity names with one boundary-aware compiled pattern - instead of rescanning every memory once per entity, and the streamable-HTTP launcher warms - the singleton service before accepting clients. - -- The graph explorer uses its locally bundled ForceGraph + D3 renderer under the strict - same-origin CSP. The new scene APIs and synchronized accessible entity/relation tables are - additive to that shipped UI. -- Graph GET requests are strictly read-only. While an explicit mutating index job runs, graph - reads return a rebuilding conflict instead of mixing old and partially derived metrics. +No unreleased changes. ## [1.0.1] - 2026-07-23 @@ -78,6 +31,10 @@ Public 1.0.1 client reliability release. backtracking. CodeQL now disables pull-request diff-informed analysis and CI fails on every raw SARIF result, including pre-existing and source-suppressed results. - The documented grounded-recall evaluation prints with the default Windows console encoding. +- Hosted Pro and Team links preserve the selected plan through account creation and Checkout. +- A total `401`/`402`/`403` Cloud Sync authorization loss restores the hosted recovery CTA, + while a successful empty or read-only workspace remains a partial result instead of being + misreported as a total denial. ## [1.0.0] - 2026-07-23 @@ -85,9 +42,14 @@ Public 1.0.0 open-core GA release. ### Added -- The Galaxy Knowledge Graph explorer with deterministic communities, entity/relation search, - temporal scenes, evidence and history inspection, accessible tables, and a locally bundled - ForceGraph + D3 renderer. +- The search-first Galaxy Knowledge Graph explorer with deterministic communities, canonical + evidence-weighted scenes, entity/relation search, temporal filtering, evidence and history + inspection, strongest-evidence paths, synchronized accessible tables, saved scene state, + local PNG/JSON/CSV export, Simple and Advanced views, and a locally bundled ForceGraph + D3 renderer + under the strict same-origin CSP. +- Additive schema-v4 canonical identity and bi-temporal edge-support records; deterministic + graph scene, suggestion, entity, and path APIs; and a persisted graph-index job with dry-run, + progress, cancellation, bounded errors, audit records, and tamper-evident receipts. - A 29-tool MCP surface with explicit behavior annotations, operation receipts, exact session retry semantics, portable plugin manifests, and checksummed skill assets. - Customer-side hosted protocols for scoped Cloud Sync, rotating cloud sessions, Analytics, @@ -102,6 +64,15 @@ Public 1.0.0 open-core GA release. no environment variable turns the public package into a hosted Engraphis service. - Session identity is exact across workspace, repo, authenticated user, agent, and goal; callers can request a distinct run with `force_new=true` and observe retry reuse explicitly. +- The legacy graph view defaults to deterministic community islands, keeps sparse influence + bridges subordinate, and renders bounded A-MEM links when entity extraction is disabled. The + repository screen demo proves session handoff, bi-temporal supersession, recall evidence, and + history without an external service. +- The hosted no-card trial is exactly 3 active days after email confirmation. A separate + `workspace_write_grace` may preserve ordinary local writes for at most 24 hours but never + extends trial or paid cloud access. +- Apache-2.0 rights in published releases remain irrevocable; proprietary hosted value is + enforced by the private implementation and service authorization boundary. ### Fixed @@ -117,6 +88,11 @@ Public 1.0.0 open-core GA release. forgotten session-only graph evidence. - Windows private-state validation uses safe file metadata checks without weakening symlink, ownership, size, or atomic-publication protections. +- Recall graph seeding uses one boundary-aware compiled pattern instead of rescanning every + memory per entity, and the streamable HTTP launcher warms the singleton service before + accepting clients. +- Graph GET requests remain read-only and return a rebuilding conflict while an explicit + mutating index job is in progress. ### Security diff --git a/docs/SYNC.md b/docs/SYNC.md index 4d8ca74..07a171b 100644 --- a/docs/SYNC.md +++ b/docs/SYNC.md @@ -65,7 +65,10 @@ python -m scripts.sync \ The dashboard's **Sync now** action invokes the same customer protocol. The public package does not run a local auto-sync loop or ship a cron/Task Scheduler wrapper. Hosted automation belongs -to the private service. +to the private service. If the relay denies every attempted shared workspace because the session +is expired, revoked, or no longer entitled, the dashboard returns to the hosted Pro/Team recovery +CTA instead of reporting a successful empty sync. A successful empty or read-only workspace keeps +the result partial so another workspace's denial is not misreported as a total authorization loss. ### Local folder transport diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index d8b6543..2064995 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -1804,6 +1804,7 @@ def _sync_all(svc) -> dict: # device by its workspace count. Counting unique device ids gives the true peer total. peer_devices: set = set() exported, errors = 0, [] + attempted, succeeded = 0, 0 legacy_token_configured = has_sync_token() for w in wss: name = w.get("name") @@ -1827,6 +1828,7 @@ def _sync_all(svc) -> dict: # convention (which collapses malformed settings to "shared"): this path # uploads the folder off-device, so a corrupted settings row must block the # push rather than silently treat a possibly-personal folder as shared. + attempted += 1 try: raw_settings = json.loads(row["settings"] or "{}") except (TypeError, ValueError): @@ -1878,6 +1880,7 @@ def _sync_all(svc) -> dict: logger.error("sync workspace failed (%s)", type(exc).__name__) errors.append({"workspace": name, "error": "sync workspace failed"}) continue + succeeded += 1 exported += int(rep.get("exported_memories", 0) or 0) for a in rep.get("applied") or []: dev = a.get("from_device") @@ -1886,7 +1889,8 @@ def _sync_all(svc) -> dict: for k in totals: totals[k] += int((rep.get("totals") or {}).get(k, 0) or 0) - return {"at": time.time(), "workspaces": len(wss), "exported": exported, + return {"at": time.time(), "workspaces": len(wss), "attempted": attempted, + "succeeded": succeeded, "exported": exported, "peers": len(peer_devices), "added": totals["added"], "updated": totals["updated"], "unchanged": totals["unchanged"], "errors": errors} @@ -1917,11 +1921,23 @@ async def sync_run(): import asyncio summary = await asyncio.to_thread(_sync_all, svc) _SYNC_STATE["last"] = summary - # If cloud authorization failed for every workspace (nothing exported, a 402 seen), - # surface it as the button's upgrade/renew prompt rather than a silent partial success. - if summary["exported"] == 0 and any(e.get("status") == 402 for e in summary["errors"]): - first = next(e for e in summary["errors"] if e.get("status") == 402) - raise HTTPException(status_code=402, detail={ + # Promote a total authorization loss to the dashboard's recovery CTA. Successful + # empty/read-only workspaces still count as successes, so exported == 0 is not enough: + # every attempted shared workspace must have failed with an authorization status, and + # no different workspace error may be hidden behind the recovery prompt. + authorization_statuses = {401, 402, 403} + authorization_errors = [ + error for error in summary["errors"] + if error.get("status") in authorization_statuses + ] + if ( + summary["attempted"] > 0 + and summary["succeeded"] == 0 + and len(authorization_errors) == summary["attempted"] + and len(authorization_errors) == len(summary["errors"]) + ): + first = authorization_errors[0] + raise HTTPException(status_code=first["status"], detail={ "error": first["error"], "upgrade_url": licensing.upgrade_url()}) return {"ok": True, "summary": summary} diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index b78d12d..0ef593d 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -151,7 +151,7 @@ async function loadOverviewAnalytics(){ } /* ── 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(trial)url.searchParams.set('trial',plan);return url.href}catch(e){return safe}} +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 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')} @@ -440,7 +440,7 @@ async function testLlm(){const r=document.getElementById('llm-test-result');if(r 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')}} function renderSync(d){const el=document.getElementById('sync-body');if(!el)return;d=d||{};if(!d.available){el.innerHTML=unlockHtml('Cloud Sync','pro');return}const last=d.last;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)}
`} -async function syncNow(){const b=document.getElementById('sync-btn');const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent='Sync now'}if(s)s.textContent='Sync failed — try again.'}} +async function syncNow(){const b=document.getElementById('sync-btn');const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){if(e.status===401||e.status===402||e.status===403){const el=document.getElementById('sync-body');if(el)el.innerHTML=unlockHtml('Cloud Sync','pro');toast(e.status===402?'Cloud Sync requires an active Pro or Team entitlement — open Engraphis Cloud to upgrade or renew.':'Cloud Sync authorization is no longer active — reconnect in Engraphis Cloud.','err');return}toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent='Sync now'}if(s)s.textContent='Sync failed — try again.'}} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ let GRAPH=null, FG=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}; diff --git a/playwright.config.js b/playwright.config.js index b7481fb..961ba48 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,19 +1,25 @@ // @ts-check const { defineConfig } = require('@playwright/test'); +const playwrightPort = Number(process.env.ENGRAPHIS_PLAYWRIGHT_PORT || 8700); +if (!Number.isInteger(playwrightPort) || playwrightPort < 1024 || playwrightPort > 65535) { + throw new Error('ENGRAPHIS_PLAYWRIGHT_PORT must be an integer from 1024 to 65535'); +} +const playwrightBaseURL = `http://127.0.0.1:${playwrightPort}`; + module.exports = defineConfig({ testDir: './tests/e2e', timeout: 60_000, retries: 0, use: { - baseURL: 'http://127.0.0.1:8700', + baseURL: playwrightBaseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', }, webServer: { // Run the checked-out source, not a possibly stale globally installed console script. - command: 'python -m scripts.start_dashboard --no-open --port 8700', - url: 'http://127.0.0.1:8700/api/health', + command: `python -m scripts.start_dashboard --no-open --port ${playwrightPort}`, + url: `${playwrightBaseURL}/api/health`, timeout: 120_000, reuseExistingServer: false, env: { diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index 6ecea16..860414b 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -14,7 +14,7 @@ const hostedLicense = { trial: { used: false, trial_days: 3 }, }; -async function mockLocalClient(page, cloudStatus = 402) { +async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) { const calls = []; await page.route('**/api/**', async route => { @@ -53,7 +53,17 @@ async function mockLocalClient(page, cloudStatus = 402) { cloud_url: 'https://cloud.engraphis.test/team', }; } else if (path === '/sync/status') { - body = { available: false }; + body = { available: syncRunStatus !== null }; + } else if (path === '/sync/run' && syncRunStatus !== null) { + status = syncRunStatus; + body = { + detail: { + error: syncRunStatus === 402 + ? 'Cloud Sync entitlement is inactive (upgrade or renew required)' + : 'cloud relay synchronization failed', + upgrade_url: 'https://cloud.engraphis.test/pro', + }, + }; } else if (path === '/llm/status') { body = { configured: false, @@ -88,6 +98,23 @@ async function mockLocalClient(page, cloudStatus = 402) { return calls; } +test('Cloud Sync denial returns an unlicensed installation to the hosted upgrade CTA', async ({ page }) => { + const errors = recordBrowserErrors(page); + const calls = await mockLocalClient(page, 402, 402); + await page.goto('/'); + await openView(page, 'settings'); + + await expect(page.getByRole('button', { name: 'Sync now' })).toBeVisible(); + 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.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); + expect(errors).toEqual([]); +}); + function recordBrowserErrors(page) { const errors = []; page.on('console', message => { @@ -95,7 +122,9 @@ function recordBrowserErrors(page) { const location = message.location(); const expectedCloudDenial = /\/api\/(analytics|automation)/.test(location.url || '') && /status of (401|402|501)/.test(message.text()); - if (expectedCloudDenial) return; + const expectedSyncDenial = /\/api\/sync\/run/.test(location.url || '') + && /status of (401|402|403)/.test(message.text()); + if (expectedCloudDenial || expectedSyncDenial) return; errors.push(message.text() + (location.url ? ` @ ${location.url}:${location.lineNumber}` : '')); @@ -134,9 +163,9 @@ test('local dashboard exposes hosted Pro and Team CTAs without local commercial const team = page.locator('#team-body'); await expect(team.getByText('Engraphis Team Cloud', { exact: false })).toBeVisible(); await expect(team.getByRole('link', { name: 'Start hosted Team trial' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/team?trial=team'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team&trial=team'); await expect(team.getByRole('link', { name: 'Open Team Cloud' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/team'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team'); await expect(team).toContainText('exactly 3 active days'); await expect(team).toContainText( 'A separate local-only write grace is capped at 24 hours and never extends Team or other cloud access.', @@ -187,9 +216,9 @@ for (const cloudStatus of [401, 402, 501]) { 'Local-only write grace is separate, capped at 24 hours, and never extends cloud access.', ); await expect(analytics.getByRole('link', { name: 'Start hosted Pro trial' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?trial=pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro'); await expect(analytics.getByRole('link', { name: 'View Pro plans' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro'); await expect(page.locator('#an-lock')).toHaveText('PRO'); const automationBefore = calls.filter(call => call.path === '/automation').length; @@ -206,9 +235,9 @@ for (const cloudStatus of [401, 402, 501]) { 'Local-only write grace is separate, capped at 24 hours, and never extends cloud access.', ); await expect(automation.getByRole('link', { name: 'Start hosted Pro trial' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?trial=pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro'); await expect(automation.getByRole('link', { name: 'View Pro plans' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro'); await expect(page.locator('#au-lock')).toHaveText('PRO'); expect(calls.some(call => call.path === '/analytics' && call.method === 'GET')).toBe(true); diff --git a/tests/test_sync_dashboard.py b/tests/test_sync_dashboard.py index 44da806..23c3016 100644 --- a/tests/test_sync_dashboard.py +++ b/tests/test_sync_dashboard.py @@ -103,6 +103,8 @@ def fake_get_transport(kind="folder", **kw): assert r.status_code == 200, r.text su = r.json()["summary"] assert su["workspaces"] >= 1 + assert su["attempted"] >= 1 + assert su["succeeded"] >= 1 assert su["exported"] >= 1 # the seeded memory was pushed assert su["errors"] == [] # the relay transport is namespaced by workspace NAME, at the managed relay @@ -189,3 +191,49 @@ def fail_transport(*args, **kwargs): "status": 502, }] assert secret not in repr(errors) + + +@pytest.mark.parametrize("status", (401, 402, 403)) +def test_sync_run_surfaces_total_authorization_denial(monkeypatch, tmp_path, status): + """A fully denied session must not look like a successful zero-item sync.""" + from engraphis.backends.sync_relay import RelayError + + def fail_transport(*args, **kwargs): + raise RelayError("relay authorization denied", status=status) + + monkeypatch.setattr("engraphis.backends.sync_folder.get_transport", fail_transport) + with _client(monkeypatch, tmp_path, cloud=True) as c: + response = c.post("/api/sync/run", json={}) + + assert response.status_code == status + detail = response.json()["detail"] + assert detail["error"] == "cloud relay synchronization failed" + assert detail["upgrade_url"].startswith("https://api.engraphis.com/account") + + +def test_sync_run_does_not_promote_partial_success_with_zero_exports(monkeypatch, tmp_path): + """A successful empty workspace prevents another workspace's denial from becoming total.""" + from engraphis.backends.sync_relay import RelayError + + def mixed_transport(*args, **kwargs): + if kwargs.get("workspace_id") == "demo": + raise RelayError("relay authorization denied", status=403) + return _FakeTransport() + + monkeypatch.setattr("engraphis.backends.sync_folder.get_transport", mixed_transport) + with _client(monkeypatch, tmp_path, cloud=True) as c: + from engraphis.routes import v2_api + v2_api.service().create_workspace("empty-shared", visibility="shared", confirmed=True) + + response = c.post("/api/sync/run", json={}) + + assert response.status_code == 200 + summary = response.json()["summary"] + assert summary["attempted"] == 2 + assert summary["succeeded"] == 1 + assert summary["exported"] == 0 + assert summary["errors"] == [{ + "workspace": "demo", + "error": "cloud relay synchronization failed", + "status": 403, + }] From a24005880caa76a2776367680d9391aa80adbf5b Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 04:28:52 -0400 Subject: [PATCH 03/11] fix: close v1.0.1 release blockers --- engraphis/backends/sync_relay.py | 4 ++- engraphis/cloud_features.py | 6 ++-- engraphis/cloud_session.py | 29 +++++++++++------ engraphis/routes/v2_api.py | 31 +++++++++++++----- engraphis/service.py | 9 ++---- engraphis/static/dashboard.js | 9 ++++-- tests/e2e/commercial.spec.js | 37 +++++++++++++++++++-- tests/test_cloud_session.py | 45 +++++++++++++++++++++++++- tests/test_dashboard_v2.py | 14 ++++++++ tests/test_proactive_context.py | 1 + tests/test_provider_error_redaction.py | 4 +-- tests/test_relay_device_credentials.py | 15 +++++++-- tests/test_service_graph.py | 1 + tests/test_sync_cli.py | 10 +++++- tests/test_sync_dashboard.py | 34 +++++++++++++++++++ 15 files changed, 211 insertions(+), 38 deletions(-) diff --git a/engraphis/backends/sync_relay.py b/engraphis/backends/sync_relay.py index 1fdc05c..9e9d601 100644 --- a/engraphis/backends/sync_relay.py +++ b/engraphis/backends/sync_relay.py @@ -291,7 +291,9 @@ def _validated_base_url(value: str) -> str: raise ValueError( "relay URL must not target private/reserved IP ranges") except (_socket.gaierror, OSError): - pass # DNS resolution failure; let the actual request fail later + raise ValueError( + "relay URL host could not be resolved to a public address" + ) from None return urlunsplit((scheme, parts.netloc, parts.path.rstrip("/"), "", "")) diff --git a/engraphis/cloud_features.py b/engraphis/cloud_features.py index f703f9b..cf5f201 100644 --- a/engraphis/cloud_features.py +++ b/engraphis/cloud_features.py @@ -27,7 +27,7 @@ MAX_TEXT_CHARS = 100_000 -def _truthy(value: str | None) -> bool: +def _truthy(value: Optional[str]) -> bool: return value is not None and value.strip().lower() in ("1", "true", "yes", "on") @@ -35,10 +35,11 @@ class CloudFeatureError(RuntimeError): """A bounded, redacted managed-cloud failure suitable for an HTTP/UI boundary.""" def __init__(self, message: str, *, status: Optional[int] = None, - transient: bool = False) -> None: + transient: bool = False, code: Optional[str] = None) -> None: super().__init__(message) self.status = status self.transient = transient + self.code = code class _NoRedirect(urllib.request.HTTPRedirectHandler): @@ -203,6 +204,7 @@ def _build_managed_snapshot_locked(service: Any, workspace: str, *, "Managed compute is off. Opt in before uploading workspace content by setting " "ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1.", status=409, + code="consent_required", ) snapshot_generation = _reserve_snapshot_generation( service, workspace_id, requested=generation diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index 6208c30..b505491 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -29,7 +29,9 @@ class CloudSessionError(RuntimeError): - pass + def __init__(self, message: str, *, status: int = 503) -> None: + super().__init__(message) + self.status = status class _NoRedirect(urllib.request.HTTPRedirectHandler): @@ -40,7 +42,9 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): def _validated_token_subject(value: object) -> str: subject = str(value or "member").strip().lower() if subject not in {"device", "member"}: - raise CloudSessionError("Cloud token subject must be 'device' or 'member'.") + raise CloudSessionError( + "Cloud token subject must be 'device' or 'member'.", status=409 + ) return subject @@ -98,7 +102,7 @@ def _refresh_lock(): raise except (OSError, UnsafeStateFile) as exc: raise CloudSessionError( - "The cloud session refresh lock is unavailable or unsafe." + "The cloud session refresh lock is unavailable or unsafe.", status=409 ) from exc handle = os.fdopen(descriptor, "r+b") @@ -123,7 +127,7 @@ def _refresh_lock(): except (OSError, UnsafeStateFile) as exc: handle.close() raise CloudSessionError( - "The cloud session refresh lock is unavailable or unsafe." + "The cloud session refresh lock is unavailable or unsafe.", status=409 ) from exc body_failed = False @@ -152,7 +156,7 @@ def _refresh_lock(): cleanup_error = cleanup_error or exc if cleanup_error is not None and not body_failed: raise CloudSessionError( - "The cloud session refresh lock could not be released safely." + "The cloud session refresh lock could not be released safely.", status=409 ) from cleanup_error @@ -160,13 +164,17 @@ def _load() -> dict: try: raw = read_private_text(_session_path(), max_bytes=64 * 1024, allow_missing=True) except UnsafeStateFile as exc: - raise CloudSessionError("The saved cloud session has unsafe filesystem permissions.") from exc + raise CloudSessionError( + "The saved cloud session has unsafe filesystem permissions.", status=409 + ) from exc if not raw: return {} try: value = json.loads(raw) except (ValueError, RecursionError) as exc: - raise CloudSessionError("The saved cloud session is invalid; connect again.") from exc + raise CloudSessionError( + "The saved cloud session is invalid; connect again.", status=409 + ) from exc return value if isinstance(value, dict) else {} @@ -229,7 +237,8 @@ def _post_refresh(control_url: str, refresh: str, workspace_id: str, exc.read(_MAX_RESPONSE_BYTES + 1) if exc.code in {401, 403}: raise CloudSessionError( - "The cloud session expired or was revoked; connect again." + "The cloud session expired or was revoked; connect again.", + status=exc.code, ) raise CloudSessionError("Engraphis Cloud could not refresh this session.") finally: @@ -294,7 +303,9 @@ def access_for_workspace( control = control or str(saved.get("control_url") or "").strip() compute = direct_compute or str(saved.get("compute_url") or "").strip() if not refresh or not control or (require_compute and not compute): - raise CloudSessionError("Connect this installation to Engraphis Cloud first.") + 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 "" token_subject = _token_subject(saved) diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index 2064995..a648ea9 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -147,10 +147,13 @@ 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, + "transient": exc.transient} + if exc.code == "consent_required": + detail["code"] = exc.code raise HTTPException( status_code=exc.status or 503, - detail={"error": "managed cloud operation failed", "managed_cloud": True, - "transient": exc.transient}, + detail=detail, ) from None @@ -1828,7 +1831,6 @@ def _sync_all(svc) -> dict: # convention (which collapses malformed settings to "shared"): this path # uploads the folder off-device, so a corrupted settings row must block the # push rather than silently treat a possibly-personal folder as shared. - attempted += 1 try: raw_settings = json.loads(row["settings"] or "{}") except (TypeError, ValueError): @@ -1850,6 +1852,7 @@ def _sync_all(svc) -> dict: "shared relay", }) continue + attempted += 1 try: cloud_access = None if not legacy_token_configured: @@ -1864,9 +1867,17 @@ def _sync_all(svc) -> dict: read_only = sync_read_only() rep = syncer.sync(transport, row["id"], push=not read_only) except CloudSessionError as exc: - logger.warning("cloud sync session failed (%s)", type(exc).__name__) - errors.append({"workspace": name, "error": "cloud session authorization failed", - "status": 401}) + status = exc.status if 400 <= exc.status <= 599 else 503 + if status in {401, 403}: + message = "cloud session authorization failed" + elif status == 409: + message = "cloud session state is unavailable" + else: + message = "cloud session is temporarily unavailable" + logger.warning( + "cloud sync session failed (%s, status=%s)", type(exc).__name__, status + ) + errors.append({"workspace": name, "error": message, "status": status}) continue except RelayError as exc: # Record the HTTP status (402 == cloud authorization denied) instead of raising, so @@ -1906,8 +1917,12 @@ async def sync_run(): has_token = has_sync_token() try: has_cloud_session = configured(require_compute=False) - except CloudSessionError: - has_cloud_session = False + except CloudSessionError as exc: + status = exc.status if 400 <= exc.status <= 599 else 503 + raise HTTPException(status_code=status, detail={ + "error": "The saved cloud session is unavailable.", + "upgrade_url": licensing.upgrade_url(), + }) from None if not has_token and not has_cloud_session: raise HTTPException(status_code=402, detail={ "error": "Connect this installation to Engraphis Cloud before syncing.", diff --git a/engraphis/service.py b/engraphis/service.py index 8736a22..1dbf15f 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -1942,8 +1942,7 @@ def proactive_context(self, *, workspace: str, repo: Optional[str] = None, memories.extend(recalled.get("memories") or []) except Exception as exc: logger.warning( - "proactive_context recall failed for workspace %s (%s)", - workspace, + "proactive_context recall failed (%s)", type(exc).__name__, ) llm = None @@ -5374,8 +5373,7 @@ def _lazy_backfill_graph(self, wid: str) -> None: provenance={"source": "structured_backfill", "memory_id": r["id"]}) except Exception as exc: logger.warning( - "structured graph backfill failed for memory %s (%s)", - r["id"], + "structured graph backfill failed (%s)", type(exc).__name__, ) if self.engine.graph_extractor is not None: @@ -5386,8 +5384,7 @@ def _lazy_backfill_graph(self, wid: str) -> None: provenance={"source": "lazy_backfill", "memory_id": r["id"]}) except Exception as exc: logger.warning( - "lazy graph backfill failed for memory %s (%s)", - r["id"], + "lazy graph backfill failed (%s)", type(exc).__name__, ) diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 0ef593d..d6196e2 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -176,12 +176,14 @@ 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}`} -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{el.innerHTML='
'+esc(e.message)+'
'}}} +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)+'
'}}} /* ── hosted automation policy (Pro / Team) ── */ async function loadAutomation(){const el=document.getElementById('automation-body'),lock=document.getElementById('au-lock'),ws='?workspace='+encodeURIComponent(WS||'');el.innerHTML='
';try{const p=await api('/automation'+ws);setPlanPill(lock,(LIC&&LIC.is_trial)?'TRIAL':'CLOUD','pill pill-accent');const last=p.last_run?fmtRel(p.last_run):'never',dream=p.dream_enabled!=null?p.dream_enabled:p.dream;el.innerHTML=`
Hosted maintenance policy
The cloud returns reviewable proposals. Pinned memories remain protected.
Cloud worker status
Status${p.enabled?'ENABLED':'OFF'}
Last run${esc(last)}
After explicit opt-in with ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1, requesting managed work uploads the selected workspace’s normal and sensitive memory content, excluding secret and session-scoped rows, capped at 16 MiB, over HTTPS without end-to-end encryption. Results are proposals and never automatically write the local database.
`}catch(e){if(e.status===401||e.status===402||e.status===501){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}} async function saveAutomation(){const body={enabled:document.getElementById('au-enabled').checked,cadence_hours:Number(document.getElementById('au-cadence').value)||24,consolidate:document.getElementById('au-consolidate').checked,min_cluster:Number(document.getElementById('au-mincluster').value)||3,archive_below:Number(document.getElementById('au-archive').value)||0.05,dream_enabled:document.getElementById('au-dream').checked,dream_min_new:Number(document.getElementById('au-dream-min').value)||20,dream_idle_minutes:Number(document.getElementById('au-dream-idle').value)};try{await api('/automation?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Hosted policy saved','ok');loadAutomation()}catch(e){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='
'+esc(e.message)+'
';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')}} const runMaintenanceBase=runMaintenance; let MAINTENANCE_PENDING=false; @@ -439,7 +441,8 @@ async function testLlm(){const r=document.getElementById('llm-test-result');if(r 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')}} -function renderSync(d){const el=document.getElementById('sync-body');if(!el)return;d=d||{};if(!d.available){el.innerHTML=unlockHtml('Cloud Sync','pro');return}const last=d.last;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)}
`} +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 renderSync(d){const el=document.getElementById('sync-body');if(!el)return;d=d||{};const last=d.last;if(!d.available||syncTotalAuthorizationDenial(last)){el.innerHTML=unlockHtml('Cloud Sync','pro');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)}
`} async function syncNow(){const b=document.getElementById('sync-btn');const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){if(e.status===401||e.status===402||e.status===403){const el=document.getElementById('sync-body');if(el)el.innerHTML=unlockHtml('Cloud Sync','pro');toast(e.status===402?'Cloud Sync requires an active Pro or Team entitlement — open Engraphis Cloud to upgrade or renew.':'Cloud Sync authorization is no longer active — reconnect in Engraphis Cloud.','err');return}toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent='Sync now'}if(s)s.textContent='Sync failed — try again.'}} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index 860414b..e8c6027 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -16,6 +16,7 @@ const hostedLicense = { async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) { const calls = []; + let syncLast = null; await page.route('**/api/**', async route => { const request = route.request(); @@ -53,9 +54,17 @@ async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) { cloud_url: 'https://cloud.engraphis.test/team', }; } else if (path === '/sync/status') { - body = { available: syncRunStatus !== null }; + body = { available: syncRunStatus !== null, last: syncLast }; } else if (path === '/sync/run' && syncRunStatus !== null) { status = syncRunStatus; + syncLast = { + at: Date.now() / 1000, + attempted: 1, + succeeded: 0, + exported: 0, + added: 0, + errors: [{ status: syncRunStatus }], + }; body = { detail: { error: syncRunStatus === 402 @@ -79,11 +88,14 @@ async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) { status = cloudStatus; body = { detail: { - error: cloudStatus === 401 + error: cloudStatus === 409 + ? 'managed cloud operation failed' + : cloudStatus === 401 ? 'Connect this installation to Engraphis Cloud.' : cloudStatus === 402 ? 'A hosted Pro or Team entitlement is required.' : 'This capability is available through Engraphis Cloud.', + ...(cloudStatus === 409 ? { code: 'consent_required' } : {}), }, }; } @@ -112,6 +124,11 @@ test('Cloud Sync denial returns an unlicensed installation to the hosted upgrade 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.getByRole('button', { name: 'Sync now' })).toHaveCount(0); expect(errors).toEqual([]); }); @@ -121,7 +138,7 @@ function recordBrowserErrors(page) { if (message.type() === 'error') { const location = message.location(); const expectedCloudDenial = /\/api\/(analytics|automation)/.test(location.url || '') - && /status of (401|402|501)/.test(message.text()); + && /status of (401|402|409|501)/.test(message.text()); const expectedSyncDenial = /\/api\/sync\/run/.test(location.url || '') && /status of (401|402|403)/.test(message.text()); if (expectedCloudDenial || expectedSyncDenial) return; @@ -245,3 +262,17 @@ for (const cloudStatus of [401, 402, 501]) { expect(errors).toEqual([]); }); } + +test('Analytics explains the local managed-compute consent step', 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(page.locator('#an-lock')).toHaveText('CLOUD'); + expect(errors).toEqual([]); +}); diff --git a/tests/test_cloud_session.py b/tests/test_cloud_session.py index 0c7821e..8d8be45 100644 --- a/tests/test_cloud_session.py +++ b/tests/test_cloud_session.py @@ -187,8 +187,9 @@ def test_refresh_lock_oserror_is_normalized(monkeypatch) -> None: lambda *args, **kwargs: (_ for _ in ()).throw(OSError("lock failure")), ) - with pytest.raises(cloud_session.CloudSessionError, match="lock.*unsafe"): + with pytest.raises(cloud_session.CloudSessionError, match="lock.*unsafe") as caught: cloud_session.access_for_workspace("ws", require_compute=False) + assert caught.value.status == 409 def test_refresh_http_error_response_is_closed(monkeypatch) -> None: @@ -219,6 +220,48 @@ def open(self, request, timeout): assert closed == [True] +@pytest.mark.parametrize("status", [401, 403]) +def test_refresh_authorization_error_preserves_status(monkeypatch, status) -> None: + error = urllib.error.HTTPError( + "https://control.example.test/v1/tokens/refresh", + status, + "denied", + {}, + BytesIO(b'{"detail":"private"}'), + ) + + class _Opener: + def open(self, request, timeout): + raise error + + monkeypatch.setattr( + cloud_session.urllib.request, "build_opener", lambda *handlers: _Opener() + ) + with pytest.raises(cloud_session.CloudSessionError) as caught: + cloud_session._post_refresh( + "https://control.example.test", "refresh", "ws", "member" + ) + + assert caught.value.status == status + + +def test_refresh_network_error_is_service_unavailable(monkeypatch) -> None: + class _Opener: + def open(self, request, timeout): + raise urllib.error.URLError("private network detail") + + monkeypatch.setattr( + cloud_session.urllib.request, "build_opener", lambda *handlers: _Opener() + ) + with pytest.raises(cloud_session.CloudSessionError) as caught: + cloud_session._post_refresh( + "https://control.example.test", "refresh", "ws", "member" + ) + + assert caught.value.status == 503 + assert "private network detail" not in str(caught.value) + + @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_dashboard_v2.py b/tests/test_dashboard_v2.py index fcbe199..2525ae7 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -319,3 +319,17 @@ def fail_with(exc): "transient": False, } assert secret not in repr(managed.value.detail) + + with pytest.raises(HTTPException) as consent: + v2_api._managed_call( + fail_with, + CloudFeatureError(secret, status=409, code="consent_required"), + ) + assert consent.value.status_code == 409 + assert consent.value.detail == { + "error": "managed cloud operation failed", + "managed_cloud": True, + "transient": False, + "code": "consent_required", + } + assert secret not in repr(consent.value.detail) diff --git a/tests/test_proactive_context.py b/tests/test_proactive_context.py index 7849377..2d5b300 100644 --- a/tests/test_proactive_context.py +++ b/tests/test_proactive_context.py @@ -61,6 +61,7 @@ def fail_recall(*args, **kwargs): assert out["workspace"] == "acme" assert "RuntimeError" in caplog.text + assert "acme" not in caplog.text assert "credential-like provider detail" not in caplog.text diff --git a/tests/test_provider_error_redaction.py b/tests/test_provider_error_redaction.py index a6343e3..8e1ab90 100644 --- a/tests/test_provider_error_redaction.py +++ b/tests/test_provider_error_redaction.py @@ -197,7 +197,7 @@ def fail(req, **_kwargs): monkeypatch.setattr(sync_relay, "_urlopen_no_redirect", fail) transport = RelayTransport( - "https://relay.example/%s" % url_marker, "workspace", license_key="safe-key" + "https://93.184.216.34/%s" % url_marker, "workspace", license_key="safe-key" ) with pytest.raises(RelayError) as caught: @@ -217,7 +217,7 @@ def test_relay_network_error_discards_base_url_and_reason(monkeypatch): lambda *_args, **_kwargs: (_ for _ in ()).throw(urllib.error.URLError(marker)), ) transport = RelayTransport( - "https://relay.example/%s" % marker, "workspace", license_key="safe-key" + "https://93.184.216.34/%s" % marker, "workspace", license_key="safe-key" ) with pytest.raises(RelayUnreachable) as caught: diff --git a/tests/test_relay_device_credentials.py b/tests/test_relay_device_credentials.py index f296bcb..7577759 100644 --- a/tests/test_relay_device_credentials.py +++ b/tests/test_relay_device_credentials.py @@ -3,6 +3,7 @@ import io import json +import socket import urllib.error import pytest @@ -14,6 +15,16 @@ TOKEN = "engr_access_" + "a" * 48 +def test_relay_url_dns_resolution_failure_fails_closed(monkeypatch): + def fail_resolution(*args, **kwargs): + raise socket.gaierror("private resolver detail") + + monkeypatch.setattr(socket, "getaddrinfo", fail_resolution) + + with pytest.raises(ValueError, match="could not be resolved"): + RelayTransport("https://unresolved.example", "workspace", access_token=TOKEN) + + def test_scoped_bearer_is_the_only_authorization_sent(monkeypatch): requests = [] @@ -75,7 +86,7 @@ def test_legacy_named_parameter_is_only_a_bearer_alias(): def test_saved_bearer_is_never_forwarded_to_another_relay(monkeypatch): relay_backend.save_sync_token( - TOKEN, relay_origin="https://relay-one.example" + TOKEN, relay_origin="https://1.1.1.1" ) monkeypatch.setattr( relay_backend, @@ -84,7 +95,7 @@ def test_saved_bearer_is_never_forwarded_to_another_relay(monkeypatch): ) with pytest.raises(RelayError, match="another relay") as caught: - RelayTransport("https://relay-two.example", "workspace") + RelayTransport("https://8.8.8.8", "workspace") assert caught.value.status == 409 diff --git a/tests/test_service_graph.py b/tests/test_service_graph.py index df5adcf..37d60f2 100644 --- a/tests/test_service_graph.py +++ b/tests/test_service_graph.py @@ -320,6 +320,7 @@ def fail_feed(*args, **kwargs): assert graph["nodes"] == [] assert "RuntimeError" in caplog.text + assert "mem_" not in caplog.text assert "credential-like graph detail" not in caplog.text diff --git a/tests/test_sync_cli.py b/tests/test_sync_cli.py index e6b6bb2..a26eebd 100644 --- a/tests/test_sync_cli.py +++ b/tests/test_sync_cli.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import socket import pytest @@ -21,7 +22,14 @@ # ── factory: relay is now a first-class transport ─────────────────────────────────── -def test_get_transport_relay_builds_relay_transport(): +def test_get_transport_relay_builds_relay_transport(monkeypatch): + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0)) + ], + ) t = get_transport("relay", base_url="https://sync.test/", workspace_id="acme", access_token="engr_ut_" + "x" * 32) assert isinstance(t, RelayTransport) diff --git a/tests/test_sync_dashboard.py b/tests/test_sync_dashboard.py index 23c3016..a3362db 100644 --- a/tests/test_sync_dashboard.py +++ b/tests/test_sync_dashboard.py @@ -164,6 +164,8 @@ def fake_get_transport(kind="folder", **kw): response = c.post("/api/sync/run", json={}) assert response.status_code == 200 summary = response.json()["summary"] + assert summary["attempted"] == 0 + assert summary["succeeded"] == 0 assert "demo" not in synced assert any( error["workspace"] == "demo" and "visibility is invalid" in error["error"] @@ -209,6 +211,14 @@ def fail_transport(*args, **kwargs): detail = response.json()["detail"] assert detail["error"] == "cloud relay synchronization failed" assert detail["upgrade_url"].startswith("https://api.engraphis.com/account") + last = c.get("/api/sync/status").json()["last"] + assert last["attempted"] == 1 + assert last["succeeded"] == 0 + assert last["errors"] == [{ + "workspace": "demo", + "error": "cloud relay synchronization failed", + "status": status, + }] def test_sync_run_does_not_promote_partial_success_with_zero_exports(monkeypatch, tmp_path): @@ -237,3 +247,27 @@ def mixed_transport(*args, **kwargs): "error": "cloud relay synchronization failed", "status": 403, }] + + +def test_sync_run_does_not_treat_cloud_session_outage_as_authorization_loss( + monkeypatch, tmp_path +): + from engraphis.cloud_session import CloudSessionError + + def fail_session(*args, **kwargs): + raise CloudSessionError("private network detail", status=503) + + monkeypatch.setattr("engraphis.cloud_session.access_for_workspace", fail_session) + with _client(monkeypatch, tmp_path, cloud=True) as c: + response = c.post("/api/sync/run", json={}) + + assert response.status_code == 200 + summary = response.json()["summary"] + assert summary["attempted"] == 1 + assert summary["succeeded"] == 0 + assert summary["errors"] == [{ + "workspace": "demo", + "error": "cloud session is temporarily unavailable", + "status": 503, + }] + assert "private network detail" not in repr(summary) From 990f54339f873e17400fc7b8dfc47a650075ca1a Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 04:50:50 -0400 Subject: [PATCH 04/11] fix: harden hosted recovery and transport --- CHANGELOG.md | 7 +- SECURITY.md | 4 +- engraphis/backends/sync_relay.py | 6 +- engraphis/cloud_features.py | 14 +++- engraphis/cloud_session.py | 4 +- engraphis/hosted_client.py | 111 +++++++++++++++++++++------ engraphis/static/dashboard.js | 7 +- tests/e2e/commercial.spec.js | 94 ++++++++++++++++++----- tests/test_cloud_features.py | 27 +++++++ tests/test_hosted_client.py | 69 +++++++++++++++++ tests/test_release_infrastructure.py | 2 +- 11 files changed, 285 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7312be..f579b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to Engraphis are documented here. Format loosely follows No unreleased changes. -## [1.0.1] - 2026-07-23 +## [1.0.1] - 2026-07-24 Public 1.0.1 client reliability release. @@ -17,8 +17,9 @@ Public 1.0.1 client reliability release. dashboard host and retired Railway relay URL without changing customer-provided relay URLs. - Default Pro and Team upgrade links now target the live authenticated account portal rather than the retired Team dashboard host. -- Hosted endpoint validation now fails closed when DNS resolution cannot establish that a - configured destination is public, preventing unresolved hosts from bypassing the SSRF guard. +- Hosted endpoint validation now fails closed unless DNS establishes a globally routable + destination, and credential-bearing HTTPS connections pin the vetted address while preserving + original-host TLS verification to prevent DNS-rebinding SSRF. - Hosted Automation and maintenance requests now use the selected workspace end to end rather than silently falling back to the first workspace. - The Automation tab has one proposal action, clear managed-upload disclosure, and explicit diff --git a/SECURITY.md b/SECURITY.md index 3053a46..4b617a9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -122,7 +122,9 @@ them back as `expected_head` / `expected_count` when independent evidence is req separately bounded, maximum-24-hour local workspace-write grace never extends the trial, subscription, Cloud Sync, managed compute, Team access, seats, or credentials. - **Remote URL validation:** hosted endpoints require HTTPS except explicit loopback use, - reject embedded credentials and redirects, and block private/reserved literal targets. + reject embedded credentials and redirects, require globally routable resolved addresses, + and pin credential-bearing TLS connections to a vetted address while verifying the + original hostname. - **Bounded I/O:** credential-bearing JSON responses are read through strict byte limits; malformed, oversized, or authoritative denial responses fail closed. - **HTTP response headers:** every entrypoint sends `X-Content-Type-Options: nosniff`, diff --git a/engraphis/backends/sync_relay.py b/engraphis/backends/sync_relay.py index 9e9d601..387dbb5 100644 --- a/engraphis/backends/sync_relay.py +++ b/engraphis/backends/sync_relay.py @@ -25,6 +25,7 @@ from typing import Iterable, List, Optional, Tuple from urllib.parse import quote, urlsplit, urlunsplit +from engraphis.hosted_client import build_pinned_https_opener from engraphis.private_state import UnsafeStateFile, atomic_private_text, read_private_text MAX_RELAY_BUNDLE_BYTES = 64 * 1024 * 1024 @@ -70,7 +71,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): def _urlopen_no_redirect(req, *, timeout: float): - return urllib.request.build_opener(_NoRedirectHandler()).open(req, timeout=timeout) + return build_pinned_https_opener(_NoRedirectHandler()).open(req, timeout=timeout) def _validated_sync_token(value: str) -> str: @@ -286,8 +287,7 @@ def _validated_base_url(value: str) -> str: ip_obj = ipaddress.ip_address(ip) except ValueError: continue # sockaddr wasn't a parseable IP; skip - if (ip_obj.is_private or ip_obj.is_reserved or ip_obj.is_link_local - or ip_obj.is_multicast or ip_obj.is_unspecified): + if not ip_obj.is_global: raise ValueError( "relay URL must not target private/reserved IP ranges") except (_socket.gaierror, OSError): diff --git a/engraphis/cloud_features.py b/engraphis/cloud_features.py index cf5f201..2235517 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.hosted_client import build_pinned_https_opener SNAPSHOT_SCHEMA = "engraphis-managed-snapshot/v1" MAX_RESPONSE_BYTES = 16 * 1024 * 1024 @@ -303,8 +304,15 @@ class CloudFeatureClient: def from_environment(cls, workspace_id: str) -> "CloudFeatureClient": try: access_token, organization_id, base_url = access_for_workspace(workspace_id) - except (CloudSessionError, ValueError) as exc: - raise CloudFeatureError(str(exc), status=503) from exc + except CloudSessionError as exc: + status = exc.status if 400 <= exc.status <= 599 else 503 + raise CloudFeatureError( + "The cloud session is unavailable.", status=status + ) from exc + except ValueError as exc: + raise CloudFeatureError( + "The cloud session configuration is invalid.", status=409 + ) from exc return cls(base_url=base_url, organization_id=organization_id, access_token=access_token) @@ -325,7 +333,7 @@ def _request(self, method: str, path: str, payload: Optional[dict] = None) -> di request = urllib.request.Request(self.base_url + path, data=encoded, headers=headers, method=method) try: - with urllib.request.build_opener(_NoRedirect()).open( + with build_pinned_https_opener(_NoRedirect()).open( request, timeout=self.timeout_seconds ) as response: raw = response.read(MAX_RESPONSE_BYTES + 1) diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index b505491..182ef26 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Optional, Tuple -from engraphis.hosted_client import validate_cloud_base_url +from engraphis.hosted_client import build_pinned_https_opener, validate_cloud_base_url from engraphis.private_state import ( UnsafeStateFile, atomic_private_text, @@ -228,7 +228,7 @@ def _post_refresh(control_url: str, refresh: str, workspace_id: str, method="POST", ) try: - with urllib.request.build_opener(_NoRedirect()).open( + with build_pinned_https_opener(_NoRedirect()).open( request, timeout=10.0 ) as response: raw = response.read(_MAX_RESPONSE_BYTES + 1) diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index 9db914b..efba485 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -6,9 +6,11 @@ """ from __future__ import annotations +import http.client import ipaddress import os import socket +import urllib.request from typing import Optional from urllib.parse import urlsplit, urlunsplit @@ -77,6 +79,91 @@ def _is_loopback_host(host: str) -> bool: return False +def _validated_addresses(host: str) -> list[str]: + """Resolve *host* once and return only connection-safe numeric addresses.""" + + try: + literal = ipaddress.ip_address(host) + except ValueError: + literal = None + if literal is not None: + if literal.is_loopback: + return [str(literal)] + if not literal.is_global: + raise ValueError("cloud service URL must not target private/reserved IP ranges") + return [str(literal)] + + try: + resolved = socket.getaddrinfo( + host, None, socket.AF_UNSPEC, socket.SOCK_STREAM + ) + except (socket.gaierror, OSError): + raise ValueError("cloud service URL could not be resolved") from None + + addresses = [] + loopback_name = _is_loopback_host(host) + for _, _, _, _, sockaddr in resolved: + try: + address = ipaddress.ip_address(sockaddr[0]) + except ValueError: + continue + if address.is_loopback and loopback_name: + addresses.append(str(address)) + continue + if not address.is_global: + 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") + return list(dict.fromkeys(addresses)) + + +class PinnedHTTPSConnection(http.client.HTTPSConnection): + """HTTPS connection pinned to a vetted address with original-host TLS checks.""" + + def __init__(self, host, *args, **kwargs): + super().__init__(host, *args, **kwargs) + self._tls_server_hostname = self.host + + 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. + self._tls_server_hostname = host + pinned = _validated_addresses(host)[0] + return super().set_tunnel(pinned, port=port, headers=headers) + + def connect(self): + target = self.host + if self._tunnel_host is None: + target = _validated_addresses(self.host)[0] + self.sock = self._create_connection( + (target, self.port), self.timeout, self.source_address + ) + if self._tunnel_host: + self._tunnel() + self.sock = self._context.wrap_socket( + self.sock, server_hostname=self._tls_server_hostname + ) + + +class PinnedHTTPSHandler(urllib.request.HTTPSHandler): + """urllib handler using pinned connections for every HTTPS request.""" + + def https_open(self, req): + return self.do_open( + PinnedHTTPSConnection, + req, + context=self._context, + check_hostname=self._check_hostname, + ) + + +def build_pinned_https_opener(*handlers): + """Build an opener that prevents DNS rebinding on credential-bearing HTTPS.""" + + return urllib.request.build_opener(*handlers, PinnedHTTPSHandler()) + + def validate_cloud_base_url(value: str) -> str: """Validate a cloud endpoint without reflecting its potentially sensitive value.""" @@ -98,27 +185,5 @@ def validate_cloud_base_url(value: str) -> str: if scheme != "https" and not _is_loopback_host(hostname): raise ValueError("cloud service URL must use HTTPS unless it targets loopback") if not _is_loopback_host(hostname): - try: - addresses = socket.getaddrinfo( - hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM - ) - for _, _, _, _, sockaddr in addresses: - try: - address = ipaddress.ip_address(sockaddr[0]) - except ValueError: - continue - if ( - address.is_private - or address.is_reserved - or address.is_link_local - or address.is_multicast - or address.is_unspecified - ): - raise ValueError( - "cloud service URL must not target private/reserved IP ranges" - ) - except (socket.gaierror, OSError): - raise ValueError( - "cloud service URL could not be resolved" - ) from None + _validated_addresses(hostname) return urlunsplit((scheme, parts.netloc, parts.path.rstrip("/"), "", "")) diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index d6196e2..0856b67 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -182,7 +182,7 @@ async function loadAnalytics(){const el=document.getElementById('analytics-body' /* ── hosted automation policy (Pro / Team) ── */ async function loadAutomation(){const el=document.getElementById('automation-body'),lock=document.getElementById('au-lock'),ws='?workspace='+encodeURIComponent(WS||'');el.innerHTML='
';try{const p=await api('/automation'+ws);setPlanPill(lock,(LIC&&LIC.is_trial)?'TRIAL':'CLOUD','pill pill-accent');const last=p.last_run?fmtRel(p.last_run):'never',dream=p.dream_enabled!=null?p.dream_enabled:p.dream;el.innerHTML=`
Hosted maintenance policy
The cloud returns reviewable proposals. Pinned memories remain protected.
Cloud worker status
Status${p.enabled?'ENABLED':'OFF'}
Last run${esc(last)}
After explicit opt-in with ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1, requesting managed work uploads the selected workspace’s normal and sensitive memory content, excluding secret and session-scoped rows, capped at 16 MiB, over HTTPS without end-to-end encryption. Results are proposals and never automatically write the local database.
`}catch(e){if(e.status===401||e.status===402||e.status===501){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}} -async function saveAutomation(){const body={enabled:document.getElementById('au-enabled').checked,cadence_hours:Number(document.getElementById('au-cadence').value)||24,consolidate:document.getElementById('au-consolidate').checked,min_cluster:Number(document.getElementById('au-mincluster').value)||3,archive_below:Number(document.getElementById('au-archive').value)||0.05,dream_enabled:document.getElementById('au-dream').checked,dream_min_new:Number(document.getElementById('au-dream-min').value)||20,dream_idle_minutes:Number(document.getElementById('au-dream-idle').value)};try{await api('/automation?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Hosted policy saved','ok');loadAutomation()}catch(e){toast((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message,'err')}} +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')}} const runMaintenanceBase=runMaintenance; @@ -442,8 +442,9 @@ async function testLlm(){const r=document.getElementById('llm-test-result');if(r 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')}} 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 renderSync(d){const el=document.getElementById('sync-body');if(!el)return;d=d||{};const last=d.last;if(!d.available||syncTotalAuthorizationDenial(last)){el.innerHTML=unlockHtml('Cloud Sync','pro');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)}
`} -async function syncNow(){const b=document.getElementById('sync-btn');const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){if(e.status===401||e.status===402||e.status===403){const el=document.getElementById('sync-body');if(el)el.innerHTML=unlockHtml('Cloud Sync','pro');toast(e.status===402?'Cloud Sync requires an active Pro or Team entitlement — open Engraphis Cloud to upgrade or renew.':'Cloud Sync authorization is no longer active — reconnect in Engraphis Cloud.','err');return}toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent='Sync now'}if(s)s.textContent='Sync failed — try again.'}} +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)}
`} +async function syncNow(){const b=document.getElementById('sync-btn')||document.getElementById('sync-retry-btn');const original=b&&b.textContent;const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){if(e.status===401||e.status===402||e.status===403){const el=document.getElementById('sync-body');if(el)el.innerHTML=syncRecoveryHtml();toast(e.status===402?'Cloud Sync requires an active Pro or Team entitlement — open Engraphis Cloud to upgrade or renew.':'Cloud Sync authorization is no longer active — reconnect in Engraphis Cloud.','err');return}toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent=original||'Sync now'}if(s)s.textContent='Sync failed — try again.'}} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ let GRAPH=null, FG=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}; diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index e8c6027..cecdf4d 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -14,9 +14,16 @@ const hostedLicense = { trial: { used: false, trial_days: 3 }, }; -async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) { +async function mockLocalClient( + page, + cloudStatus = 402, + syncRunStatus = null, + automationPostStatus = null, +) { const calls = []; let syncLast = null; + let activeSyncRunStatus = syncRunStatus; + calls.setSyncRunStatus = status => { activeSyncRunStatus = status; }; await page.route('**/api/**', async route => { const request = route.request(); @@ -54,25 +61,37 @@ async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) { cloud_url: 'https://cloud.engraphis.test/team', }; } else if (path === '/sync/status') { - body = { available: syncRunStatus !== null, last: syncLast }; - } else if (path === '/sync/run' && syncRunStatus !== null) { - status = syncRunStatus; - syncLast = { - at: Date.now() / 1000, - attempted: 1, - succeeded: 0, - exported: 0, - added: 0, - errors: [{ status: syncRunStatus }], - }; - body = { - detail: { - error: syncRunStatus === 402 - ? 'Cloud Sync entitlement is inactive (upgrade or renew required)' - : 'cloud relay synchronization failed', - upgrade_url: 'https://cloud.engraphis.test/pro', - }, - }; + body = { available: activeSyncRunStatus !== null, last: syncLast }; + } else if (path === '/sync/run' && activeSyncRunStatus !== null) { + status = activeSyncRunStatus; + if (status === 200) { + syncLast = { + at: Date.now() / 1000, + attempted: 1, + succeeded: 1, + exported: 0, + added: 0, + errors: [], + }; + body = { ok: true, summary: syncLast }; + } else { + syncLast = { + at: Date.now() / 1000, + attempted: 1, + succeeded: 0, + exported: 0, + added: 0, + errors: [{ status: activeSyncRunStatus }], + }; + body = { + detail: { + error: activeSyncRunStatus === 402 + ? 'Cloud Sync entitlement is inactive (upgrade or renew required)' + : 'cloud relay synchronization failed', + upgrade_url: 'https://cloud.engraphis.test/pro', + }, + }; + } } else if (path === '/llm/status') { body = { configured: false, @@ -84,6 +103,18 @@ async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) { default_models: { openai: 'gpt-4o-mini' }, env_snippet: '', }; + } else if ( + path === '/automation' + && request.method() === 'POST' + && automationPostStatus !== null + ) { + status = automationPostStatus; + body = { + detail: { + error: 'managed cloud operation failed', + code: 'consent_required', + }, + }; } else if (path === '/analytics' || path === '/automation') { status = cloudStatus; body = { @@ -128,7 +159,13 @@ test('Cloud Sync denial returns an unlicensed installation to the hosted upgrade await page.reload(); await openView(page, 'settings'); await expect(page.locator('#sync-body')).toContainText('Cloud Sync runs in Engraphis Pro Cloud'); - await expect(page.getByRole('button', { name: 'Sync now' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Try Cloud Sync again' })).toBeVisible(); + + calls.setSyncRunStatus(200); + await page.getByRole('button', { name: 'Try Cloud Sync again' }).click(); + await expect(page.locator('#sync-body')).toContainText('Hosted relay'); + await expect(page.locator('#sync-body')).toContainText('CONNECTED'); + await expect(page.getByRole('button', { name: 'Sync now' })).toBeVisible(); expect(errors).toEqual([]); }); @@ -276,3 +313,18 @@ test('Analytics explains the local managed-compute consent step', async ({ page await expect(page.locator('#an-lock')).toHaveText('CLOUD'); expect(errors).toEqual([]); }); + +test('Automation policy save explains the managed-compute consent step', async ({ page }) => { + const errors = recordBrowserErrors(page); + await mockLocalClient(page, 200, null, 409); + await page.goto('/'); + await openView(page, 'automation'); + + 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'); + expect(errors).toEqual([]); +}); diff --git a/tests/test_cloud_features.py b/tests/test_cloud_features.py index f3b00fd..3a84ffd 100644 --- a/tests/test_cloud_features.py +++ b/tests/test_cloud_features.py @@ -53,6 +53,33 @@ def test_snapshot_accepts_environment_opt_in(monkeypatch) -> None: assert snapshot["managed_compute_consent"] is True +@pytest.mark.parametrize("status", [401, 403, 409, 503]) +def test_cloud_client_preserves_cloud_session_status(monkeypatch, status) -> None: + def fail_access(*args, **kwargs): + raise cloud_features.CloudSessionError("private session detail", status=status) + + monkeypatch.setattr(cloud_features, "access_for_workspace", fail_access) + + with pytest.raises(CloudFeatureError) as caught: + CloudFeatureClient.from_environment("ws_test") + + assert caught.value.status == status + assert "private session detail" not in str(caught.value) + + +def test_cloud_client_reports_invalid_session_configuration_as_conflict(monkeypatch) -> None: + def fail_access(*args, **kwargs): + raise ValueError("private configuration detail") + + monkeypatch.setattr(cloud_features, "access_for_workspace", fail_access) + + with pytest.raises(CloudFeatureError) as caught: + CloudFeatureClient.from_environment("ws_test") + + assert caught.value.status == 409 + assert "private configuration detail" not in str(caught.value) + + def test_explicit_false_consent_cannot_be_overridden_by_environment(monkeypatch) -> None: monkeypatch.setenv("ENGRAPHIS_MANAGED_COMPUTE_CONSENT", "1") diff --git a/tests/test_hosted_client.py b/tests/test_hosted_client.py index 1e3dbaf..95bc178 100644 --- a/tests/test_hosted_client.py +++ b/tests/test_hosted_client.py @@ -1,5 +1,7 @@ from __future__ import annotations +import socket + import pytest from engraphis import hosted_client, licensing @@ -55,6 +57,73 @@ def test_cloud_url_validation_rejects_unresolvable_hosts(monkeypatch): hosted_client.validate_cloud_base_url("https://unresolvable.example/") +@pytest.mark.parametrize("address", ["10.0.0.1", "100.64.0.1", "169.254.169.254"]) +def test_cloud_url_validation_rejects_every_non_global_address(address): + with pytest.raises(ValueError, match="private/reserved"): + hosted_client.validate_cloud_base_url("https://%s" % address) + + +def test_pinned_https_connection_uses_vetted_address_and_original_tls_name(monkeypatch): + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)) + ], + ) + connected = [] + wrapped = [] + + class _Context: + def wrap_socket(self, sock, *, server_hostname): + wrapped.append(server_hostname) + return sock + + connection = hosted_client.PinnedHTTPSConnection("cloud.example") + connection._context = _Context() + connection._create_connection = ( + lambda address, timeout, source: connected.append(address) or object() + ) + + connection.connect() + + assert connected == [("93.184.216.34", 443)] + assert wrapped == ["cloud.example"] + + +def test_pinned_https_connection_rejects_rebound_private_address(monkeypatch): + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443)) + ], + ) + connection = hosted_client.PinnedHTTPSConnection("cloud.example") + connection._create_connection = lambda *args: pytest.fail( + "private rebound address reached the socket" + ) + + with pytest.raises(ValueError, match="private/reserved"): + connection.connect() + + +def test_pinned_https_proxy_tunnel_uses_vetted_ip_and_original_tls_name(monkeypatch): + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)) + ], + ) + connection = hosted_client.PinnedHTTPSConnection("proxy.example") + + connection.set_tunnel("cloud.example", 443) + + assert connection._tunnel_host == "93.184.216.34" + assert connection._tls_server_hostname == "cloud.example" + + def test_licensing_facade_exposes_no_local_entitlement_engine(): assert licensing.TRIAL_DAYS == 3 assert licensing.production_warnings() == [] diff --git a/tests/test_release_infrastructure.py b/tests/test_release_infrastructure.py index 2d2756e..b307be9 100644 --- a/tests/test_release_infrastructure.py +++ b/tests/test_release_infrastructure.py @@ -194,7 +194,7 @@ def test_public_capability_and_support_docs_match_the_shipped_tree(): changelog = _text("CHANGELOG.md") assert "ForceGraph + D3 renderer" in changelog - assert "## [1.0.1] - 2026-07-23" in changelog + assert "## [1.0.1] - 2026-07-24" in changelog assert "Public 1.0.1 client reliability release." in changelog assert "## [1.0.0] - 2026-07-23" in changelog assert "## [1.0.0] - 2026-07-19" not in changelog From 02c7bf5d6dd81996cca548daaadcef8c5baf5f50 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 10:41:40 -0400 Subject: [PATCH 05/11] fix: harden unconfigured cloud recovery --- engraphis/backends/sync_relay.py | 2 +- engraphis/cloud_session.py | 12 +++++++++- engraphis/hosted_client.py | 21 ++++++++++++------ tests/test_cloud_session.py | 19 ++++++++++++++++ tests/test_dashboard_v2.py | 22 ++++++++++++++++++ tests/test_hosted_client.py | 38 ++++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 9 deletions(-) diff --git a/engraphis/backends/sync_relay.py b/engraphis/backends/sync_relay.py index 387dbb5..a611bfa 100644 --- a/engraphis/backends/sync_relay.py +++ b/engraphis/backends/sync_relay.py @@ -248,7 +248,7 @@ def has_sync_token() -> bool: def _is_loopback_host(host: str) -> bool: - if host == "localhost" or host.endswith(".localhost"): + if host == "localhost": return True try: return ipaddress.ip_address(host).is_loopback diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index 182ef26..caaf13e 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -69,8 +69,8 @@ def _refresh_lock(): """ with _REFRESH_THREAD_LOCK: lock_path = _session_path().with_name(".cloud_session.refresh.lock") - lock_path.parent.mkdir(parents=True, exist_ok=True) try: + lock_path.parent.mkdir(parents=True, exist_ok=True) expected = private_file_stat(lock_path, allow_missing=True) flags = os.O_RDWR | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) if expected is None: @@ -290,6 +290,16 @@ def access_for_workspace( compute_url = validate_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 + # installation. In particular, a stale home-directory mount must produce the + # normal structured "connect first" response rather than an unhandled filesystem + # error. The authoritative session record is still loaded again under the lock + # below before any refresh credential is used. + if not configured(require_compute=require_compute): + raise CloudSessionError( + "Connect this installation to Engraphis Cloud first.", status=401 + ) + with _refresh_lock(): # Load only after acquiring both locks. The saved rotation is the current # single-use credential; reading it before the lock lets two workers spend the diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index efba485..f9dda39 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -71,7 +71,7 @@ def upgrade_url(plan: Optional[str] = None) -> str: def _is_loopback_host(host: str) -> bool: - if host == "localhost" or host.endswith(".localhost"): + if host == "localhost": return True try: return ipaddress.ip_address(host).is_loopback @@ -133,12 +133,19 @@ def set_tunnel(self, host, port=None, headers=None): return super().set_tunnel(pinned, port=port, headers=headers) def connect(self): - target = self.host - if self._tunnel_host is None: - target = _validated_addresses(self.host)[0] - self.sock = self._create_connection( - (target, self.port), self.timeout, self.source_address - ) + targets = [self.host] if self._tunnel_host is not None else _validated_addresses(self.host) + last_error = None + for target in targets: + try: + self.sock = self._create_connection( + (target, self.port), self.timeout, self.source_address + ) + break + except OSError as exc: + last_error = exc + else: + assert last_error is not None + raise last_error if self._tunnel_host: self._tunnel() self.sock = self._context.wrap_socket( diff --git a/tests/test_cloud_session.py b/tests/test_cloud_session.py index 8d8be45..ab93583 100644 --- a/tests/test_cloud_session.py +++ b/tests/test_cloud_session.py @@ -192,6 +192,25 @@ def test_refresh_lock_oserror_is_normalized(monkeypatch) -> None: assert caught.value.status == 409 +def test_unconfigured_client_does_not_create_the_state_directory(monkeypatch) -> None: + monkeypatch.delenv("ENGRAPHIS_CLOUD_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_COMPUTE_URL", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", raising=False) + monkeypatch.delenv("ENGRAPHIS_CLOUD_CONTROL_URL", raising=False) + monkeypatch.setattr(cloud_session, "_load", lambda: {}) + monkeypatch.setattr( + cloud_session.Path, + "mkdir", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("must not lock")), + ) + + with pytest.raises(cloud_session.CloudSessionError, match="Connect this installation") as caught: + cloud_session.access_for_workspace("ws") + + assert caught.value.status == 401 + + def test_refresh_http_error_response_is_closed(monkeypatch) -> None: error = urllib.error.HTTPError( "https://control.example.test/v1/tokens/refresh", diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index 2525ae7..f66c1c0 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -103,6 +103,28 @@ def test_analytics_route_delegates_to_managed_compute(monkeypatch, tmp_path): assert response.json()["generation"] == 4 +def test_unconnected_automation_returns_a_structured_auth_error(monkeypatch, tmp_path): + for name in ( + "ENGRAPHIS_CLOUD_ACCESS_TOKEN", + "ENGRAPHIS_CLOUD_ORGANIZATION_ID", + "ENGRAPHIS_CLOUD_COMPUTE_URL", + "ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", + "ENGRAPHIS_CLOUD_CONTROL_URL", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("ENGRAPHIS_STATE_DIR", str(tmp_path / "unconnected-state")) + + with _client(monkeypatch, tmp_path) as client: + response = client.get("/api/automation?workspace=demo") + + assert response.status_code == 401 + assert response.json()["detail"] == { + "error": "managed cloud operation failed", + "managed_cloud": True, + "transient": False, + } + + def test_hosted_automation_accepts_the_cloud_policy_field(monkeypatch, tmp_path): saved = {} diff --git a/tests/test_hosted_client.py b/tests/test_hosted_client.py index 95bc178..0770c86 100644 --- a/tests/test_hosted_client.py +++ b/tests/test_hosted_client.py @@ -63,6 +63,11 @@ def test_cloud_url_validation_rejects_every_non_global_address(address): hosted_client.validate_cloud_base_url("https://%s" % address) +def test_cloud_url_validation_does_not_treat_arbitrary_localhost_subdomains_as_loopback(): + with pytest.raises(ValueError, match="must use HTTPS"): + hosted_client.validate_cloud_base_url("http://attacker.localhost") + + def test_pinned_https_connection_uses_vetted_address_and_original_tls_name(monkeypatch): monkeypatch.setattr( hosted_client.socket, @@ -91,6 +96,39 @@ def wrap_socket(self, sock, *, server_hostname): assert wrapped == ["cloud.example"] +def test_pinned_https_connection_falls_back_to_next_vetted_address(monkeypatch): + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:2800:220:1:248:1893:25c8:1946", 443)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)), + ], + ) + connected = [] + + class _Context: + def wrap_socket(self, sock, *, server_hostname): + return sock + + def connect(address, timeout, source): + connected.append(address) + if ":" in address[0]: + raise OSError("IPv6 route is unavailable") + return object() + + connection = hosted_client.PinnedHTTPSConnection("cloud.example") + connection._context = _Context() + connection._create_connection = connect + + connection.connect() + + assert connected == [ + ("2606:2800:220:1:248:1893:25c8:1946", 443), + ("93.184.216.34", 443), + ] + + def test_pinned_https_connection_rejects_rebound_private_address(monkeypatch): monkeypatch.setattr( hosted_client.socket, From eaf68e97f6f107eb8a66a742796208a3688459cd Mon Sep 17 00:00:00 2001 From: Jaixii Date: Fri, 24 Jul 2026 19:03:49 -0400 Subject: [PATCH 06/11] fix(cloud): restore hosted HTTPS on Python 3.12+ and proxied explicit ports PinnedHTTPSHandler.https_open read self._check_hostname unconditionally. Python 3.12 folded check_hostname into the SSL context: HTTPSHandler no longer keeps the attribute and HTTPSConnection no longer accepts the keyword, so every request raised AttributeError. build_pinned_https_opener replaces the default handler, so this broke Cloud Sync, session refresh, and managed compute on 3.12/3.13 -- all declared-supported interpreters. Forward the keyword only where it still exists. PinnedHTTPSConnection.set_tunnel validated the raw netloc urllib hands it, so a hosted endpoint with an explicit port behind an HTTPS proxy resolved "cloud.example:8443" verbatim, failed, and set an invalid SNI name. Split host:port with _get_hostport first, the way http.client does. Restore the legacy relay-token fallback in /sync/run: an unreadable cloud session raised immediately, so installations still authenticating with a legacy sync token went from working to a hard 409. Also raise a real OSError instead of `raise None` when the connect loop ends with no recorded error under python -O, where the assert is stripped. Nothing exercised https_open before, which is why CI stayed green on 3.12; both new tests fail against the previous code and pass against this one. --- engraphis/hosted_client.py | 28 ++++++++++++++-------- engraphis/routes/v2_api.py | 15 ++++++++---- tests/test_hosted_client.py | 48 +++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index f9dda39..757bbc4 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -128,9 +128,13 @@ def __init__(self, host, *args, **kwargs): 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. - self._tls_server_hostname = host - pinned = _validated_addresses(host)[0] - return super().set_tunnel(pinned, port=port, headers=headers) + # urllib passes ``Request.host`` straight through, and that netloc may carry an + # explicit port, so split it the way http.client does before resolving or pinning + # the SNI name -- otherwise ``cloud.example:8443`` is looked up verbatim and fails. + hostname, tunnel_port = self._get_hostport(host, port) + self._tls_server_hostname = hostname + pinned = _validated_addresses(hostname)[0] + return super().set_tunnel(pinned, port=tunnel_port, headers=headers) def connect(self): targets = [self.host] if self._tunnel_host is not None else _validated_addresses(self.host) @@ -144,7 +148,8 @@ def connect(self): except OSError as exc: last_error = exc else: - assert last_error is not None + if last_error is None: + raise OSError("cloud service URL has no connectable address") raise last_error if self._tunnel_host: self._tunnel() @@ -157,12 +162,15 @@ class PinnedHTTPSHandler(urllib.request.HTTPSHandler): """urllib handler using pinned connections for every HTTPS request.""" def https_open(self, req): - return self.do_open( - PinnedHTTPSConnection, - req, - context=self._context, - check_hostname=self._check_hostname, - ) + # Python 3.12 folded ``check_hostname`` into the SSL context: ``HTTPSHandler`` no + # longer keeps ``_check_hostname`` and ``HTTPSConnection`` no longer accepts the + # keyword. Forward it only on the interpreters that still track it, so a single + # code path works from 3.9 through 3.13. + kwargs = {"context": self._context} + check_hostname = getattr(self, "_check_hostname", None) + if check_hostname is not None: + kwargs["check_hostname"] = check_hostname + return self.do_open(PinnedHTTPSConnection, req, **kwargs) def build_pinned_https_opener(*handlers): diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index a648ea9..7adf73e 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -1918,11 +1918,16 @@ async def sync_run(): try: has_cloud_session = configured(require_compute=False) except CloudSessionError as exc: - status = exc.status if 400 <= exc.status <= 599 else 503 - raise HTTPException(status_code=status, detail={ - "error": "The saved cloud session is unavailable.", - "upgrade_url": licensing.upgrade_url(), - }) from None + # An unreadable cloud session must not strand installations that still authenticate + # to the relay with a legacy sync token -- surface it only when there is no other + # way in, otherwise fall through to the token path as before. + if not has_token: + status = exc.status if 400 <= exc.status <= 599 else 503 + raise HTTPException(status_code=status, detail={ + "error": "The saved cloud session is unavailable.", + "upgrade_url": licensing.upgrade_url(), + }) from None + has_cloud_session = False if not has_token and not has_cloud_session: raise HTTPException(status_code=402, detail={ "error": "Connect this installation to Engraphis Cloud before syncing.", diff --git a/tests/test_hosted_client.py b/tests/test_hosted_client.py index 0770c86..34c2654 100644 --- a/tests/test_hosted_client.py +++ b/tests/test_hosted_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import socket +import urllib.request import pytest @@ -162,6 +163,53 @@ def test_pinned_https_proxy_tunnel_uses_vetted_ip_and_original_tls_name(monkeypa assert connection._tls_server_hostname == "cloud.example" +def test_pinned_https_proxy_tunnel_accepts_an_explicit_port_in_the_host(monkeypatch): + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 8443)) + ], + ) + connection = hosted_client.PinnedHTTPSConnection("proxy.example") + + # urllib hands the raw netloc to set_tunnel, port included. + connection.set_tunnel("cloud.example:8443") + + assert connection._tunnel_host == "93.184.216.34" + assert connection._tunnel_port == 8443 + assert connection._tls_server_hostname == "cloud.example" + + +def test_pinned_https_handler_forwards_only_supported_connection_arguments(monkeypatch): + """Regression: 3.12 dropped ``HTTPSHandler._check_hostname`` and the matching kwarg. + + Reading it unconditionally raised ``AttributeError`` on 3.12+, breaking every hosted + HTTPS request, and forwarding it would have raised ``TypeError`` inside + ``http.client.HTTPSConnection``. Nothing exercised ``https_open`` before this test. + """ + + handler = hosted_client.PinnedHTTPSHandler() + captured = {} + + def _capture(http_class, req, **kwargs): + captured["http_class"] = http_class + captured["kwargs"] = kwargs + return "response" + + monkeypatch.setattr(handler, "do_open", _capture) + + result = handler.https_open(urllib.request.Request("https://cloud.example/resource")) + + assert result == "response" + assert captured["http_class"] is hosted_client.PinnedHTTPSConnection + assert captured["kwargs"]["context"] is handler._context + assert set(captured["kwargs"]) <= {"context", "check_hostname"} + # The forwarded arguments must actually be accepted by the connection class on this + # interpreter; constructing it does not open a socket. + hosted_client.PinnedHTTPSConnection("cloud.example", **captured["kwargs"]) + + def test_licensing_facade_exposes_no_local_entitlement_engine(): assert licensing.TRIAL_DAYS == 3 assert licensing.production_warnings() == [] From 713e2dbc7a5bcaa05c41a590de5759d728d48972 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Fri, 24 Jul 2026 19:20:19 -0400 Subject: [PATCH 07/11] fix(cloud): retry every vetted tunnel address and normalize state-mount I/O Two review findings from the automated re-review of eaf68e9. set_tunnel pinned only the first vetted address for CONNECT while the direct path retried all of them. A dual-stack endpoint whose first address is unreachable from the proxy therefore failed every hosted request with no fallback. Keep the whole vetted list and retry it, redialing the proxy each attempt because a failed CONNECT leaves the socket unusable. UnicodeError is caught alongside OSError: http.client idna-encodes the tunnel host, which fails on a bare IPv6 literal -- a reason to try the next address rather than abort. access_for_workspace's preflight calls configured() -> _load() before _refresh_lock() can normalize filesystem failures, and _load caught UnsafeStateFile but not plain OSError. An unreadable or stale state mount escaped as an unhandled exception and became an opaque 500, contrary to the behaviour the surrounding comment claims. Convert those to CloudSessionError at the source so every caller benefits, and correct the comment, which described a "connect first" response the code never produced for I/O failures. --- engraphis/cloud_session.py | 15 +++++--- engraphis/hosted_client.py | 57 +++++++++++++++++++++-------- tests/test_cloud_session.py | 18 ++++++++++ tests/test_hosted_client.py | 72 +++++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 19 deletions(-) diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py index caaf13e..f3f97dd 100644 --- a/engraphis/cloud_session.py +++ b/engraphis/cloud_session.py @@ -167,6 +167,13 @@ def _load() -> dict: raise CloudSessionError( "The saved cloud session has unsafe filesystem permissions.", status=409 ) from exc + except (OSError, RuntimeError) as exc: + # An unreadable or stale state mount (and Path.home() failing outright) must + # surface as a structured, retryable cloud error rather than escaping as an + # unhandled filesystem exception and becoming an opaque 500. + raise CloudSessionError( + "The saved cloud session is temporarily unreadable." + ) from exc if not raw: return {} try: @@ -291,10 +298,10 @@ def access_for_workspace( return direct_token, direct_org, compute_url # Do not create the owner-only state directory merely to report an unconnected - # installation. In particular, a stale home-directory mount must produce the - # normal structured "connect first" response rather than an unhandled filesystem - # error. The authoritative session record is still loaded again under the lock - # below before any refresh credential is used. + # installation. An absent session yields the normal structured "connect first" + # response; a stale home-directory mount yields a structured, retryable error from + # ``_load`` rather than an unhandled filesystem exception. The authoritative session + # record is still loaded again under the lock below before any credential is used. if not configured(require_compute=require_compute): raise CloudSessionError( "Connect this installation to Engraphis Cloud first.", status=401 diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index 757bbc4..b1de801 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -124,6 +124,7 @@ class PinnedHTTPSConnection(http.client.HTTPSConnection): def __init__(self, host, *args, **kwargs): super().__init__(host, *args, **kwargs) self._tls_server_hostname = self.host + self._tunnel_targets = [] def set_tunnel(self, host, port=None, headers=None): # Make a configured proxy CONNECT to the vetted numeric target. TLS still @@ -133,29 +134,55 @@ def set_tunnel(self, host, port=None, headers=None): # the SNI name -- otherwise ``cloud.example:8443`` is looked up verbatim and fails. hostname, tunnel_port = self._get_hostport(host, port) self._tls_server_hostname = hostname - pinned = _validated_addresses(hostname)[0] - return super().set_tunnel(pinned, port=tunnel_port, headers=headers) + self._tunnel_targets = _validated_addresses(hostname) + return super().set_tunnel(self._tunnel_targets[0], port=tunnel_port, headers=headers) def connect(self): - targets = [self.host] if self._tunnel_host is not None else _validated_addresses(self.host) + if self._tunnel_host is not None: + self._connect_through_proxy() + else: + self.sock = self._connect_directly() + self.sock = self._context.wrap_socket( + self.sock, server_hostname=self._tls_server_hostname + ) + + def _connect_directly(self): last_error = None - for target in targets: + for target in _validated_addresses(self.host): try: - self.sock = self._create_connection( + return self._create_connection( (target, self.port), self.timeout, self.source_address ) - break except OSError as exc: last_error = exc - else: - if last_error is None: - raise OSError("cloud service URL has no connectable address") - raise last_error - if self._tunnel_host: - self._tunnel() - self.sock = self._context.wrap_socket( - self.sock, server_hostname=self._tls_server_hostname - ) + if last_error is None: + raise OSError("cloud service URL has no connectable address") + raise last_error + + def _connect_through_proxy(self): + # Every vetted address is an equally valid CONNECT target, so a dual-stack + # endpoint whose first address is unreachable *from the proxy* must fall through + # 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 + for target in self._tunnel_targets or [self._tunnel_host]: + self._tunnel_host = target + try: + self.sock = self._create_connection( + (self.host, self.port), self.timeout, self.source_address + ) + self._tunnel() + return + except (OSError, UnicodeError) as exc: + # UnicodeError: http.client idna-encodes the tunnel host, which fails on + # a bare IPv6 literal -- a reason to try the next address, not to abort. + last_error = exc + if self.sock is not None: + self.sock.close() + self.sock = None + if last_error is None: + raise OSError("cloud service URL has no connectable address") + raise last_error class PinnedHTTPSHandler(urllib.request.HTTPSHandler): diff --git a/tests/test_cloud_session.py b/tests/test_cloud_session.py index ab93583..533604b 100644 --- a/tests/test_cloud_session.py +++ b/tests/test_cloud_session.py @@ -192,6 +192,24 @@ def test_refresh_lock_oserror_is_normalized(monkeypatch) -> None: assert caught.value.status == 409 +def test_unreadable_state_mount_is_normalized_before_the_refresh_lock(monkeypatch) -> None: + """A stale/unreadable state mount must not escape as a raw filesystem error. + + ``access_for_workspace`` calls ``configured()`` -> ``_load()`` in its preflight, + before ``_refresh_lock()`` can normalize I/O failures, so an OSError there used to + reach the route layer as an opaque 500 instead of a structured cloud error. + """ + + monkeypatch.setattr( + cloud_session, "read_private_text", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("stale NFS handle")), + ) + + with pytest.raises(cloud_session.CloudSessionError, match="temporarily unreadable") as caught: + cloud_session.access_for_workspace("ws", require_compute=False) + assert 400 <= caught.value.status <= 599 + + def test_unconfigured_client_does_not_create_the_state_directory(monkeypatch) -> None: monkeypatch.delenv("ENGRAPHIS_CLOUD_ACCESS_TOKEN", raising=False) monkeypatch.delenv("ENGRAPHIS_CLOUD_ORGANIZATION_ID", raising=False) diff --git a/tests/test_hosted_client.py b/tests/test_hosted_client.py index 34c2654..0ed1742 100644 --- a/tests/test_hosted_client.py +++ b/tests/test_hosted_client.py @@ -181,6 +181,78 @@ def test_pinned_https_proxy_tunnel_accepts_an_explicit_port_in_the_host(monkeypa assert connection._tls_server_hostname == "cloud.example" +def test_pinned_https_proxy_tunnel_retries_every_vetted_address(monkeypatch): + """A CONNECT that fails on the first address must fall through to the rest. + + Otherwise a dual-stack endpoint whose IPv6 address is unreachable from the proxy + makes every hosted request fail, unlike the direct path which already retries. + """ + + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:2800:220:1:248:1893:25c8:1946", 443)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)), + ], + ) + tunnelled = [] + + class _Sock: + def close(self): + pass + + class _Context: + def wrap_socket(self, sock, *, server_hostname): + return sock + + connection = hosted_client.PinnedHTTPSConnection("proxy.example", 3128) + connection._context = _Context() + connection._create_connection = lambda address, timeout, source: _Sock() + + def fake_tunnel(): + tunnelled.append(connection._tunnel_host) + if ":" in connection._tunnel_host: + raise OSError("Tunnel connection failed: 502 Bad Gateway") + + connection._tunnel = fake_tunnel + connection.set_tunnel("cloud.example", 443) + + connection.connect() + + assert tunnelled == ["2606:2800:220:1:248:1893:25c8:1946", "93.184.216.34"] + assert connection._tunnel_host == "93.184.216.34" + assert connection._tls_server_hostname == "cloud.example" + + +def test_pinned_https_proxy_tunnel_reports_the_last_failure_when_every_address_fails( + monkeypatch, +): + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)) + ], + ) + + class _Sock: + def close(self): + pass + + connection = hosted_client.PinnedHTTPSConnection("proxy.example", 3128) + connection._create_connection = lambda address, timeout, source: _Sock() + + def fake_tunnel(): + raise OSError("Tunnel connection failed: 403 Forbidden") + + connection._tunnel = fake_tunnel + connection.set_tunnel("cloud.example", 443) + + with pytest.raises(OSError, match="403 Forbidden"): + connection.connect() + + def test_pinned_https_handler_forwards_only_supported_connection_arguments(monkeypatch): """Regression: 3.12 dropped ``HTTPSHandler._check_hostname`` and the matching kwarg. From eba81f44a821d7f007df23e322e9eebef720bdc9 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Fri, 24 Jul 2026 19:31:58 -0400 Subject: [PATCH 08/11] fix(cloud): track the CONNECT authority per retry and restore the offline gate Two further review findings, both verified before fixing. Python 3.12 caches an authority in _tunnel_headers["Host"] when set_tunnel runs (3.11 does not -- confirmed against both interpreters). The retry loop added in 713e2db reassigned _tunnel_host but left that header pinned to the first address, so a strict proxy would reject the retry as a Host/authority mismatch. Rebuild the header for each target, with IPv6 bracketing. AGENTS.md documents `python -m pytest tests/ -q` as a fully offline gate, but relay and cloud URL validation resolve their destination to reject private/reserved targets, so the suite silently required working DNS. With DNS blocked it failed five tests, not the one the review named: the sync-token fail-closed test plus four in test_provider_error_redaction.py and test_relay_device_credentials.py. Stub getaddrinfo in the suite-wide isolation fixture; tests needing a specific resolution result still override it. Verified green with DNS blocked, and the security behaviour of resolving at validation time is unchanged. --- engraphis/hosted_client.py | 12 ++++++++++++ tests/conftest.py | 18 ++++++++++++++++++ tests/test_hosted_client.py | 12 ++++++++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index b1de801..212245b 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -159,14 +159,26 @@ def _connect_directly(self): raise OSError("cloud service URL has no connectable address") raise last_error + def _tunnel_authority(self, target): + literal = "[%s]" % target if ":" in target else target + return "%s:%d" % (literal, self._tunnel_port) + def _connect_through_proxy(self): # Every vetted address is an equally valid CONNECT target, so a dual-stack # endpoint whose first address is unreachable *from the proxy* must fall through # 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 + base_headers = dict(self._tunnel_headers) for target in self._tunnel_targets or [self._tunnel_host]: self._tunnel_host = target + # Python 3.12+ caches an authority in _tunnel_headers["Host"] when the tunnel + # is configured. It must follow the address actually being CONNECTed, or a + # strict proxy rejects the retry because the Host names the failed address. + self._tunnel_headers = dict(base_headers) + for name in list(self._tunnel_headers): + if name.lower() == "host": + self._tunnel_headers[name] = self._tunnel_authority(target) try: self.sock = self._create_connection( (self.host, self.port), self.timeout, self.source_address diff --git a/tests/conftest.py b/tests/conftest.py index 2d52b1a..6829a04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,29 @@ """Suite-wide isolation for local runtime configuration and private client state.""" from __future__ import annotations +import socket + import pytest from engraphis.config import settings +@pytest.fixture(autouse=True) +def _offline_dns_isolation(monkeypatch): + """Keep the documented offline gate genuinely offline. + + AGENTS.md requires ``python -m pytest tests/ -q`` to pass with no network. Relay and + cloud URL validation resolve their destination to reject private/reserved targets, so + without this stub the suite silently depends on working DNS and fails on an air-gapped + machine. Tests that need a specific resolution result still override it themselves. + """ + + def _resolve(host, port, *args, **kwargs): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port or 0))] + + monkeypatch.setattr(socket, "getaddrinfo", _resolve) + + @pytest.fixture(autouse=True) def _deployment_settings_isolation(monkeypatch, tmp_path): """Keep developer deployment bindings and cloud credentials out of tests.""" diff --git a/tests/test_hosted_client.py b/tests/test_hosted_client.py index 0ed1742..5f24d45 100644 --- a/tests/test_hosted_client.py +++ b/tests/test_hosted_client.py @@ -211,16 +211,24 @@ def wrap_socket(self, sock, *, server_hostname): connection._create_connection = lambda address, timeout, source: _Sock() def fake_tunnel(): - tunnelled.append(connection._tunnel_host) + tunnelled.append((connection._tunnel_host, connection._tunnel_headers.get("Host"))) if ":" in connection._tunnel_host: raise OSError("Tunnel connection failed: 502 Bad Gateway") connection._tunnel = fake_tunnel connection.set_tunnel("cloud.example", 443) + # Python 3.12+ caches this authority at set_tunnel time; 3.11 does not. Set it + # explicitly so the rebuild is asserted identically on every interpreter. + connection._tunnel_headers["Host"] = "2606:2800:220:1:248:1893:25c8:1946:443" connection.connect() - assert tunnelled == ["2606:2800:220:1:248:1893:25c8:1946", "93.184.216.34"] + # The Host authority must follow the address actually being CONNECTed -- a strict + # proxy rejects a retry whose Host still names the address that just failed. + assert tunnelled == [ + ("2606:2800:220:1:248:1893:25c8:1946", "[2606:2800:220:1:248:1893:25c8:1946]:443"), + ("93.184.216.34", "93.184.216.34:443"), + ] assert connection._tunnel_host == "93.184.216.34" assert connection._tls_server_hostname == "cloud.example" From 619599c146d5ee4a57c8442880aa046ffd24e819 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Fri, 24 Jul 2026 19:42:16 -0400 Subject: [PATCH 09/11] fix(cloud): bracket IPv6 CONNECT targets on Python 3.9 and 3.10 Verified against four interpreters before changing anything. Python 3.11 and 3.12 wrap the tunnel host with _wrap_ipv6 and already emit `CONNECT [2606:...]:443`, but 3.9.25 and 3.10.20 serialize _tunnel_host verbatim and emit `CONNECT 2606:...:443` -- an ambiguous authority strict proxies reject. This package supports 3.9, so normalize the target ourselves; 3.11+ leave an already-bracketed value untouched, so one code path is correct everywhere. The new test drives the real http.client._tunnel over a fake socket and asserts the request line, so it covers whichever serialization the interpreter uses. All of 3.9.25, 3.10.20, 3.11.15 and 3.12.10 now emit the bracketed form. Also corrects a comment added in 713e2db: encoding a bare IPv6 tunnel host does not raise, so the UnicodeError guard is defensive rather than the IPv6 handler the comment claimed. --- engraphis/hosted_client.py | 24 +++++++++++++++----- tests/test_hosted_client.py | 45 ++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index 212245b..a7e97b2 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -159,9 +159,16 @@ def _connect_directly(self): raise OSError("cloud service URL has no connectable address") raise last_error + @staticmethod + def _bracketed(target): + """Return *target* as an unambiguous URI host (IPv6 literals get brackets).""" + + if ":" in target and not target.startswith("["): + return "[%s]" % target + return target + def _tunnel_authority(self, target): - literal = "[%s]" % target if ":" in target else target - return "%s:%d" % (literal, self._tunnel_port) + return "%s:%d" % (self._bracketed(target), self._tunnel_port) def _connect_through_proxy(self): # Every vetted address is an equally valid CONNECT target, so a dual-stack @@ -171,8 +178,13 @@ def _connect_through_proxy(self): last_error = None base_headers = dict(self._tunnel_headers) for target in self._tunnel_targets or [self._tunnel_host]: - self._tunnel_host = target - # Python 3.12+ caches an authority in _tunnel_headers["Host"] when the tunnel + # Python 3.9 and 3.10 serialize the CONNECT request target verbatim, so a + # bare IPv6 literal becomes an ambiguous ":" authority that + # strict proxies reject. 3.11+ bracket it themselves and leave an already + # bracketed value untouched, so normalizing here is right on every version + # this package supports. + self._tunnel_host = self._bracketed(target) + # 3.12+ also caches an authority in _tunnel_headers["Host"] when the tunnel # is configured. It must follow the address actually being CONNECTed, or a # strict proxy rejects the retry because the Host names the failed address. self._tunnel_headers = dict(base_headers) @@ -186,8 +198,8 @@ def _connect_through_proxy(self): self._tunnel() return except (OSError, UnicodeError) as exc: - # UnicodeError: http.client idna-encodes the tunnel host, which fails on - # a bare IPv6 literal -- a reason to try the next address, not to abort. + # UnicodeError: http.client encodes the tunnel host before sending it, + # which is a reason to try the next address rather than abort outright. last_error = exc if self.sock is not None: self.sock.close() diff --git a/tests/test_hosted_client.py b/tests/test_hosted_client.py index 5f24d45..f1dfa11 100644 --- a/tests/test_hosted_client.py +++ b/tests/test_hosted_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import socket import urllib.request @@ -226,7 +227,7 @@ def fake_tunnel(): # The Host authority must follow the address actually being CONNECTed -- a strict # proxy rejects a retry whose Host still names the address that just failed. assert tunnelled == [ - ("2606:2800:220:1:248:1893:25c8:1946", "[2606:2800:220:1:248:1893:25c8:1946]:443"), + ("[2606:2800:220:1:248:1893:25c8:1946]", "[2606:2800:220:1:248:1893:25c8:1946]:443"), ("93.184.216.34", "93.184.216.34:443"), ] assert connection._tunnel_host == "93.184.216.34" @@ -261,6 +262,48 @@ def fake_tunnel(): connection.connect() +def test_pinned_https_proxy_tunnel_brackets_ipv6_on_the_connect_request_line(monkeypatch): + """The CONNECT request target must be an unambiguous authority on every version. + + Python 3.9 and 3.10 serialize ``_tunnel_host`` verbatim, so a bare IPv6 literal + would emit ``CONNECT 2606:...:443`` -- an ambiguous authority strict proxies reject. + 3.11+ bracket it themselves and leave an already-bracketed value alone. + """ + + v6 = "2606:2800:220:1:248:1893:25c8:1946" + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", (v6, 443))], + ) + sent = [] + + class _Sock(io.BytesIO): + def sendall(self, data): + sent.append(data) + + def makefile(self, *args, **kwargs): + return io.BytesIO(b"HTTP/1.1 200 Connection established\r\n\r\n") + + def close(self): + pass + + class _Context: + def wrap_socket(self, sock, *, server_hostname): + return sock + + connection = hosted_client.PinnedHTTPSConnection("proxy.example", 3128) + connection._context = _Context() + connection._create_connection = lambda address, timeout, source: _Sock() + connection.set_tunnel("cloud.example", 443) + + connection.connect() + + assert sent, "no CONNECT request was sent" + request_line = sent[0].split(b"\r\n")[0] + assert request_line.startswith(b"CONNECT [%s]:443 " % v6.encode()), request_line + + def test_pinned_https_handler_forwards_only_supported_connection_arguments(monkeypatch): """Regression: 3.12 dropped ``HTTPSHandler._check_hostname`` and the matching kwarg. From 7916faf787bb36d39c55c1b8fbb8f35a73431949 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Fri, 24 Jul 2026 19:49:46 -0400 Subject: [PATCH 10/11] fix(dashboard): show the consent prompt on the Overview analytics card Managed-compute consent now defaults to off, so a default installation's Overview immediately calls /analytics and gets the new 409 consent_required. Only the full Analytics view routed that through managedConsentRequired()/managedConsentHtml(); the Overview fell through to the generic branch and rendered an opaque "managed cloud operation failed" string -- the worst first-run experience for exactly the new default. Mirror the Analytics handling in loadOverviewAnalytics so the card shows the opt-in instructions and a CLOUD pill instead. Reuses the existing s87 style token, so scripts/externalize_dashboard_assets.py reports no CSP drift. --- engraphis/static/dashboard.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 0856b67..9b86abc 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -146,6 +146,10 @@ async function loadOverviewAnalytics(){ 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)+'
'; } } From 715a016e302bcede1ee078ac76df027d01d275bf Mon Sep 17 00:00:00 2001 From: Jaixii Date: Fri, 24 Jul 2026 23:21:29 -0400 Subject: [PATCH 11/11] fix(cloud): keep hosted connections visible to Python audit hooks http.client.HTTPConnection.connect raises the http.client.connect audit event before opening its socket, which is how a host process records or blocks outbound connections. PinnedHTTPSConnection overrides connect() and calls _create_connection directly on both the direct and proxy paths, so it never raised that event: every hosted request -- Cloud Sync, session refresh, managed compute -- was invisible to those hooks, silently and with no way to notice. Measured with a real audit hook on 3.11.15 and 3.12.10: stock HTTPSConnection raised 1 event, the pinned client raised 0 on both paths. It now raises 1 on each, matching stock. The direct path names the vetted address actually dialled rather than the hostname, so a hook deciding by address sees the destination the socket really opens; the proxy path names the proxy, which is the socket a tunnelled request actually opens and what stock audits in the same situation. Each retry is audited, so the event count tracks real dials. --- engraphis/hosted_client.py | 9 ++++ tests/test_hosted_client.py | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py index a7e97b2..d633a0c 100644 --- a/engraphis/hosted_client.py +++ b/engraphis/hosted_client.py @@ -10,6 +10,7 @@ import ipaddress import os import socket +import sys import urllib.request from typing import Optional from urllib.parse import urlsplit, urlunsplit @@ -149,6 +150,11 @@ def connect(self): def _connect_directly(self): last_error = None 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 + # or blocking outbound connections. Emit it per dial, naming the vetted + # address actually opened rather than the hostname, so a hook sees the truth. + sys.audit("http.client.connect", self, target, self.port) try: return self._create_connection( (target, self.port), self.timeout, self.source_address @@ -191,6 +197,9 @@ def _connect_through_proxy(self): for name in list(self._tunnel_headers): if name.lower() == "host": self._tunnel_headers[name] = self._tunnel_authority(target) + # The socket opened here goes to the proxy, which is exactly what the stock + # implementation audits before a tunnelled request, so report the proxy. + 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 diff --git a/tests/test_hosted_client.py b/tests/test_hosted_client.py index f1dfa11..20282f9 100644 --- a/tests/test_hosted_client.py +++ b/tests/test_hosted_client.py @@ -304,6 +304,92 @@ def wrap_socket(self, sock, *, server_hostname): assert request_line.startswith(b"CONNECT [%s]:443 " % v6.encode()), request_line +def _record_connect_audits(monkeypatch): + """Capture only ``http.client.connect`` audit events raised by the pinned client.""" + + audited = [] + + def _audit(name, *args): + if name == "http.client.connect": + audited.append(args) + + monkeypatch.setattr(hosted_client.sys, "audit", _audit) + return audited + + +def test_pinned_connection_still_emits_the_standard_connect_audit_event(monkeypatch): + """Overriding connect() must not hide hosted requests from audit hooks. + + ``HTTPConnection.connect`` raises ``http.client.connect`` before opening its socket, + which is how a host process records or blocks outbound connections. This class + bypasses that method, so it has to raise the event itself or every hosted request + becomes invisible to those hooks. + """ + + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)) + ], + ) + audited = _record_connect_audits(monkeypatch) + + class _Context: + def wrap_socket(self, sock, *, server_hostname): + return sock + + connection = hosted_client.PinnedHTTPSConnection("cloud.example", 443) + connection._context = _Context() + connection._create_connection = lambda address, timeout, source: object() + + connection.connect() + + # The vetted address actually dialled is reported, not the hostname, so a hook + # deciding by address sees the destination the socket really opens. + assert audited == [(connection, "93.184.216.34", 443)] + + +def test_pinned_proxy_tunnel_audits_each_proxy_dial(monkeypatch): + monkeypatch.setattr( + hosted_client.socket, + "getaddrinfo", + lambda *args, **kwargs: [ + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:2800:220:1:248:1893:25c8:1946", 443)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)), + ], + ) + + class _Sock: + def close(self): + pass + + class _Context: + def wrap_socket(self, sock, *, server_hostname): + return sock + + connection = hosted_client.PinnedHTTPSConnection("proxy.example", 3128) + connection._context = _Context() + connection._create_connection = lambda address, timeout, source: _Sock() + + def fake_tunnel(): + if ":" in connection._tunnel_host: + raise OSError("Tunnel connection failed: 502 Bad Gateway") + + connection._tunnel = fake_tunnel + connection.set_tunnel("cloud.example", 443) + audited = _record_connect_audits(monkeypatch) + + connection.connect() + + # One event per redial of the proxy, naming the proxy -- which is the socket that + # actually gets opened on a tunnelled request. + assert audited == [ + (connection, "proxy.example", 3128), + (connection, "proxy.example", 3128), + ] + + def test_pinned_https_handler_forwards_only_supported_connection_arguments(monkeypatch): """Regression: 3.12 dropped ``HTTPSHandler._check_hostname`` and the matching kwarg.