From df2605413317bee0f5c60024f058197b0bfedb3d Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Tue, 22 Sep 2026 01:03:33 -0700 Subject: [PATCH 1/2] kits: one Harness per workspace, and a database connection's configuration on the graph A launched kit's Harness belongs to the workspace it was launched in, but launch and the catalog looked the org over: a second workspace was handed the first workspace's Harness, which its members could not see, and its card said Running for a kit that workspace never launched. Both now keep to the caller's workspace, with the Default Workspace's leniency for a Harness made before workspaces were stamped. What a harness is connected to is configuration, and configuration is listed and managed; a vault record is neither. The database attach now writes a HarnessConnection vertex (engine, host, database, sampling, the vault key) beside the vault record that alone holds the credential, and a removed entry drops it. Co-Authored-By: Claude Fable 5.1 --- gateway/app.py | 65 ++++++++++++++- gateway/tests/test_kits_workspace.py | 118 +++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 gateway/tests/test_kits_workspace.py diff --git a/gateway/app.py b/gateway/app.py index 012992f..565481d 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -9960,6 +9960,8 @@ async def _hosted_scrub_removed(org: str, hid: str, before: list[dict], after: l scrubbed += 1 if scrubbed: print(f"[sql] {hid}: disconnected ({scrubbed} record(s) scrubbed)", flush=True) + for key in sorted(gone): + await _connection_vertex_drop(hid, key) async def _mcp_write(hid: str, servers: list[dict]) -> None: @@ -10164,6 +10166,53 @@ async def _db_validate(engine: str, conn: str) -> tuple[str, str, str, str]: return eng, c, host, db +# ── the connection, on the graph ───────────────────────────────────────────────────────────── +# What a harness is connected to is configuration, and configuration is listed, shown and +# managed; a vault record is none of those things. So the attach below writes two things: the +# connection string into the vault (the only place a credential ever sits) and this vertex, which +# holds everything else and names the vault key. One per harness entry, rewritten on reconnect. +_CONNECTION_LABEL = "HarnessConnection" + + +def _connection_vertex_id(hid: str, entry_id: str) -> str: + return "hconn_" + hashlib.sha1(f"{hid}|{entry_id}".encode()).hexdigest()[:24] + + +async def _connection_vertex_put(org: str, hid: str, v: dict | None, entry_id: str, key: str, + engine: str, host: str, db: str, sample_rows: bool) -> None: + now = str(int(time.time() * 1000)) + vid = _connection_vertex_id(hid, entry_id) + prev = await _vertex_get(vid) + await _vg_upsert(_CONNECTION_LABEL, vid, { + "org": org, "workspace": str((v or {}).get("workspace") or ""), "harness": hid, "entry": entry_id, + "kind": "database", "server": "database", "engine": engine, "host": host, "database": db, + "sample_rows": "1" if sample_rows else "0", "secret_key": key, + "created_at": str((prev or {}).get("created_at") or now), "updated_at": now, "deleted": "0"}) + + +async def _connection_vertex_drop(hid: str, key: str) -> None: + """The entry is gone, so the configuration is gone with it (the record was scrubbed already).""" + with contextlib.suppress(Exception): + for row in await BACKING.graph.find(_CONNECTION_LABEL, {"harness": hid, "secret_key": key}): + vid = str(row.get("id") or "") + if vid: + await _vg_upsert(_CONNECTION_LABEL, vid, {"deleted": "1", "updated_at": str(int(time.time() * 1000))}) + + +async def _connections_of(org: str, hid: str) -> list[dict]: + """A harness's connections as the console may see them: never the credential.""" + out = [] + for row in await BACKING.graph.find(_CONNECTION_LABEL, {"harness": hid, "org": org}): + if str(row.get("deleted") or "0") in ("1", "true", "True"): + continue + out.append({"id": str(row.get("id") or ""), "entry": str(row.get("entry") or ""), + "kind": str(row.get("kind") or ""), "engine": str(row.get("engine") or ""), + "host": str(row.get("host") or ""), "database": str(row.get("database") or ""), + "sampleRows": str(row.get("sample_rows") or "0") == "1", + "updatedAt": int(str(row.get("updated_at") or 0) or 0)}) + return out + + async def _hosted_db_entry(hid: str, v: dict | None, decl: dict) -> None: """The database ENTRY, with no credential in it. @@ -10230,6 +10279,7 @@ async def _hosted_db_attach(org: str, hid: str, v: dict | None, decl: dict, "auth": f"vault:{key}", "enabled": str(prev.get("enabled", True)) not in ("False", "false", "0")} await _mcp_write(hid, [e for e in cur if str(e.get("id") or "") != decl["id"]] + [entry]) + await _connection_vertex_put(org, hid, v, decl["id"], key, engine, host, db, sample_rows) # host and database only. The credential is not printed, here or anywhere. print(f"[sql] {hid}: connected {engine} {host}/{db} " f"(sample rows {'on' if sample_rows else 'off'})", flush=True) @@ -14840,8 +14890,12 @@ async def list_kits(request: Request) -> dict: org = p.get("org", "") if not org: raise uhp_error(401, "invalid_credential", "Missing or invalid API key.") + # One kit, one Harness PER WORKSPACE: a launched kit's Harness belongs to the workspace it was + # launched in, so "launched" here is a fact about the caller's workspace, not the org. + ws, wsd = str(p.get("workspace") or ""), bool(p.get("workspace_default")) rows = await _vg_list_by_org("Harness", org) - by_kit = {str(r.get("kit") or ""): r for r in rows if str(r.get("deleted") or "0") != "1"} + by_kit = {str(r.get("kit") or ""): r for r in rows + if str(r.get("deleted") or "0") != "1" and _workspace_keep(str(r.get("workspace") or ""), ws, wsd)} out = [] # Manifest order, NOT alphabetical. kits.json lists the kits in the order the catalogue wants # them shown, install-kits.sh bundles them in that order, and _kits() builds its dict by @@ -14978,8 +15032,12 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non # a corrected form, not a half-configured Harness to find and fix. checked = await _db_validate(db_in.engine, db_in.connection_string) if db_in else None + # Per workspace (see list_kits): launching in a second workspace makes that workspace its own + # Harness rather than handing back the first workspace's, which its members could not see. + ws, wsd = str(p.get("workspace") or ""), bool(p.get("workspace_default")) existing = next((r for r in await _vg_list_by_org("Harness", org) - if str(r.get("kit") or "") == kit_id and str(r.get("deleted") or "0") != "1"), + if str(r.get("kit") or "") == kit_id and str(r.get("deleted") or "0") != "1" + and _workspace_keep(str(r.get("workspace") or ""), ws, wsd)), None) want_h = (body_in.harness if body_in else "").strip() if want_h and want_h != str((existing or {}).get("id") or ""): @@ -14987,7 +15045,8 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non # Harness keeps its sessions and its package but is no longer the one the app talks to, # and a Harness already running another kit is not taken over. target = await _vertex_get(want_h) - if not target or str(target.get("org") or "") != org or str(target.get("deleted") or "0") == "1": + if not target or str(target.get("org") or "") != org or str(target.get("deleted") or "0") == "1" \ + or not _workspace_keep(str(target.get("workspace") or ""), ws, wsd): raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness") other = str(target.get("kit") or "") if other and other != kit_id: diff --git a/gateway/tests/test_kits_workspace.py b/gateway/tests/test_kits_workspace.py new file mode 100644 index 0000000..96807dc --- /dev/null +++ b/gateway/tests/test_kits_workspace.py @@ -0,0 +1,118 @@ +"""A starter kit launches once per WORKSPACE: a launched kit's Harness belongs to the workspace it +was launched in, so a second workspace gets its own rather than the first workspace's, which its +members could not see. And a database connection's configuration is recorded on the graph beside +the vault record that alone holds the credential.""" +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import app as gw # noqa: E402 + +KIT = {"id": "slides", "title": "Slides", "harness": {"name": "Slides", "mcp_servers": [], + "recommended": [{"base": "hermes", "model": "deepseek-v4-pro"}]}, "app": {"route": "/kits/slides"}} +DB_KIT = {"id": "dashboard", "title": "Dashboards", "harness": {"name": "Dashboards", "mcp_servers": [], + "launch": {"database": {"engines": ["postgres"], "name": "database", "id": "mcp.database"}}, + "recommended": [{"base": "hermes", "model": "deepseek-v4-pro"}]}, "app": {"route": "/kits/dashboard"}} + + +class _Req: + headers: dict = {} + + +@pytest.fixture +def world(monkeypatch): + store: dict = {} + who = {"org": "org.a", "member": "m@a", "workspace": "org.a__ws1", "workspace_default": False} + + async def _principal(request): + return dict(who) + + async def _vg_list_by_org(label, org): + return [dict(v, id=k) for k, v in store.items() if v.get("org") == org] + + async def _vertex_get(vid): + return dict(store[vid], id=vid) if vid in store else None + + async def _vg_upsert(label, vid, props, **kw): + store.setdefault(vid, {}).update(props) + + async def _find(label, eq=None, neq=None): + rows = [dict(v, id=k) for k, v in store.items()] + return [r for r in rows if all(str(r.get(k)) == str(v) for k, v in (eq or {}).items()) + and all(str(r.get(k)) != str(v) for k, v in (neq or {}).items())] + + async def _servable_models(org, backend): + return None + + async def _skills_prepare(skills): + return list(skills or []) + + async def _plugins_prepare(body, org, previous=None, reserved_mcp=()): + return [] + + async def _mcp_migrate(org, hid, v): + return v + monkeypatch.setattr(gw.BACKING.graph, "find", _find) + for name, fn in (("_principal", _principal), ("_vg_list_by_org", _vg_list_by_org), ("_vertex_get", _vertex_get), + ("_vg_upsert", _vg_upsert), ("_servable_models", _servable_models), ("_skills_prepare", _skills_prepare), + ("_plugins_prepare", _plugins_prepare), ("_mcp_migrate", _mcp_migrate)): + monkeypatch.setattr(gw, name, fn) + monkeypatch.setattr(gw, "_kits", lambda: {"slides": KIT, "dashboard": DB_KIT}) + monkeypatch.setattr(gw, "_kit_plugin", lambda kit: None) + monkeypatch.setattr(gw, "_kit_skills", lambda kit: []) + return store, who + + +def test_a_kit_launches_once_per_workspace(world): + store, who = world + first = asyncio.run(gw.launch_kit("slides", _Req(), None)) + again = asyncio.run(gw.launch_kit("slides", _Req(), None)) + assert first["created"] and not again["created"] and again["harnessId"] == first["harnessId"] + who["workspace"] = "org.a__ws2" + other = asyncio.run(gw.launch_kit("slides", _Req(), None)) + assert other["created"] and other["harnessId"] != first["harnessId"] + assert store[other["harnessId"]]["workspace"] == "org.a__ws2" + slides = next(k for k in asyncio.run(gw.list_kits(_Req()))["kits"] if k["id"] == "slides") + assert slides["launched"] and slides["harnessId"] == other["harnessId"] + who["workspace"] = "org.a__ws3" + assert not next(k for k in asyncio.run(gw.list_kits(_Req()))["kits"] if k["id"] == "slides")["launched"] + + +def test_a_legacy_unstamped_kit_harness_belongs_to_the_default_workspace(world): + store, who = world + store["chrn_old"] = {"org": "org.a", "kit": "slides", "workspace": "", "deleted": "0", "name": "Slides", "base": "hermes"} + who.update(workspace="org.a__hr_default", workspace_default=True) + assert asyncio.run(gw.launch_kit("slides", _Req(), None))["harnessId"] == "chrn_old" + who.update(workspace="org.a__ws9", workspace_default=False) + assert asyncio.run(gw.launch_kit("slides", _Req(), None))["harnessId"] != "chrn_old" + + +def test_a_database_connection_is_recorded_on_the_graph_without_its_credential(world, monkeypatch): + store, who = world + records = {} + + async def _db_validate(engine, conn): + return "postgres", conn, "db.example", "shop" + + async def _hosted_put_record(org, key, record, *, secret=True, param="connection_string"): + records[key] = record + + async def _mcp_write(hid, servers): + store[hid]["mcp_servers"] = json.dumps(servers) + monkeypatch.setattr(gw, "_db_validate", _db_validate) + monkeypatch.setattr(gw, "_hosted_put_record", _hosted_put_record) + monkeypatch.setattr(gw, "_mcp_write", _mcp_write) + monkeypatch.setattr(gw, "_own_origins", lambda: ["https://api.example"]) + body = gw.KitLaunchBody(database=gw.KitDatabaseBody(engine="postgres", connection_string="postgresql://u:p@db.example/shop", sample_rows=False)) + hid = asyncio.run(gw.launch_kit("dashboard", _Req(), body))["harnessId"] + key = next(iter(records)) + conns = [v for v in store.values() if v.get("kind") == "database"] + assert len(conns) == 1 and conns[0]["harness"] == hid and conns[0]["secret_key"] == key + assert conns[0]["workspace"] == "org.a__ws1" and conns[0]["database"] == "shop" and "u:p@" not in json.dumps(conns[0]) + assert asyncio.run(gw._connections_of("org.a", hid))[0]["database"] == "shop" From 4e9b0f878ff7c34ca91027dc1d92696df4906521 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Tue, 22 Sep 2026 01:04:18 -0700 Subject: [PATCH 2/2] ci: run the checks on #240