Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
# seats, license revocation, Pro sync) — the thing teams actually deploy. The raw
# single-user v1 API server is available under the "api" profile:
# docker compose --profile api up engraphis-api
#
# NOTE: the `env_file:` object form below (`- path: .env` + `required: false`, which makes
# .env OPTIONAL so a fresh clone with no .env still boots) needs Docker Compose v2.24+
# (Jan 2024). On older Compose, either upgrade, or replace each `env_file:` block with the
# plain list form `env_file: [.env]` and run `touch .env` first — the `environment:` blocks
# already supply every default, so .env is only ever for optional overrides.
services:
engraphis:
build: .
Expand Down
11 changes: 10 additions & 1 deletion engraphis/routes/v2_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,16 @@ def _cookie_secure(request: Request) -> bool:
https``. Trusting ``request.url.scheme`` alone dropped Secure for every proxied
deployment, letting the session cookie ride over cleartext HTTP. (Honouring the
forwarded proto can only ADD Secure — making the cookie more restrictive — so a forged
header cannot downgrade cookie security.)"""
header cannot downgrade cookie security.)

Caveat (non-proxied plain-HTTP deployments): the forwarded proto is trusted from ANY
caller, so on a deployment that terminates NO TLS and sits behind NO proxy, an
intermediary — or the client itself — that injects ``X-Forwarded-Proto: https`` makes
this cookie Secure and therefore undeliverable over HTTP. That is a self-inflicted
lockout of that one caller (a client can only set the header on its OWN request), never
a cross-user risk and never a way to leak or downgrade a session. The supported topology
(TLS terminated at the app or a fronting proxy) is unaffected; if you must serve cleartext
HTTP with no proxy, do not front the app with anything that sets that header."""
if request.url.scheme == "https":
return True
xfp = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
Expand Down
77 changes: 77 additions & 0 deletions tests/test_intent_paywall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Team-paywall regression tests for the intent-native agent WRITE surface
(commit 5a31389 added ``_paid("team")`` to /api/intent/remember and /api/intent/link).

These routes are the intent-native equivalent of /api/remember — an agent writing onto
this cloud instance's store — so a free / lapsed instance must not host them (402). The
shipping commit gated both routes but added no test either way; this pins the gate so a
future refactor can't silently reopen the free-write hole, and can't over-block a licensed
instance.
"""
import pytest

pytest.importorskip("fastapi")

from fastapi import FastAPI
from fastapi.testclient import TestClient

from engraphis import licensing
from engraphis.routes import v2_api


class _StubService:
"""Minimal stand-in so the ALLOW path exercises the gate, not the store."""

def intent_remember(self, *args, **kwargs):
return {"id": "mem_stub", "decision": "add"}

def intent_link(self, *args, **kwargs):
return {"linked": True}


@pytest.fixture(autouse=True)
def _reset_service():
yield
v2_api._service = None # don't leak the stub to other tests


def _client(monkeypatch, *, licensed):
if licensed:
monkeypatch.setattr("engraphis.licensing.require_feature", lambda *a, **k: None)
else:
def _deny(feature):
raise licensing.LicenseError("Team license required", feature=feature)
monkeypatch.setattr("engraphis.licensing.require_feature", _deny)
v2_api.set_service(_StubService())
app = FastAPI()
app.include_router(v2_api.router)
return TestClient(app)


def test_intent_remember_blocked_without_team_license(monkeypatch):
client = _client(monkeypatch, licensed=False)
r = client.post("/api/intent/remember", json={"text": "hello world"})
assert r.status_code == 402, r.text
assert r.json()["detail"]["feature"] == "team"


def test_intent_link_blocked_without_team_license(monkeypatch):
client = _client(monkeypatch, licensed=False)
r = client.post("/api/intent/link",
json={"source_id": "mem_a", "target_id": "mem_b", "workspace": "default"})
assert r.status_code == 402, r.text
assert r.json()["detail"]["feature"] == "team"


def test_intent_remember_allowed_with_team_license(monkeypatch):
client = _client(monkeypatch, licensed=True)
r = client.post("/api/intent/remember", json={"text": "hello world"})
assert r.status_code == 200, r.text
assert r.json()["id"] == "mem_stub"


def test_intent_link_allowed_with_team_license(monkeypatch):
client = _client(monkeypatch, licensed=True)
r = client.post("/api/intent/link",
json={"source_id": "mem_a", "target_id": "mem_b", "workspace": "default"})
assert r.status_code == 200, r.text
assert r.json()["linked"] is True
34 changes: 34 additions & 0 deletions tests/test_machine_id_concurrency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Thread-race regression test for device-id (machine_id) first-run generation
(commit 7b62157 "serialize the process license cache and device-id generation").

Without the ``_machine_id_lock`` + ``O_EXCL`` atomic create, N threads racing a fresh,
unpersisted id each mint a DIFFERENT uuid — registering the same device twice and burning a
Team seat. This proves they converge on ONE id and persist exactly one file. (The
license-cache half of 7b62157 is exercised by the concurrency test in
tests/test_online_only_enforcement.py.)
"""
from concurrent.futures import ThreadPoolExecutor
import threading


def test_machine_id_is_stable_under_concurrent_first_generation(monkeypatch, tmp_path):
from engraphis import cloud_license

mid_file = tmp_path / "sub" / "machine_id" # parent dir does not exist yet
monkeypatch.setattr(cloud_license, "_MACHINE_ID_FILE", mid_file)
cloud_license._machine_id_cache.clear() # force a real first-run generation

barrier = threading.Barrier(16)

def get_id(_):
barrier.wait() # release all threads together
return cloud_license.machine_id()

try:
with ThreadPoolExecutor(max_workers=16) as pool:
ids = list(pool.map(get_id, range(16)))
assert len(set(ids)) == 1, "concurrent first-run generation minted more than one id"
assert ids[0]
assert mid_file.read_text(encoding="utf-8").strip() == ids[0]
finally:
cloud_license._machine_id_cache.clear() # don't leak the tmp id to other tests