From ad26b5e5520cf7de5364e68263dc80688af66c85 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 02:21:10 -0400 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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