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..f579b37 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,56 +5,9 @@ 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
+No unreleased changes.
-- 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.
-
-## [1.0.1] - 2026-07-23
+## [1.0.1] - 2026-07-24
Public 1.0.1 client reliability release.
@@ -64,12 +17,13 @@ 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 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
@@ -78,6 +32,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 +43,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 +65,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 +89,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
@@ -124,8 +101,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..4b617a9 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -114,14 +114,17 @@ 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,
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/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/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/backends/sync_relay.py b/engraphis/backends/sync_relay.py
index 1fdc05c..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,12 +287,13 @@ 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):
- 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 acda9fe..2235517 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
@@ -18,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
@@ -26,14 +28,19 @@
MAX_TEXT_CHARS = 100_000
+def _truthy(value: Optional[str]) -> 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."""
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):
@@ -42,13 +49,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 +186,27 @@ 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,
+ code="consent_required",
+ )
snapshot_generation = _reserve_snapshot_generation(
service, workspace_id, requested=generation
)
@@ -288,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)
@@ -310,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 6208c30..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,
@@ -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 {}
@@ -220,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)
@@ -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/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/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/routes/v2_api.py b/engraphis/routes/v2_api.py
index d8b6543..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
@@ -1804,6 +1807,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")
@@ -1848,6 +1852,7 @@ def _sync_all(svc) -> dict:
"shared relay",
})
continue
+ attempted += 1
try:
cloud_access = None
if not legacy_token_configured:
@@ -1862,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
@@ -1878,6 +1891,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 +1900,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}
@@ -1902,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.",
@@ -1917,11 +1936,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/service.py b/engraphis/service.py
index c501052..1dbf15f 100644
--- a/engraphis/service.py
+++ b/engraphis/service.py
@@ -1938,10 +1938,13 @@ 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 (%s)",
+ type(exc).__name__,
+ )
llm = None
if synthesize:
try:
@@ -5368,16 +5371,22 @@ 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 (%s)",
+ 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 (%s)",
+ 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..0856b67 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.
`}
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')}
@@ -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('')||'
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 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.
';toast((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message,'err')}}
+async function loadAutomation(){const el=document.getElementById('automation-body'),lock=document.getElementById('au-lock'),ws='?workspace='+encodeURIComponent(WS||'');el.innerHTML='';try{const p=await api('/automation'+ws);setPlanPill(lock,(LIC&&LIC.is_trial)?'TRIAL':'CLOUD','pill pill-accent');const last=p.last_run?fmtRel(p.last_run):'never',dream=p.dream_enabled!=null?p.dream_enabled:p.dream;el.innerHTML=`
Hosted maintenance policy
The cloud returns reviewable proposals. Pinned memories remain protected.
Cloud worker status
Status${p.enabled?'ENABLED':'OFF'}
Last run${esc(last)}
After explicit opt-in with ENGRAPHIS_MANAGED_COMPUTE_CONSENT=1, requesting managed work uploads the selected workspace’s normal and sensitive memory content, excluding secret and session-scoped rows, capped at 16 MiB, over HTTPS without end-to-end encryption. Results are proposals and never automatically write the local database.
`}catch(e){if(e.status===401||e.status===402||e.status===501){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}}
+async function saveAutomation(){const body={enabled:document.getElementById('au-enabled').checked,cadence_hours:Number(document.getElementById('au-cadence').value)||24,consolidate:document.getElementById('au-consolidate').checked,min_cluster:Number(document.getElementById('au-mincluster').value)||3,archive_below:Number(document.getElementById('au-archive').value)||0.05,dream_enabled:document.getElementById('au-dream').checked,dream_min_new:Number(document.getElementById('au-dream-min').value)||20,dream_idle_minutes:Number(document.getElementById('au-dream-idle').value)};try{await api('/automation?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Hosted policy saved','ok');loadAutomation()}catch(e){if(managedConsentRequired(e)){const result=document.getElementById('au-result');if(result)result.innerHTML=managedConsentHtml('Hosted Automation');toast('Enable managed compute consent and restart Engraphis','err');return}toast((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message,'err')}}
+async function runMaintenance(){const el=document.getElementById('au-result');if(el)el.innerHTML='';try{const d=await api('/maintenance/run?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dry_run:true})});if(el)el.innerHTML=`PROPOSAL Hosted work was submitted for review.
`}
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.'}}
+function syncTotalAuthorizationDenial(last){if(!last||!(Number(last.attempted)>0)||Number(last.succeeded)!==0)return false;const errors=Array.isArray(last.errors)?last.errors:[];return errors.length===Number(last.attempted)&&errors.every(error=>[401,402,403].includes(Number(error.status)))}
+function syncRecoveryHtml(){return unlockHtml('Cloud Sync','pro')+``}
+function renderSync(d){const el=document.getElementById('sync-body');if(!el)return;d=d||{};const last=d.last;if(!d.available){el.innerHTML=unlockHtml('Cloud Sync','pro');return}if(syncTotalAuthorizationDenial(last)){el.innerHTML=syncRecoveryHtml();return}let status='Cloud session connected; no sync recorded on this installation.';if(last){const when=new Date((last.at||0)*1000).toLocaleString();status='Last synced '+when+' — pushed '+(last.exported||0)+', +'+(last.added||0)+' received'+((last.errors&&last.errors.length)?' · '+last.errors.length+' issue(s)':'')+'.'}el.innerHTML=`
Hosted relayCONNECTED
Relay storage and authorization run in Engraphis Cloud. This package contains only the customer client; it does not run a local relay or background scheduler.
${esc(status)}
`}
+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/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/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/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js
index 6ecea16..cecdf4d 100644
--- a/tests/e2e/commercial.spec.js
+++ b/tests/e2e/commercial.spec.js
@@ -14,8 +14,16 @@ const hostedLicense = {
trial: { used: false, trial_days: 3 },
};
-async function mockLocalClient(page, cloudStatus = 402) {
+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();
@@ -53,7 +61,37 @@ async function mockLocalClient(page, cloudStatus = 402) {
cloud_url: 'https://cloud.engraphis.test/team',
};
} else if (path === '/sync/status') {
- body = { available: false };
+ 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,
@@ -65,15 +103,30 @@ async function mockLocalClient(page, cloudStatus = 402) {
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 = {
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' } : {}),
},
};
}
@@ -88,14 +141,44 @@ 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);
+
+ 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: '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([]);
+});
+
function recordBrowserErrors(page) {
const errors = [];
page.on('console', message => {
if (message.type() === 'error') {
const location = message.location();
const expectedCloudDenial = /\/api\/(analytics|automation)/.test(location.url || '')
- && /status of (401|402|501)/.test(message.text());
- if (expectedCloudDenial) return;
+ && /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;
errors.push(message.text() + (location.url
? ` @ ${location.url}:${location.lineNumber}`
: ''));
@@ -134,9 +217,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 +270,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 +289,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);
@@ -216,3 +299,32 @@ 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([]);
+});
+
+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 ed2d590..3a84ffd 100644
--- a/tests/test_cloud_features.py
+++ b/tests/test_cloud_features.py
@@ -34,13 +34,59 @@ 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
+@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")
+
+ 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 +244,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_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_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..2525ae7 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):
@@ -318,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_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_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..2d5b300 100644
--- a/tests/test_proactive_context.py
+++ b/tests/test_proactive_context.py
@@ -46,6 +46,25 @@ 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 "acme" not 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_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_release_infrastructure.py b/tests/test_release_infrastructure.py
index a7322c4..b307be9 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",
@@ -191,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
diff --git a/tests/test_service_graph.py b/tests/test_service_graph.py
index 4e7bec5..37d60f2 100644
--- a/tests/test_service_graph.py
+++ b/tests/test_service_graph.py
@@ -297,6 +297,33 @@ 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 "mem_" not 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
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 44da806..a3362db 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
@@ -162,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"]
@@ -189,3 +193,81 @@ 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")
+ 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):
+ """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,
+ }]
+
+
+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)