Skip to content

Commit 402a772

Browse files
thinmintdevclaude
andauthored
release: v0.9.8 (#1304)
* fix(memory): enforce visibility:private ACL on delete in unified-bank mode In unified-bank mode (the default) every agent's visibility:private docs live in the single `shared` bank, isolated only by a read-time agent:<id> tag filter. delete() swept by document_id with no owner check, so any caller who knew/guessed another agent's (guessable) document_id could delete that agent's private memory — read/search/list enforce visibility, delete did not. delete() now resolves each target id's owner via _deletable_ids() and withholds any doc the caller can't see (fail-closed), mirroring the read paths. Legacy multi-bank mode is unchanged: _allowed_namespaces never yields a foreign private bank, so bank isolation already protects it. Residual surface hardening (no /api/memory auth, ungated /mcp/memory bulk delete) tracked in #1302. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(services): register turnstone as a companion service Turnstone shipped as a bundled agent but was never wired into the services surface the way hermes is, so it was absent from /api/services, the Services pane, the Overview health card, and the sidebar footer status strip. - Add a ServiceDef (unit hal0-agent@turnstone.service, systemd probe, loopback :9129, full lifecycle) → surfaces in /api/services with real status + start/stop/restart controls (generic systemd probe, like hindsight). - Add a _probe_turnstone systemd probe + block to /api/services/health so the Overview card and the footer status pip resolve turnstone (now 5 curated services). - Footer indicator (chrome.jsx) + dashboard-redesign SERVICE_ORDER slot turnstone next to hermes. - Update service page/health id-set + count tests; stub _probe_turnstone everywhere hermes is stubbed to keep the health tests hermetic. Turnstone runs as the shared `hal0` user (User=hal0 in the templated unit; no dedicated account), same as hermes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * release: v0.9.8 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aff99e5 commit 402a772

10 files changed

Lines changed: 263 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,34 @@ applying. Add those subsections to a version's section to surface them; see
2121

2222
## [Unreleased]
2323

24+
## [0.9.8] — 2026-07-13
25+
26+
Turnstone lands as a second heavyweight bundled agent alongside Hermes and
27+
becomes a first-class companion service; the unified **hal0-rocmfpx** runner
28+
becomes the default image for AMD GPUs (with an automatic slot migration on
29+
update); and a memory security fix stops one agent deleting another's private
30+
memories.
31+
32+
### Highlights
33+
- **Turnstone — a second heavyweight bundled agent** joins Hermes: a native `turnstone-server` on loopback :9129, installed into its own managed PyPI venv, coexisting with Hermes via relaxed single-pick (#1299).
34+
- **Turnstone is a first-class companion service** — it shows in the Services pane and the Overview health card with start/stop/restart controls, next to Hermes/Hindsight/OpenWebUI.
35+
- **hal0-rocmfpx is now the universal default runner for AMD GPUs** — one unified image (Vulkan/RADV + HIP) replaces the per-lane toolboxes; CUDA and CPU-only lanes keep their lean images (#1297).
36+
- **Memory: private-visibility is now enforced on delete** — in unified-bank mode one agent could delete another agent's `visibility:private` memory by id; `delete` now applies the same fail-closed ACL as read/search/list.
37+
38+
### Migrations
39+
- `hal0 update` automatically re-pins existing AMD-GPU slots from the old `amd-strix-halo-toolboxes` images to the unified `hal0-rocmfpx` runner (no-op on CUDA/CPU lanes) (#1297).
40+
41+
### Added
42+
- **Turnstone bundled agent** — provisioning pipeline (managed PyPI venv, JWT-secret generation, model automap) plus hal0 provider/memory wiring, coexisting with Hermes (#1299).
43+
- **Turnstone companion-service registration** — a `ServiceDef` + systemd health probe, so turnstone appears in `/api/services` (Services pane, full lifecycle actions) and `/api/services/health` (Overview card + sidebar status).
44+
45+
### Changed
46+
- **hal0-rocmfpx as the default AMD-GPU image** — basic seed profiles defer to a manifest-driven resolver that returns the unified runner for AMD lanes, the CUDA image for NVIDIA, and the lean toolbox for CPU-only (#1297).
47+
- PyPI distribution is published under the name **`hal0ai`** (the import package and `hal0`/`hal0-agent` console scripts are unchanged) (#1298).
48+
49+
### Fixed
50+
- **Memory delete ACL**`delete` enforces the `visibility:private` owner check in unified-bank mode, so an agent can no longer delete another agent's private memory by (guessable) document id; unresolved ids are withheld fail-closed (#1302).
51+
2452
## [0.9.7.3] — 2026-07-12
2553

2654
A robustness release. Fresh installs now adapt to the host — provisioning their

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
1010
# that resolves this project by distribution name must use "hal0ai".
1111
[project]
1212
name = "hal0ai"
13-
version = "0.9.7.3"
13+
version = "0.9.8"
1414
description = "Open-source home AI inference platform"
1515
readme = "README.md"
1616
requires-python = ">=3.12"

src/hal0/api/routes/services_health.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""GET /api/services/health — dashboard services health aggregator.
22
3-
Returns a stable list of four well-known services (comfyui, hermes,
4-
openwebui, n8n) with honest up/down state. Every source degrades
3+
Returns a stable list of five well-known services (comfyui, hermes,
4+
turnstone, openwebui, n8n) with honest up/down state. Every source degrades
55
gracefully — a probe failure yields up=false, never a 500.
66
7-
Real probes: comfyui (in-process /system_stats+/queue), hermes (systemd
8-
unit state), openwebui (loopback GET /health — SpikeB §5.4). n8n has no
9-
reachable probe from the API process (not deployed on this host) and
7+
Real probes: comfyui (in-process /system_stats+/queue), hermes + turnstone
8+
(systemd unit state), openwebui (loopback GET /health — SpikeB §5.4). n8n
9+
has no reachable probe from the API process (not deployed on this host) and
1010
reports up=false, detail="unmonitored".
1111
1212
HARD RULE: up=true requires a real signal. Services with no wired probe
@@ -38,6 +38,11 @@
3838

3939
router = APIRouter()
4040

41+
# Turnstone's systemd unit (bundled agent, loopback :9129) — probed the same
42+
# way as hermes. _HERMES_UNIT is imported from comfyui; turnstone has no such
43+
# shared constant, so name it locally.
44+
_TURNSTONE_UNIT = "hal0-agent@turnstone.service"
45+
4146
# OpenWebUI binds 0.0.0.0:3001 in hal0-openwebui.service (the port is fixed
4247
# in the unit — see config.py _OPENWEBUI_PORT). We probe it over loopback,
4348
# independent of the browser-facing public URL (_openwebui_url). The probe
@@ -113,6 +118,18 @@ async def _probe_hermes() -> tuple[bool, str]:
113118
return False, "systemd unit inactive or absent"
114119

115120

121+
async def _probe_turnstone() -> tuple[bool, str]:
122+
"""Probe Turnstone via systemd unit state — same mechanism as hermes.
123+
124+
Turnstone is a bundled agent running ``hal0-agent@turnstone.service``
125+
(loopback :9129). Real signal: the unit's active state.
126+
"""
127+
active = await _systemd_active(_TURNSTONE_UNIT)
128+
if active:
129+
return True, "systemd unit active"
130+
return False, "systemd unit inactive or absent"
131+
132+
116133
async def _probe_openwebui() -> tuple[bool, str]:
117134
"""Real reachability probe — GET <loopback>/health on OpenWebUI.
118135
@@ -141,14 +158,14 @@ async def _probe_n8n() -> tuple[bool, str]:
141158

142159
@router.get("/health")
143160
async def services_health() -> dict[str, Any]:
144-
"""Aggregate health of the four known hal0 companion services.
161+
"""Aggregate health of the five known hal0 companion services.
145162
146163
Response shape::
147164
148165
{
149166
"services": [
150167
{
151-
"id": "comfyui"|"hermes"|"openwebui"|"n8n",
168+
"id": "comfyui"|"hermes"|"turnstone"|"openwebui"|"n8n",
152169
"name": str,
153170
"up": bool,
154171
"detail": str,
@@ -199,6 +216,24 @@ async def services_health() -> dict[str, Any]:
199216
}
200217
)
201218

219+
# ── turnstone ────────────────────────────────────────────────────────────
220+
try:
221+
t_up, t_detail = await _probe_turnstone()
222+
except Exception as exc:
223+
log.warning("services_health.turnstone_probe_error", exc=repr(exc))
224+
t_up, t_detail = False, type(exc).__name__
225+
226+
services.append(
227+
{
228+
"id": "turnstone",
229+
"name": "Turnstone",
230+
"up": t_up,
231+
"detail": t_detail,
232+
"url": None, # loopback-only, no browser-reachable URL
233+
"stat": None,
234+
}
235+
)
236+
202237
# ── openwebui ─────────────────────────────────────────────────────────────
203238
try:
204239
ow_up, ow_detail = await _probe_openwebui()

src/hal0/memory/hindsight_provider.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
_ANONYMOUS = "anonymous"
4646
_UNKNOWN_AGENT = "unknown"
4747

48+
# Page size for the pre-delete visibility scan (unified mode). delete() lists
49+
# the caller's banks to resolve each target doc's owner before removing it; a
50+
# page big enough that a normal delete resolves in one round-trip.
51+
_DELETE_SCAN_PAGE = 500
52+
4853

4954
def _agent_of(tags: list[str] | tuple[str, ...] | None) -> str | None:
5055
"""Return the ``<id>`` of the first ``agent:<id>`` tag, else None."""
@@ -383,6 +388,47 @@ def _list_fact_to_item(fact: dict[str, Any], bank: str) -> dict[str, Any]:
383388
"type": fact.get("fact_type"),
384389
}
385390

391+
async def _deletable_ids(
392+
self, ids: set[str], banks: list[str], client_id: str | None
393+
) -> set[str]:
394+
"""Return the subset of ``ids`` the caller is allowed to delete under
395+
unified-bank visibility.
396+
397+
In unified mode the single ``shared`` bank holds every agent's
398+
``visibility:private`` docs, so a blind delete-by-id would let any
399+
caller remove another agent's private memory (read/search/list all
400+
enforce visibility; delete must too). A doc is deletable when it is
401+
non-private, or private and owned by the caller — the exact predicate
402+
the read paths use (:meth:`_is_visible_to`). An id we cannot resolve to
403+
a doc in the caller's banks is **withheld** (fail-closed), matching the
404+
fail-closed posture of the read filter.
405+
"""
406+
if not ids:
407+
return set()
408+
caller = self._caller_agent(client_id)
409+
wanted = set(ids)
410+
out: set[str] = set()
411+
for bank in banks:
412+
if not wanted - out:
413+
break
414+
offset = 0
415+
while wanted - out:
416+
try:
417+
resp = await self._client.list_memories(
418+
bank_id=bank, limit=_DELETE_SCAN_PAGE, offset=offset
419+
)
420+
except Exception:
421+
break # fail-soft per bank; unresolved ids stay withheld
422+
items = resp.get("items", [])
423+
for fact in items:
424+
fid = fact.get("id") or fact.get("document_id")
425+
if fid in wanted and fid not in out and self._is_visible_to(fact, caller):
426+
out.add(fid)
427+
if len(items) < _DELETE_SCAN_PAGE:
428+
break # last page
429+
offset += _DELETE_SCAN_PAGE
430+
return out
431+
386432
async def delete(
387433
self,
388434
ids: list[str],
@@ -400,7 +446,16 @@ async def delete(
400446
banks = [
401447
namespace_to_bank(ns) for ns in self._allowed_namespaces(dataset or _SHARED, client_id)
402448
]
449+
# Unified mode: gate deletes on the same visibility:private ACL the read
450+
# paths enforce, so one agent can't delete another's private doc by id.
451+
# Legacy multi-bank mode needs no gate — _allowed_namespaces never
452+
# yields a foreign private bank, so bank isolation already protects it.
453+
deletable = (
454+
await self._deletable_ids(set(ids), banks, client_id) if self._unified_bank else None
455+
)
403456
for document_id in ids:
457+
if deletable is not None and document_id not in deletable:
458+
continue # withheld: not visible to this caller (fail-closed)
404459
for bank in banks:
405460
try:
406461
res = await self._client.delete_document(bank_id=bank, document_id=document_id)

src/hal0/services/registry.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ class ServiceDef:
101101
actions=_FULL,
102102
loopback_port=9119,
103103
),
104+
ServiceDef(
105+
id="turnstone",
106+
name="Turnstone",
107+
description="Bundled agent (turnstone-server, loopback :9129).",
108+
unit="hal0-agent@turnstone.service",
109+
public_url_env="HAL0_TURNSTONE_PUBLIC_URL",
110+
probe="systemd",
111+
actions=_FULL,
112+
loopback_port=9129,
113+
),
104114
ServiceDef(
105115
id="hindsight",
106116
name="Hindsight",

tests/api/test_services_health.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
so the endpoint is reachable without touching the real app factory.
77
88
Covers:
9-
1. Endpoint returns 200 with all 4 service ids present.
9+
1. Endpoint returns 200 with all 5 service ids present.
1010
2. n8n is up=false, detail="unmonitored" (no real probe).
1111
3. With comfyui probe mocked reachable -> up=true, stat populated.
1212
4. comfyui probe raising -> comfyui up=false, endpoint still 200.
@@ -27,7 +27,7 @@
2727
from hal0.api.routes.services_health import router as services_router
2828

2929
_BASE = "hal0.api.routes.services_health"
30-
_EXPECTED_IDS = {"comfyui", "hermes", "openwebui", "n8n"}
30+
_EXPECTED_IDS = {"comfyui", "hermes", "turnstone", "openwebui", "n8n"}
3131

3232

3333
@pytest.fixture
@@ -43,7 +43,7 @@ def _services_by_id(body: dict) -> dict:
4343

4444

4545
def _stub_other_probes() -> list:
46-
"""Patch comfyui/hermes/openwebui to neutral down states so a test can
46+
"""Patch comfyui/hermes/turnstone/openwebui to neutral down states so a test can
4747
isolate ONE service without real network/systemd calls. Returns a list
4848
of started patchers the caller closes via an ExitStack, OR use as a
4949
context-manager group. Default: everything down/unmonitored.
@@ -59,6 +59,11 @@ def _stub_other_probes() -> list:
5959
new_callable=AsyncMock,
6060
return_value=(False, "systemd unit inactive or absent"),
6161
),
62+
patch(
63+
f"{_BASE}._probe_turnstone",
64+
new_callable=AsyncMock,
65+
return_value=(False, "systemd unit inactive or absent"),
66+
),
6267
patch(
6368
f"{_BASE}._probe_openwebui",
6469
new_callable=AsyncMock,
@@ -67,7 +72,7 @@ def _stub_other_probes() -> list:
6772
]
6873

6974

70-
# ── 1. shape: 200 + all 4 ids ─────────────────────────────────────────────────
75+
# ── 1. shape: 200 + all 5 ids ─────────────────────────────────────────────────
7176

7277

7378
def test_services_health_200_all_ids(svc_client: TestClient) -> None:
@@ -79,7 +84,7 @@ def test_services_health_200_all_ids(svc_client: TestClient) -> None:
7984
assert r.status_code == 200, r.text
8085
body = r.json()
8186
assert "services" in body
82-
assert len(body["services"]) == 4
87+
assert len(body["services"]) == 5
8388
ids = {s["id"] for s in body["services"]}
8489
assert ids == _EXPECTED_IDS
8590

@@ -116,6 +121,11 @@ def test_comfyui_reachable_up_true_stat_populated(svc_client: TestClient) -> Non
116121
new_callable=AsyncMock,
117122
return_value=(False, "systemd unit inactive or absent"),
118123
),
124+
patch(
125+
f"{_BASE}._probe_turnstone",
126+
new_callable=AsyncMock,
127+
return_value=(False, "systemd unit inactive or absent"),
128+
),
119129
patch(
120130
f"{_BASE}._probe_openwebui",
121131
new_callable=AsyncMock,
@@ -148,6 +158,11 @@ def test_comfyui_probe_raises_degrades_gracefully(svc_client: TestClient) -> Non
148158
new_callable=AsyncMock,
149159
return_value=(False, "systemd unit inactive or absent"),
150160
),
161+
patch(
162+
f"{_BASE}._probe_turnstone",
163+
new_callable=AsyncMock,
164+
return_value=(False, "systemd unit inactive or absent"),
165+
),
151166
patch(
152167
f"{_BASE}._probe_openwebui",
153168
new_callable=AsyncMock,
@@ -182,6 +197,11 @@ def test_openwebui_health_2xx_up_true(svc_client: TestClient) -> None:
182197
new_callable=AsyncMock,
183198
return_value=(False, "systemd unit inactive or absent"),
184199
),
200+
patch(
201+
f"{_BASE}._probe_turnstone",
202+
new_callable=AsyncMock,
203+
return_value=(False, "systemd unit inactive or absent"),
204+
),
185205
patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=ok_resp),
186206
):
187207
r = svc_client.get("/api/services/health")
@@ -205,6 +225,11 @@ def test_openwebui_unreachable_up_false(svc_client: TestClient) -> None:
205225
new_callable=AsyncMock,
206226
return_value=(False, "systemd unit inactive or absent"),
207227
),
228+
patch(
229+
f"{_BASE}._probe_turnstone",
230+
new_callable=AsyncMock,
231+
return_value=(False, "systemd unit inactive or absent"),
232+
),
208233
patch("httpx.AsyncClient.get", new_callable=AsyncMock, side_effect=conn_err),
209234
):
210235
r = svc_client.get("/api/services/health")
@@ -228,6 +253,11 @@ def test_openwebui_non_2xx_up_false(svc_client: TestClient) -> None:
228253
new_callable=AsyncMock,
229254
return_value=(False, "systemd unit inactive or absent"),
230255
),
256+
patch(
257+
f"{_BASE}._probe_turnstone",
258+
new_callable=AsyncMock,
259+
return_value=(False, "systemd unit inactive or absent"),
260+
),
231261
patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=bad_resp),
232262
):
233263
r = svc_client.get("/api/services/health")

tests/api/test_services_page.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from hal0.services.registry import SERVICES, service_by_id
3131

3232
_ROUTE = "hal0.api.routes.services"
33-
_EXPECTED_IDS = {"openwebui", "comfyui", "hermes", "hindsight", "honcho", "n8n"}
33+
_EXPECTED_IDS = {"openwebui", "comfyui", "hermes", "turnstone", "hindsight", "honcho", "n8n"}
3434

3535
_DOWN_STATE = {
3636
"active_state": "inactive",

0 commit comments

Comments
 (0)