Compare commits
18 Commits
124206b748
...
e1ef110c8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ef110c8a | ||
|
|
a75ca29934 | ||
|
|
681b040885 | ||
|
|
6964a9d8c8 | ||
|
|
74c6549965 | ||
|
|
aeef0f8484 | ||
|
|
1b1a14e972 | ||
|
|
b8fb72fbcc | ||
|
|
3ebee2cc15 | ||
|
|
9f0ffe031c | ||
|
|
01abf89270 | ||
|
|
b9b7126ee1 | ||
|
|
6a5e0d872b | ||
|
|
b4145861d2 | ||
|
|
1a586d9523 | ||
|
|
91eb4f92b7 | ||
|
|
c7aa7f462b | ||
|
|
e9a6eba30b |
@ -156,6 +156,7 @@ spec:
|
||||
dockerfiles/hermes_jetson_tts_cues.py
|
||||
PYTHONPATH=/tmp/hermes-voice-test-deps python3 -m pytest -q \
|
||||
testing/tests/test_hermes_stt_streaming.py \
|
||||
testing/tests/test_hermes_stt_rolling_model.py \
|
||||
testing/tests/test_hermes_tts_language_routing.py \
|
||||
testing/tests/test_hermes_voice_language_routing.py \
|
||||
testing/tests/test_hermes_oci_promote.py \
|
||||
|
||||
@ -17,6 +17,9 @@ ADD --checksum=sha256:aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c98392
|
||||
ADD --checksum=sha256:9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794 --chmod=0444 \
|
||||
https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt \
|
||||
/opt/models/whisper/small.pt
|
||||
ADD --checksum=sha256:65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9 --chmod=0444 \
|
||||
https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt \
|
||||
/opt/models/whisper/tiny.pt
|
||||
RUN chmod 0555 /opt/models /opt/models/whisper
|
||||
|
||||
COPY dockerfiles/hermes-jetson-stt-server.py /opt/atlas/hermes-jetson-stt-server.py
|
||||
@ -28,11 +31,12 @@ WORKDIR /opt/atlas
|
||||
|
||||
# Import the Xavier CUDA stack and confirm Whisper resolves the baked artifact.
|
||||
# Full GPU warm-up is covered by the Kubernetes startup probe on titan-21.
|
||||
RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=Path('/opt/models/whisper'); print(whisper.__file__, whisper.__version__, whisper.available_models()); assert {'large-v3-turbo','small'} <= set(whisper.available_models()); assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE((p/name).stat().st_mode)==0o444 for name in ('large-v3-turbo.pt','small.pt')); print(torch.__version__)"
|
||||
RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=Path('/opt/models/whisper'); print(whisper.__file__, whisper.__version__, whisper.available_models()); assert {'large-v3-turbo','small','tiny'} <= set(whisper.available_models()); assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE((p/name).stat().st_mode)==0o444 for name in ('large-v3-turbo.pt','small.pt','tiny.pt')); print(torch.__version__)"
|
||||
|
||||
ENV HERMES_STT_HOST=0.0.0.0 \
|
||||
HERMES_STT_PORT=9000 \
|
||||
HERMES_STT_MODEL=small \
|
||||
HERMES_STT_ROLLING_MODEL=tiny \
|
||||
HERMES_STT_CACHE=/opt/models/whisper \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
@ -130,6 +130,17 @@ def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -
|
||||
return value
|
||||
|
||||
|
||||
def secret_hits(data: bytes) -> list[str]:
|
||||
"""Secret-pattern classes found in text content; binary content is not scanned (SO-12)."""
|
||||
from hux.redaction import scrub_text
|
||||
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return []
|
||||
return scrub_text(text)[1]
|
||||
|
||||
|
||||
def _content(body: dict[str, Any]) -> tuple[bytes, str]:
|
||||
"""Decode the submitted content and return ``(bytes, mime)``."""
|
||||
text, encoded = body.get("content"), body.get("content_base64")
|
||||
@ -243,6 +254,9 @@ def create(request: Request) -> Response:
|
||||
sensitivity = body.get("sensitivity", "personal")
|
||||
if sensitivity not in SENSITIVITIES:
|
||||
raise Invalid("unknown sensitivity")
|
||||
hits = secret_hits(_content(body)[0])
|
||||
if hits:
|
||||
sensitivity = "restricted"
|
||||
stamp = now_iso()
|
||||
version: dict[str, Any] = {"version": 1, "created_at": stamp, "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body)}
|
||||
for field, limit in (("message_id", 120), ("note", 200)):
|
||||
@ -273,7 +287,7 @@ def create(request: Request) -> Response:
|
||||
_check(artifact)
|
||||
stored = request.store.put(FAMILY, artifact)
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("artifacts.create", stored["id"])
|
||||
request.audit("artifacts.create", stored["id"], reason="secret_pattern_restricted" if hits else "")
|
||||
emit_event(request.store, request.identity, stored, "artifact.created", f"Created {stored['type']} artifact", {"artifact_id": stored["id"], "version": 1})
|
||||
return Response(201, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
@ -318,6 +332,9 @@ def add_version(request: Request) -> Response:
|
||||
if expected != artifact["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
||||
number = artifact["current_version"] + 1
|
||||
hits = secret_hits(_content(body)[0])
|
||||
if hits:
|
||||
artifact = {**artifact, "sensitivity": "restricted"}
|
||||
entry: dict[str, Any] = {"version": number, "created_at": now_iso(), "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body), "diff_from": artifact["current_version"]}
|
||||
if body.get("diff_from") is not None:
|
||||
entry["diff_from"] = _version(artifact, body["diff_from"])["version"]
|
||||
@ -329,7 +346,7 @@ def add_version(request: Request) -> Response:
|
||||
entry["lineage"] = lineage
|
||||
stored = append_version(request.store, artifact, entry, expected)
|
||||
remember(request.store, FAMILY, key, f"{stored['id']}@{number}")
|
||||
request.audit("artifacts.version", f"{stored['id']}@{number}")
|
||||
request.audit("artifacts.version", f"{stored['id']}@{number}", reason="secret_pattern_restricted" if hits else "")
|
||||
emit_event(request.store, request.identity, stored, "artifact.version", f"New version {number}", {"artifact_id": stored["id"], "version": number})
|
||||
return Response(201, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
@ -97,17 +97,36 @@ def hashes_of(approval: dict[str, Any]) -> set[str]:
|
||||
return {e["hash"] for e in approval["request"].get("evidence", []) if e.get("kind") == "tool_call" and e.get("hash")}
|
||||
|
||||
|
||||
def run_conversation(store: TenantStore, run_id: str) -> str | None:
|
||||
"""The conversation a run belongs to, from its budget document or an approval it raised; never the body (F4)."""
|
||||
doc_id = f"bud_{policy.run_key(run_id)}"
|
||||
if store.exists(BUDGETS, doc_id) and store.get(BUDGETS, doc_id).get("_conversation_id"):
|
||||
return store.get(BUDGETS, doc_id)["_conversation_id"]
|
||||
for record in store.scan(policy.APPROVALS):
|
||||
if record["run_id"] == run_id:
|
||||
return record["conversation_id"]
|
||||
return None
|
||||
|
||||
|
||||
def matching_approval(store: TenantStore, run_id: str, capability: str, argument_hash: str, external: bool, conversation_id: str | None) -> tuple[dict[str, Any] | None, str, str | None]:
|
||||
"""The approval that releases this side effect, or why none does, plus the run's conversation when an approval names it."""
|
||||
"""The approval that releases this side effect, or why none does, plus the conversation the gate settled on.
|
||||
|
||||
External effects release only against an approval for the same run and
|
||||
the same argument hash, whatever the choice (F4, SO-39). Non-external
|
||||
session/always approvals stay reusable inside their conversation, which
|
||||
is the run's own conversation when the run is known and otherwise the
|
||||
approval's. A ``once`` approval names exactly one hash (SO-36, SO-37).
|
||||
"""
|
||||
reason = "no approval for this run and capability"
|
||||
for record in store.scan(policy.APPROVALS):
|
||||
record = policy.refresh(store, record)
|
||||
if record["capability"] != capability:
|
||||
continue
|
||||
same_run = record["run_id"] == run_id
|
||||
if same_run:
|
||||
conversation_id = conversation_id or record["conversation_id"]
|
||||
same_conv = bool(conversation_id) and record["conversation_id"] == conversation_id
|
||||
choice = record.get("decision", {}).get("choice")
|
||||
if record["capability"] != capability or not (same_run or (choice == "session" and same_conv)):
|
||||
reusable = choice in ("session", "always") and not external and not record["request"]["external"]
|
||||
same_conv = record["conversation_id"] == (conversation_id or record["conversation_id"])
|
||||
if not (same_run or (reusable and same_conv)):
|
||||
continue
|
||||
if record["status"] != "approved":
|
||||
reason = f"approval {record['id']} is {record['status']}"
|
||||
@ -118,13 +137,13 @@ def matching_approval(store: TenantStore, run_id: str, capability: str, argument
|
||||
if external and not record["request"]["external"]:
|
||||
reason = f"approval {record['id']} was not requested as external"
|
||||
continue
|
||||
if choice == "once" and argument_hash not in hashes_of(record):
|
||||
if (external or choice == "once") and argument_hash not in hashes_of(record):
|
||||
reason = f"approval {record['id']} was for different arguments"
|
||||
continue
|
||||
if choice == "once" and record.get("_consumed_at"):
|
||||
reason = f"approval {record['id']} was already consumed"
|
||||
continue
|
||||
return record, "released", conversation_id
|
||||
return record, "released", conversation_id or record["conversation_id"]
|
||||
return None, reason, conversation_id
|
||||
|
||||
|
||||
@ -139,9 +158,12 @@ def gate(request: Request) -> Response:
|
||||
if not isinstance(argument_hash, str) or not argument_hash.startswith("sha256:"):
|
||||
raise Invalid("argument_hash must be sha256:<hex>")
|
||||
external = bool(body.get("external", False))
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None:
|
||||
conversation_id = check_id(conversation_id)
|
||||
conversation_id = run_conversation(request.store, run_id)
|
||||
state = budget_state(request.store, request.identity, run_id, conversation_id)
|
||||
if state["exhausted"]: # F6: an exhausted run releases nothing, whatever was approved
|
||||
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="budget_exhausted")
|
||||
policy.emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=run_id)
|
||||
return Response(200, {"proceed": False, "reason": "budget_exhausted", "exhausted": state["exhausted"]})
|
||||
with request.store.lock(policy.APPROVALS):
|
||||
record, reason, conversation_id = matching_approval(request.store, run_id, capability, argument_hash, external, conversation_id)
|
||||
if record is not None and record["decision"]["choice"] == "once":
|
||||
@ -158,38 +180,49 @@ def gate(request: Request) -> Response:
|
||||
|
||||
# -- stop ----------------------------------------------------------------------
|
||||
|
||||
def stop_outcome(request: Request, body: dict[str, Any]) -> tuple[str, str]:
|
||||
"""(outcome, reason) for a stop; only the gateway (worker trust) may vouch for an empty process registry (F8, SO-41)."""
|
||||
if body.get("already_complete"):
|
||||
return "already_complete", "already_complete"
|
||||
if body.get("process_registry_empty") is not True:
|
||||
return "failed_to_cancel", "process_registry_not_empty"
|
||||
if request.identity.trust != "worker":
|
||||
return "failed_to_cancel", "registry_state_not_from_gateway"
|
||||
return "cancelled", "cancelled"
|
||||
|
||||
|
||||
def stop(request: Request) -> Response:
|
||||
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it."""
|
||||
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it.
|
||||
|
||||
A ``failed_to_cancel`` receipt is the one non-terminal outcome: a later
|
||||
stop that really cancels or finds the run complete supersedes it with a
|
||||
revision bump; an identical repeat still replays (F8).
|
||||
"""
|
||||
body = policy.body_dict(request)
|
||||
run_id = run_id_from(request)
|
||||
receipt_id = f"rcpt_{policy.run_key(run_id)}"
|
||||
side_effects = body.get("side_effects", [])
|
||||
if not isinstance(side_effects, list):
|
||||
raise Invalid("side_effects must be a list")
|
||||
outcome, reason = stop_outcome(request, body)
|
||||
with request.store.lock(RECEIPTS):
|
||||
if request.store.exists(RECEIPTS, receipt_id):
|
||||
existing = request.store.get(RECEIPTS, receipt_id)
|
||||
existing = request.store.get(RECEIPTS, receipt_id) if request.store.exists(RECEIPTS, receipt_id) else None
|
||||
if existing is not None and (existing["outcome"] != "failed_to_cancel" or outcome == "failed_to_cancel"):
|
||||
request.audit("runs.stop", receipt_id, reason="replayed")
|
||||
return Response(200, policy.public(existing))
|
||||
side_effects = body.get("side_effects", [])
|
||||
if not isinstance(side_effects, list):
|
||||
raise Invalid("side_effects must be a list")
|
||||
if body.get("already_complete"):
|
||||
outcome = "already_complete"
|
||||
elif body.get("process_registry_empty") is True:
|
||||
outcome = "cancelled"
|
||||
else:
|
||||
outcome = "failed_to_cancel"
|
||||
stamp = policy.iso(policy.now())
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.cancel_receipt.v1", "id": receipt_id, "run_id": run_id, "requested_by": policy.actor_for(request.identity),
|
||||
"requested_at": stamp, "acknowledged_at": stamp, "outcome": outcome, "side_effects": side_effects,
|
||||
"requested_at": existing["requested_at"] if existing else stamp, "acknowledged_at": stamp, "outcome": outcome, "side_effects": side_effects,
|
||||
}
|
||||
if outcome != "failed_to_cancel":
|
||||
record["completed_at"] = stamp
|
||||
conversation_id = body.get("conversation_id")
|
||||
conversation_id = body.get("conversation_id", existing.get("conversation_id") if existing else None)
|
||||
if conversation_id is not None:
|
||||
record["conversation_id"] = check_id(conversation_id)
|
||||
stored = request.store.put(RECEIPTS, policy.checked(record))
|
||||
request.audit("runs.stop", receipt_id, reason=outcome)
|
||||
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome}",
|
||||
stored = request.store.put(RECEIPTS, policy.checked(record), expected_revision=existing["revision"] if existing else None)
|
||||
request.audit("runs.stop", receipt_id, reason=reason if existing is None else f"superseded:{reason}")
|
||||
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome} ({reason})",
|
||||
run_id=run_id, evidence=[{"kind": "run", "id": receipt_id}])
|
||||
return Response(201, policy.public(stored))
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from collections.abc import Iterator
|
||||
|
||||
@ -47,14 +48,45 @@ def is_private(store: TenantStore, conversation_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _ledger_path(store: TenantStore, conversation_id: str) -> Path:
|
||||
return store.root / FAMILY / f"{conversation_id}.jsonl"
|
||||
|
||||
|
||||
def conversation_known(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""Ownership check (SO-18): the conversation exists somewhere in this subject's tree."""
|
||||
"""Ownership check (SO-18): the conversation document, its seq checkpoint or its event ledger exists in this subject's tree."""
|
||||
return (
|
||||
store.exists("conversations", conversation_id)
|
||||
or store.exists(SEQ_FAMILY, _seq_id(conversation_id))
|
||||
or _ledger_path(store, conversation_id).is_file()
|
||||
)
|
||||
|
||||
|
||||
def last_seq(store: TenantStore, conversation_id: str) -> int:
|
||||
"""The seq of the newest complete ledger line, read from the file tail (0 when the ledger is empty or absent).
|
||||
|
||||
A crash between the ledger append and the checkpoint write leaves the
|
||||
checkpoint behind the ledger; ``emit`` takes the larger of the two so a seq
|
||||
is never handed out twice (F5). Only the last ``LINE_CAP_BYTES`` window is
|
||||
read, and a torn trailing line is skipped.
|
||||
"""
|
||||
path = _ledger_path(store, conversation_id)
|
||||
if not path.is_file():
|
||||
return 0
|
||||
with open(path, "rb") as handle:
|
||||
handle.seek(0, 2)
|
||||
size = handle.tell()
|
||||
handle.seek(max(0, size - redaction.LINE_CAP_BYTES - 2))
|
||||
tail = handle.read()
|
||||
for line in reversed(tail.split(b"\n")):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
return int(json.loads(line).get("seq", 0))
|
||||
except (json.JSONDecodeError, AttributeError, ValueError):
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
def _actor(identity: Identity, kind: str) -> dict[str, str]:
|
||||
if kind in USER_KINDS and identity.trust != "worker":
|
||||
return {"type": "user", "id": identity.subject}
|
||||
@ -127,7 +159,7 @@ def emit_with_status(store: TenantStore, identity: Identity, conversation_id: st
|
||||
return existing, True
|
||||
seq_id = _seq_id(conversation_id)
|
||||
checkpoint = store.get(SEQ_FAMILY, seq_id) if store.exists(SEQ_FAMILY, seq_id) else {"id": seq_id, "next_seq": 1, "last_event_id": ""}
|
||||
record["seq"] = int(checkpoint["next_seq"])
|
||||
record["seq"] = max(int(checkpoint["next_seq"]), last_seq(store, conversation_id) + 1)
|
||||
problems = contracts.validate_record(record, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("event failed contract validation", problems)
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
Flags come from the ``HUX_FLAGS`` comma list. A card counts as enabled only
|
||||
when it and every card it depends on are enabled, so a half-configured
|
||||
deployment fails closed. Route ownership per card is declared here so the
|
||||
capabilities record can tell a client exactly what it may call.
|
||||
capabilities record can tell a client exactly what it may call, and the
|
||||
worker allowlist (SO-08) says which of those a ``trust: worker`` caller may
|
||||
reach at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -23,13 +25,37 @@ CARD_ROUTES: dict[str, list[str]] = {
|
||||
"HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"],
|
||||
"HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"],
|
||||
"HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"],
|
||||
"HUX-06": ["/hux/v1/modes", "/hux/v1/conversations/{id}/mode"],
|
||||
"HUX-06": [],
|
||||
"HUX-07": [],
|
||||
"HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"],
|
||||
"HUX-09": ["/hux/v1/suggestions", "/hux/v1/suggestions/{id}/{action}"],
|
||||
"HUX-09": [],
|
||||
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit"],
|
||||
"HUX-12": ["/hux/v1/releases"],
|
||||
"HUX-12": [],
|
||||
}
|
||||
# Cards whose routes are not shipped yet declare [] above until they land (F12).
|
||||
|
||||
# The only (method, template) pairs a ``trust: worker`` caller may reach (SO-08).
|
||||
# Everything else is 403 before the flag check. These are the agent-hook
|
||||
# routes: negotiation, the approval/gate/budget/stop loop, activity events,
|
||||
# the privacy policy, memory retrieval and proposals, research inputs and
|
||||
# artifact writes. The conversation read is there so the hook can honour
|
||||
# private mode (SO-28) before proposing a memory.
|
||||
WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
|
||||
("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"), ("GET", "/hux/v1/releases"),
|
||||
("POST", "/hux/v1/approvals"),
|
||||
("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"),
|
||||
("GET", "/hux/v1/runs/{id}/budget"),
|
||||
("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"),
|
||||
("GET", "/hux/v1/privacy/policy"),
|
||||
("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"),
|
||||
("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"),
|
||||
("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"),
|
||||
})
|
||||
|
||||
|
||||
def worker_may_call(method: str, template: str) -> bool:
|
||||
"""True when a ``trust: worker`` caller is allowed on this route (SO-08)."""
|
||||
return (method, template) in WORKER_ROUTES
|
||||
|
||||
|
||||
class Flags:
|
||||
|
||||
@ -18,8 +18,8 @@ from collections.abc import Mapping
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from hux import audit
|
||||
from hux.errors import HuxError, Invalid, NotFound, TooLarge
|
||||
from hux.flags import Flags, build_from_environ
|
||||
from hux.errors import Forbidden, HuxError, Invalid, NotFound, TooLarge
|
||||
from hux.flags import CONTRACT_VERSION, Flags, build_from_environ, worker_may_call
|
||||
from hux.identity import Identity, resolve
|
||||
from hux.store import TenantStore
|
||||
|
||||
@ -137,6 +137,10 @@ class Router:
|
||||
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
|
||||
return Response(405 if known else 404, error.record())
|
||||
try:
|
||||
# SO-08: the worker allowlist is checked before the flag so a
|
||||
# worker cannot even learn which cards are on.
|
||||
if identity.trust == "worker" and not worker_may_call(method, route.template):
|
||||
raise Forbidden("route is not available to worker trust")
|
||||
self.flags.require(route.card)
|
||||
payload = self._decode(body, route.max_body)
|
||||
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
|
||||
@ -145,6 +149,10 @@ class Router:
|
||||
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
|
||||
audit.record(store, identity, route.action, parts.path, outcome, error.message)
|
||||
return Response(error.status, error.record())
|
||||
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
|
||||
error = HuxError("internal error")
|
||||
audit.record(store, identity, route.action, parts.path, "deny", error.message)
|
||||
return Response(error.status, error.record())
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
@ -175,7 +183,7 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
|
||||
|
||||
def _run(self) -> None:
|
||||
if self.path == "/healthz":
|
||||
self._send(Response(200, {"status": "ok", "contract_version": "1.0.0"}))
|
||||
self._send(Response(200, {"status": "ok", "contract_version": CONTRACT_VERSION}))
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
|
||||
@ -76,20 +76,37 @@ def _persist(store: TenantStore, record: dict[str, Any], expected: int | None) -
|
||||
|
||||
def _transition(store: TenantStore, identity: Identity, record: dict[str, Any], target: str, action: str, actor: dict[str, str],
|
||||
note: str = "", expected: int | None = None, **changes: Any) -> dict[str, Any]:
|
||||
if not rules.transition_allowed(rules.MEMORY_TRANSITIONS, record["status"], target):
|
||||
raise Conflict(f"{record['status']} -> {target} is not a legal memory transition")
|
||||
stamp = now_iso()
|
||||
updated = {**record, **changes, "status": target, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": action, "actor": actor, **({"note": note[:200]} if note else {})}]}
|
||||
if target in {"forgotten", "rejected", "expired"}:
|
||||
updated["content"], updated["retrievable"] = "", False
|
||||
if target == "active":
|
||||
updated["retrievable"] = changes.get("retrievable", True)
|
||||
stored = _persist(store, updated, expected)
|
||||
if target == "forgotten":
|
||||
_tombstone(store, stored["id"], action)
|
||||
"""Move an entry to ``target`` from its *current* stored state (F7).
|
||||
|
||||
The caller's ``record`` may be stale: the entry is re-read under the family
|
||||
lock, ``expected`` (If-Match) is checked against that copy, and the write is
|
||||
always conditional on the loaded revision so a stale unconditional write can
|
||||
never resurrect a forgotten entry (SO-22, SO-44).
|
||||
"""
|
||||
with store.lock(FAMILY):
|
||||
record = _current(store, record, expected)
|
||||
if not rules.transition_allowed(rules.MEMORY_TRANSITIONS, record["status"], target):
|
||||
raise Conflict(f"{record['status']} -> {target} is not a legal memory transition")
|
||||
stamp = now_iso()
|
||||
updated = {**record, **changes, "status": target, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": action, "actor": actor, **({"note": note[:200]} if note else {})}]}
|
||||
if target in {"forgotten", "rejected", "expired"}:
|
||||
updated["content"], updated["retrievable"] = "", False
|
||||
if target == "active":
|
||||
updated["retrievable"] = changes.get("retrievable", True)
|
||||
stored = _persist(store, updated, record["revision"])
|
||||
if target == "forgotten":
|
||||
_tombstone(store, stored["id"], action)
|
||||
return stored
|
||||
|
||||
|
||||
def _current(store: TenantStore, record: dict[str, Any], expected: int | None) -> dict[str, Any]:
|
||||
"""Fresh copy of ``record`` from the store; Conflict when If-Match no longer matches it."""
|
||||
current = store.get(FAMILY, record["id"])
|
||||
if expected is not None and expected != current["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {current['revision']}", [str(current["revision"])])
|
||||
return current
|
||||
|
||||
|
||||
def load(store: TenantStore, memory_id: str, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Newest snapshot with lazy expiry (SO-27): an entry past its expiry is written as expired before it is served."""
|
||||
record = store.get(FAMILY, memory_id)
|
||||
@ -175,17 +192,12 @@ def _actor(request: Request) -> dict[str, str]:
|
||||
return {"type": "assistant", "id": "hermes"}
|
||||
|
||||
|
||||
def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""Build the entry from a proposal body and decide its approval mode from policy; returns (entry, gate_reason)."""
|
||||
def _classify(store: TenantStore, body: dict[str, Any], content: str, conversation_id: str | None) -> dict[str, Any]:
|
||||
"""Scrub ``content`` and decide topic, sensitivity floor and the privacy gate; shared by create and edit (F3)."""
|
||||
from hux import privacy
|
||||
|
||||
if "id" in body or "revision" in body or "status" in body:
|
||||
raise Invalid("id, revision and status are server-assigned")
|
||||
content = body.get("content")
|
||||
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
|
||||
raise Invalid("content must be a non-empty string of at most 2000 chars")
|
||||
hits: list[str] = []
|
||||
content = redaction.scrub_value(content.strip(), hits)
|
||||
content = redaction.scrub_value(content.strip(), hits)[:2000]
|
||||
detected = privacy.detect_topics(content) + (["credentials"] if hits else [])
|
||||
topic = body.get("topic") if body.get("topic") in rules.PRIVACY_TOPICS or body.get("topic") == "general" else None
|
||||
topic = topic if topic and topic != "general" else (detected[0] if detected else "general")
|
||||
@ -195,6 +207,35 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
|
||||
floor = privacy.topic_sensitivity(detected + ([topic] if topic != "general" else []))
|
||||
if privacy.SENSITIVITY_RANK[floor] > privacy.SENSITIVITY_RANK[sensitivity]:
|
||||
sensitivity = floor
|
||||
allowed, why = privacy.memory_write_allowed(store, conversation_id, {"sensitivity": sensitivity, "topic": topic})
|
||||
if why == "private_mode":
|
||||
raise Forbidden("memory writes are refused in private mode")
|
||||
return {"content": content, "topic": topic, "sensitivity": sensitivity, "allowed": allowed, "why": why}
|
||||
|
||||
|
||||
def _decline(entry: dict[str, Any], why: str, actor: dict[str, str], stamp: str) -> dict[str, Any]:
|
||||
"""Content-free ``no_store`` decision record.
|
||||
|
||||
The topic moves into the note because rules.memory_policy_violations reads a
|
||||
deny topic on any non-rejected status as a write, and a sensitive entry must
|
||||
say approval_mode=ask.
|
||||
"""
|
||||
topic = entry.pop("topic")
|
||||
entry.update(status="no_store", approval_mode="ask" if entry["sensitivity"] == "sensitive" else "no_store", content="", retrievable=False,
|
||||
audit=[{"at": stamp, "action": "no_store", "actor": actor, "note": f"{why}; topic={topic}"[:200]}])
|
||||
return entry
|
||||
|
||||
|
||||
def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""Build the entry from a proposal body and decide its approval mode from policy; returns (entry, gate_reason)."""
|
||||
if "id" in body or "revision" in body or "status" in body:
|
||||
raise Invalid("id, revision and status are server-assigned")
|
||||
content = body.get("content")
|
||||
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
|
||||
raise Invalid("content must be a non-empty string of at most 2000 chars")
|
||||
conversation_id = body.get("conversation_id") if isinstance(body.get("conversation_id"), str) else None
|
||||
verdict = _classify(request.store, body, content, conversation_id)
|
||||
topic, sensitivity = verdict["topic"], verdict["sensitivity"]
|
||||
scope = body.get("scope") if isinstance(body.get("scope"), dict) else {"level": "global"}
|
||||
source = body.get("source") if isinstance(body.get("source"), dict) else {"kind": "message", "id": "unspecified"}
|
||||
ttl = body.get("ttl") if isinstance(body.get("ttl"), dict) else None
|
||||
@ -203,7 +244,6 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
|
||||
ttl = {"policy": "decay", "decay_days": days}
|
||||
actor = _actor(request)
|
||||
stamp = now_iso()
|
||||
conversation_id = body.get("conversation_id") if isinstance(body.get("conversation_id"), str) else None
|
||||
provenance = {"surface": request.identity.surface, "actor": actor, "recorded_at": stamp}
|
||||
if conversation_id:
|
||||
provenance["conversation_id"] = conversation_id
|
||||
@ -211,7 +251,7 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
|
||||
provenance["run_id"] = body["run_id"][:120]
|
||||
entry: dict[str, Any] = {
|
||||
"schema": "hux.memory.v1", "id": new_id("mem"), "owner": request.identity.subject, "scope": scope,
|
||||
"kind": body.get("kind") if body.get("kind") in KINDS else "fact", "content": content, "status": "proposed",
|
||||
"kind": body.get("kind") if body.get("kind") in KINDS else "fact", "content": verdict["content"], "status": "proposed",
|
||||
"approval_mode": "ask", "sensitivity": sensitivity, "topic": topic, "ttl": ttl, "source": source,
|
||||
"provenance": provenance, "created_at": stamp, "updated_at": stamp,
|
||||
"audit": [{"at": stamp, "action": "proposed", "actor": actor}], "reason": str(body.get("reason") or "Proposed to be remembered.")[:280],
|
||||
@ -219,19 +259,12 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
|
||||
}
|
||||
if isinstance(body.get("supersedes"), str):
|
||||
entry["supersedes"] = body["supersedes"]
|
||||
allowed, why = privacy.memory_write_allowed(request.store, conversation_id, entry)
|
||||
if why == "private_mode":
|
||||
raise Forbidden("memory writes are refused in private mode")
|
||||
references_forgotten = entry.get("supersedes") in tombstoned(request.store) or source.get("id") in tombstoned(request.store)
|
||||
wants = body.get("approval_mode", "ask" if actor["type"] == "assistant" else "automatic")
|
||||
if not allowed or wants == "no_store":
|
||||
# Content-free decision record. The topic moves into the note because
|
||||
# rules.memory_policy_violations reads a deny topic on any non-rejected
|
||||
# status as a write, and a sensitive entry must say approval_mode=ask.
|
||||
why = why if not allowed else "declined"
|
||||
entry.pop("topic")
|
||||
entry.update(status="no_store", approval_mode="ask" if sensitivity == "sensitive" else "no_store", content="", retrievable=False,
|
||||
audit=[{"at": stamp, "action": "no_store", "actor": actor, "note": f"{why}; topic={topic}"[:200]}])
|
||||
why = verdict["why"]
|
||||
if not verdict["allowed"] or wants == "no_store":
|
||||
why = why if not verdict["allowed"] else "declined"
|
||||
_decline(entry, why, actor, stamp)
|
||||
elif sensitivity == "sensitive" or wants == "ask" or references_forgotten or actor["type"] != "user":
|
||||
entry["approval_mode"] = "ask"
|
||||
else:
|
||||
@ -240,26 +273,37 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
|
||||
return entry, why
|
||||
|
||||
|
||||
def _replay(request: Request, key: str) -> Response | None:
|
||||
for row in request.store.read(IDEM_FAMILY, "keys"):
|
||||
if row["idempotency_key"] == key:
|
||||
request.audit("memory.propose", row["memory_id"], "allow", "replayed")
|
||||
return Response(200, load(request.store, row["memory_id"]), {"HUX-Replayed": "true"})
|
||||
return None
|
||||
|
||||
|
||||
def post_memory(request: Request) -> Response:
|
||||
"""``POST /hux/v1/memory``: propose an entry; policy decides active, proposed (ask) or no_store (202)."""
|
||||
"""``POST /hux/v1/memory``: propose an entry; policy decides active, proposed (ask) or no_store (202).
|
||||
|
||||
The Idempotency-Key lookup, the write and the key mapping all happen under
|
||||
the family lock so concurrent retries with one key yield one record (F13b).
|
||||
"""
|
||||
body = request.body if isinstance(request.body, dict) else None
|
||||
if body is None:
|
||||
raise Invalid("body must be an object")
|
||||
key = request.idempotency_key()
|
||||
if key:
|
||||
for row in request.store.read(IDEM_FAMILY, "keys"):
|
||||
if row["idempotency_key"] == key:
|
||||
request.audit("memory.propose", row["memory_id"], "allow", "replayed")
|
||||
return Response(200, load(request.store, row["memory_id"]), {"HUX-Replayed": "true"})
|
||||
entry, why = _shape(request, body)
|
||||
try:
|
||||
stored = _persist(request.store, entry, None)
|
||||
except Invalid:
|
||||
request.store.append(FAMILY, TOMBSTONES, {"memory_id": entry["id"], "at": now_iso(), "reason": "policy_violation", "purged": True})
|
||||
_emit(request.store, request.identity, entry, "memory.suppressed", "Memory proposal suppressed by policy")
|
||||
raise
|
||||
if key:
|
||||
request.store.append(IDEM_FAMILY, "keys", {"idempotency_key": key, "memory_id": stored["id"], "at": stored["created_at"]})
|
||||
with request.store.lock(FAMILY):
|
||||
replayed = _replay(request, key) if key else None
|
||||
if replayed is not None:
|
||||
return replayed
|
||||
entry, why = _shape(request, body)
|
||||
try:
|
||||
stored = _persist(request.store, entry, None)
|
||||
except Invalid:
|
||||
request.store.append(FAMILY, TOMBSTONES, {"memory_id": entry["id"], "at": now_iso(), "reason": "policy_violation", "purged": True})
|
||||
_emit(request.store, request.identity, entry, "memory.suppressed", "Memory proposal suppressed by policy")
|
||||
raise
|
||||
if key:
|
||||
request.store.append(IDEM_FAMILY, "keys", {"idempotency_key": key, "memory_id": stored["id"], "at": stored["created_at"]})
|
||||
if stored["status"] == "no_store":
|
||||
_tombstone(request.store, stored["id"], why)
|
||||
_emit(request.store, request.identity, stored, "memory.suppressed", f"Not remembered ({why})")
|
||||
@ -299,26 +343,53 @@ def get_memory(request: Request) -> Response:
|
||||
|
||||
|
||||
def _edit(request: Request, record: dict[str, Any], actor: dict[str, str], expected: int | None) -> dict[str, Any]:
|
||||
"""Supersede ``record`` with edited content under the same privacy shaping as a proposal (F3).
|
||||
|
||||
A deny verdict (deny topic, restricted, memory disabled, conversation
|
||||
forgotten) yields a content-free ``no_store`` record and leaves the old
|
||||
entry untouched; a sensitive floor makes the replacement ``proposed`` with
|
||||
``approval_mode: ask``. Private mode is refused outright.
|
||||
"""
|
||||
body = request.body if isinstance(request.body, dict) else {}
|
||||
content = body.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise Invalid("edit needs new content")
|
||||
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
|
||||
raise Invalid("edit needs new content of at most 2000 chars")
|
||||
conversation_id = record["provenance"].get("conversation_id")
|
||||
verdict = _classify(request.store, {**body, "sensitivity": record["sensitivity"]}, content, conversation_id)
|
||||
stamp = now_iso()
|
||||
fresh = {
|
||||
**{k: v for k, v in record.items() if k not in {"revision", "supersedes"}}, "id": new_id("mem"), "content": verdict["content"],
|
||||
"topic": verdict["topic"], "sensitivity": verdict["sensitivity"], "status": "active", "approval_mode": "automatic", "retrievable": True,
|
||||
"created_at": stamp, "updated_at": stamp, "audit": [{"at": stamp, "action": "edited", "actor": actor, "note": f"edit of {record['id']}"}],
|
||||
}
|
||||
if not verdict["allowed"]:
|
||||
stored = _persist(request.store, _decline(fresh, verdict["why"], actor, stamp), None)
|
||||
_tombstone(request.store, stored["id"], verdict["why"])
|
||||
return stored
|
||||
old = _transition(request.store, request.identity, record, "forgotten", "superseded", actor, "edited", expected)
|
||||
from hux import events
|
||||
|
||||
events.redact_memory_references(request.store, old["id"])
|
||||
stamp = now_iso()
|
||||
fresh = {
|
||||
**{k: v for k, v in record.items() if k not in {"revision", "supersedes"}}, "id": new_id("mem"), "content": redaction.scrub_value(content.strip(), [])[:2000],
|
||||
"status": "active", "approval_mode": "automatic", "retrievable": True, "supersedes": old["id"], "created_at": stamp, "updated_at": stamp,
|
||||
"audit": [{"at": stamp, "action": "edited", "actor": actor, "note": f"supersedes {old['id']}"}, {"at": stamp, "action": "approved", "actor": actor}],
|
||||
}
|
||||
fresh["supersedes"] = old["id"]
|
||||
fresh["audit"][0]["note"] = f"supersedes {old['id']}"
|
||||
if fresh["sensitivity"] == "sensitive":
|
||||
fresh.update(status="proposed", approval_mode="ask", retrievable=False)
|
||||
fresh["audit"] = fresh["audit"][:1]
|
||||
else:
|
||||
fresh["audit"].append({"at": stamp, "action": "approved", "actor": actor})
|
||||
return _persist(request.store, fresh, None)
|
||||
|
||||
|
||||
def _set_retrievable(store: TenantStore, record: dict[str, Any], retrievable: bool, actor: dict[str, str], expected: int | None) -> dict[str, Any]:
|
||||
"""Flip ``retrievable`` on an active entry, re-reading it under the lock so a stale copy cannot overwrite a forget (F7)."""
|
||||
with store.lock(FAMILY):
|
||||
record = _current(store, record, expected)
|
||||
if record["status"] != "active":
|
||||
raise Conflict("retrieval can only change on active entries")
|
||||
stamp = now_iso()
|
||||
entry = {"at": stamp, "action": "approved" if retrievable else "retrieval_removed", "actor": actor, "note": "retrieval restored" if retrievable else ""}
|
||||
return _persist(store, {**record, "retrievable": retrievable, "updated_at": stamp, "audit": [*record["audit"], entry]}, record["revision"])
|
||||
|
||||
|
||||
def act_memory(request: Request) -> Response:
|
||||
"""``POST /hux/v1/memory/{id}/{action}``: approve, reject, forget, edit, remove_retrieval, restore_retrieval; every move emits a memory.* event."""
|
||||
memory_id, action = request.params["id"], request.params["action"]
|
||||
@ -349,13 +420,14 @@ def act_memory(request: Request) -> Response:
|
||||
_emit(store, identity, stored, "memory.forgotten", "Memory forgotten")
|
||||
elif action == "edit":
|
||||
stored = _edit(request, record, actor, expected)
|
||||
if stored["status"] == "no_store":
|
||||
_emit(store, identity, stored, "memory.suppressed", f"Edit not remembered ({stored['audit'][0]['note'].split(';')[0]})")
|
||||
request.audit("memory.edit", memory_id, "allow", "no_store")
|
||||
return Response(202, stored)
|
||||
_emit(store, identity, stored, "memory.committed" if stored["status"] == "active" else "memory.proposed", f"Memory edited, supersedes {record['id']}")
|
||||
else:
|
||||
if record["status"] != "active":
|
||||
raise Conflict("retrieval can only change on active entries")
|
||||
retrievable = action == "restore_retrieval"
|
||||
stamp = now_iso()
|
||||
stored = _persist(store, {**record, "retrievable": retrievable, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": "retrieval_removed" if not retrievable else "approved", "actor": actor, "note": "retrieval restored" if retrievable else ""}]}, expected)
|
||||
stored = _set_retrievable(store, record, retrievable, actor, expected)
|
||||
_emit(store, identity, stored, "memory.retrieval_removed" if not retrievable else "memory.committed", "Retrieval removed" if not retrievable else "Retrieval restored")
|
||||
request.audit(f"memory.{action}", memory_id, "allow", "" if expected is not None else "unconditional_write")
|
||||
return Response(200, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts
|
||||
from hux import contracts, redaction
|
||||
from hux.errors import Conflict, Invalid, NotFound
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.store import TenantStore, check_id, new_id, now_iso
|
||||
@ -63,8 +63,8 @@ def body_dict(request: Request) -> dict[str, Any]:
|
||||
|
||||
|
||||
def pick(body: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""Only the client-settable fields; ids, owner, timestamps and revision are server-set."""
|
||||
return {k: body[k] for k in fields if k in body}
|
||||
"""Only the client-settable fields, secret-scrubbed (F9); ids, owner, timestamps and revision are server-set."""
|
||||
return redaction.scrub_value({k: body[k] for k in fields if k in body}, [])
|
||||
|
||||
|
||||
def replay(store: TenantStore, family: str, key: str) -> dict[str, Any] | None:
|
||||
@ -233,14 +233,39 @@ def lineage(request: Request) -> Response:
|
||||
|
||||
# -- search --------------------------------------------------------------------
|
||||
|
||||
def artifact_titles(store: TenantStore, conversation: dict[str, Any]) -> list[str]:
|
||||
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
|
||||
def artifact_index(store: TenantStore) -> dict[str, list[str]]:
|
||||
"""Conversation id -> titles of the artifacts filed under it (F13c): both the conversation's ``artifact_ids`` and artifacts that name the conversation."""
|
||||
try:
|
||||
from hux import artifacts
|
||||
except ModuleNotFoundError:
|
||||
return []
|
||||
helper = getattr(artifacts, "artifact_titles", None)
|
||||
return list(helper(store, conversation["id"])) if helper else []
|
||||
return {}
|
||||
index: dict[str, list[str]] = {}
|
||||
for artifact in store.scan(artifacts.FAMILY):
|
||||
if isinstance(artifact.get("conversation_id"), str) and isinstance(artifact.get("title"), str):
|
||||
index.setdefault(artifact["conversation_id"], []).append(artifact["title"])
|
||||
for conversation in store.scan(CONVERSATIONS):
|
||||
ids = [i for i in conversation.get("artifact_ids", []) if isinstance(i, str)]
|
||||
titles = artifacts.artifact_titles(store, ids).values() if ids else []
|
||||
for title in titles:
|
||||
index.setdefault(conversation["id"], []).append(title)
|
||||
return index
|
||||
|
||||
|
||||
def artifact_titles(store: TenantStore, conversation: dict[str, Any], index: dict[str, list[str]] | None = None) -> list[str]:
|
||||
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
|
||||
index = artifact_index(store) if index is None else index
|
||||
return list(dict.fromkeys(index.get(conversation["id"], [])))
|
||||
|
||||
|
||||
def mark_forgotten(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""Blank a forgotten conversation's title and tags and archive it (F9); False when there is no document."""
|
||||
with store.lock(CONVERSATIONS):
|
||||
if not conversation_exists(store, conversation_id):
|
||||
return False
|
||||
current = store.get(CONVERSATIONS, conversation_id)
|
||||
record = {**current, "title": "[forgotten]", "tags": [], "archived": True, "updated_at": now_iso()}
|
||||
store.put(CONVERSATIONS, checked(record), expected_revision=current["revision"])
|
||||
return True
|
||||
|
||||
|
||||
def tokens(text: str) -> list[str]:
|
||||
@ -269,11 +294,12 @@ def search(request: Request) -> Response:
|
||||
raise Invalid("q is required")
|
||||
project_filter = request.query.get("project_id")
|
||||
names = {p["id"]: p["name"] for p in request.store.scan(PROJECTS)}
|
||||
index = artifact_index(request.store)
|
||||
ranked = []
|
||||
for record in request.store.scan(CONVERSATIONS):
|
||||
if project_filter and record.get("project_id") != project_filter:
|
||||
continue
|
||||
points = score(record, terms, names.get(record.get("project_id", ""), ""), artifact_titles(request.store, record))
|
||||
points = score(record, terms, names.get(record.get("project_id", ""), ""), artifact_titles(request.store, record, index))
|
||||
if points:
|
||||
ranked.append((points, record))
|
||||
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"]))
|
||||
|
||||
@ -61,9 +61,20 @@ def run_key(run_id: str) -> str:
|
||||
return hashlib.sha256(run_id.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def is_human(identity: Identity) -> bool:
|
||||
"""True when a person is behind the call: router/relay trust on a chat, telegram or voice surface."""
|
||||
return identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES
|
||||
|
||||
|
||||
def require_human(identity: Identity, what: str) -> None:
|
||||
"""Forbidden unless a human actor is behind the call (F1: policy writes and allow grants)."""
|
||||
if not is_human(identity):
|
||||
raise Forbidden(f"{what} only from a human surface")
|
||||
|
||||
|
||||
def actor_for(identity: Identity) -> dict[str, str]:
|
||||
"""The actor a record attributes to this caller: humans are users, hops are system."""
|
||||
if identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES:
|
||||
if is_human(identity):
|
||||
return {"type": "user", "id": identity.subject}
|
||||
return {"type": "system", "id": identity.surface}
|
||||
|
||||
@ -163,6 +174,8 @@ def normalise_grants(grants: Any, identity: Identity) -> list[dict[str, Any]]:
|
||||
raise Invalid("grant needs a known capability")
|
||||
if grant.get("decision") not in ("allow", "ask", "deny"):
|
||||
raise Invalid("grant decision must be allow, ask or deny")
|
||||
if grant["decision"] == "allow":
|
||||
require_human(identity, "allow grants")
|
||||
expires = grant.get("expires_at")
|
||||
if not isinstance(expires, str) or parse(expires) > ceiling:
|
||||
expires = iso(ceiling)
|
||||
@ -181,7 +194,8 @@ def get_policy(request: Request) -> Response:
|
||||
|
||||
|
||||
def put_policy(request: Request) -> Response:
|
||||
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body."""
|
||||
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body; humans only (F1)."""
|
||||
require_human(request.identity, "policy writes")
|
||||
body = body_dict(request)
|
||||
scope = body.get("scope") or {}
|
||||
if not isinstance(scope, dict):
|
||||
@ -189,6 +203,8 @@ def put_policy(request: Request) -> Response:
|
||||
record_id = policy_id(scope.get("level", ""), scope.get("scope_id"))
|
||||
if body.get("autonomy") not in ("ask_first", "safe", "autonomous"):
|
||||
raise Invalid("autonomy must be ask_first, safe or autonomous")
|
||||
if body["autonomy"] == "autonomous":
|
||||
require_human(request.identity, "autonomy escalation")
|
||||
budgets = body.get("budgets", dict(DEFAULT_BUDGETS))
|
||||
record = {
|
||||
"schema": "hux.policy.v1", "id": record_id, "owner": request.identity.subject, "scope": scope,
|
||||
@ -205,7 +221,8 @@ def put_policy(request: Request) -> Response:
|
||||
|
||||
|
||||
def add_grant(store: TenantStore, identity: Identity, level: str, scope_id: str | None, capability: str, ttl: timedelta) -> None:
|
||||
"""Append an allow grant to a scope's policy after a session/always decision."""
|
||||
"""Append an allow grant to a scope's policy after a session/always decision; the decider must be human."""
|
||||
require_human(identity, "allow grants")
|
||||
record_id = policy_id(level, scope_id)
|
||||
with store.lock(FAMILY):
|
||||
if store.exists(FAMILY, record_id):
|
||||
@ -273,6 +290,9 @@ def create_approval(request: Request) -> Response:
|
||||
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
|
||||
req = body.get("request") if isinstance(body.get("request"), dict) else {}
|
||||
external = bool(req.get("external", False))
|
||||
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
|
||||
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") > 1:
|
||||
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
|
||||
decision = resolve_request(policy, capability, external)
|
||||
stamp = now()
|
||||
record: dict[str, Any] = {
|
||||
|
||||
@ -186,11 +186,20 @@ def forget_conversation(store: TenantStore, identity: Identity, conversation_id:
|
||||
set_flag(store, conversation_id, "forgotten", True)
|
||||
forgotten_ids = memory.forget_from_conversation(store, identity, conversation_id)
|
||||
redacted = events.rewrite_full(store, conversation_id, "[forgotten conversation]", "conversation forgotten")
|
||||
counts = {"memory_forgotten": len(forgotten_ids), "events_redacted": redacted}
|
||||
counts = {"memory_forgotten": len(forgotten_ids), "events_redacted": redacted, "document_blanked": _blank_document(store, conversation_id)}
|
||||
store.append(FAMILY, "forgotten", {"conv_id": conversation_id, "requested_at": now_iso(), "purged_at": "", "counts": counts})
|
||||
return {"conversation_id": conversation_id, "forgotten": True, **counts}
|
||||
|
||||
|
||||
def _blank_document(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""Blank the HUX-03 conversation document (title, tags, archived) when the organisation lane holds one (F9)."""
|
||||
try:
|
||||
from hux import organization
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
return organization.mark_forgotten(store, conversation_id)
|
||||
|
||||
|
||||
# -- routes ---------------------------------------------------------------------
|
||||
|
||||
def _strip(record: dict[str, Any]) -> dict[str, Any]:
|
||||
@ -225,8 +234,12 @@ def post_notice(request: Request) -> Response:
|
||||
set_flag(request.store, conversation_id, "memory_disabled", True)
|
||||
elif chosen == "forget_this_conversation":
|
||||
forget_conversation(request.store, request.identity, conversation_id)
|
||||
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
|
||||
request.audit("privacy.notice", conversation_id)
|
||||
# F13a: a notice for a conversation this subject never had must not conjure
|
||||
# an event ledger for it; the notice itself is still on record.
|
||||
known = events.conversation_known(request.store, conversation_id)
|
||||
if known:
|
||||
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
|
||||
request.audit("privacy.notice", conversation_id, "allow", "" if known else "unknown_conversation")
|
||||
return Response(201, notice)
|
||||
|
||||
|
||||
|
||||
@ -136,8 +136,12 @@ def filter_detail(kind: str, detail: dict[str, Any] | None) -> dict[str, Any]:
|
||||
kept["steps"] = [str(step)[:200] for step in steps[:20]]
|
||||
if kind == "tool.call":
|
||||
# Raw arguments never persist: only their names, a hash and a byte count.
|
||||
path = kept.get("target_path")
|
||||
if not (isinstance(path, str) and path.startswith(WORKSPACE_PREFIX + "/")):
|
||||
# F13d: normalise before the prefix check so ``workspace/../.env`` cannot
|
||||
# pass as a workspace path; the stored value is the normalised one.
|
||||
path = os.path.normpath(kept["target_path"]) if isinstance(kept.get("target_path"), str) else ""
|
||||
if path.startswith(WORKSPACE_PREFIX + "/"):
|
||||
kept["target_path"] = path
|
||||
else:
|
||||
kept.pop("target_path", None)
|
||||
if "argument_names" in kept:
|
||||
names = kept["argument_names"] if isinstance(kept["argument_names"], list) else []
|
||||
|
||||
@ -16,6 +16,7 @@ from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from hux.artifacts import remember, replay
|
||||
from hux import redaction
|
||||
from hux.contracts import load_all, validate_record
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
@ -62,6 +63,11 @@ def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -
|
||||
return value
|
||||
|
||||
|
||||
def _clean(value: Any) -> Any:
|
||||
"""Secret-scrub free text before it is stored (F9); ids and enums never carry secrets so they skip this."""
|
||||
return redaction.scrub_value(value, [])
|
||||
|
||||
|
||||
def _sha(*parts: str) -> str:
|
||||
return "sha256:" + hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
@ -127,7 +133,7 @@ def create_source(request: Request) -> Response:
|
||||
classification = body.get("classification", "unknown")
|
||||
if classification not in CLASSIFICATIONS:
|
||||
raise Invalid("unknown classification")
|
||||
title = _string(body, "title", 300)
|
||||
title = _clean(_string(body, "title", 300))
|
||||
uri = _string(body, "uri", 2000, required=False)
|
||||
dedupe = _sha("uri", normalise_uri(uri)) if uri is not None else _sha("title", kind, title.strip().lower())
|
||||
stamp = now_iso()
|
||||
@ -180,7 +186,7 @@ def create_passage(request: Request) -> Response:
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
source = _resolve(request.store, SOURCES, body.get("source_id"))
|
||||
text = _string(body, "text", 4000)
|
||||
text = _clean(_string(body, "text", 4000))
|
||||
text_hash = "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
dedupe = _sha("passage", source["id"], text_hash)
|
||||
with request.store.lock(FAMILY):
|
||||
@ -215,7 +221,7 @@ def attach_citation(request: Request) -> Response:
|
||||
message_id = request.params["id"]
|
||||
if len(message_id) > 120:
|
||||
raise Invalid("message id is too long")
|
||||
claim = _string(body, "claim", 1000)
|
||||
claim = _clean(_string(body, "claim", 1000))
|
||||
support = body.get("support", "unverified")
|
||||
if support not in SUPPORT:
|
||||
raise Invalid("unknown support verdict")
|
||||
@ -231,7 +237,7 @@ def attach_citation(request: Request) -> Response:
|
||||
return Response(200, _resolve(request.store, CITATIONS, existing), {"HUX-Replayed": "true"})
|
||||
record: dict[str, Any] = {"schema": "hux.citation.v1", "id": new_id("cit"), "message_id": message_id, "claim": claim, "passage_ids": passage_ids, "support": support, "dedupe_key": dedupe}
|
||||
if _string(body, "note", 500, required=False) is not None:
|
||||
record["note"] = body["note"]
|
||||
record["note"] = _clean(body["note"])
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(CITATIONS, record)
|
||||
@ -287,7 +293,7 @@ def _text_list(body: dict[str, Any], key: str, current: list[str]) -> list[str]:
|
||||
return current
|
||||
if not isinstance(values, list) or not all(isinstance(v, str) and v.strip() for v in values):
|
||||
raise Invalid(f"{key} must be a list of non-empty strings")
|
||||
return values
|
||||
return _clean(values)
|
||||
|
||||
|
||||
def create_notebook(request: Request) -> Response:
|
||||
@ -301,7 +307,7 @@ def create_notebook(request: Request) -> Response:
|
||||
return Response(200, request.store.get(NOTEBOOKS, existing), {"HUX-Replayed": "true"})
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.research_notebook.v1", "id": new_id("nb"), "conversation_id": check_id(body.get("conversation_id")),
|
||||
"question": _string(body, "question", 1000), "status": "open", "source_ids": [], "passage_ids": [], "citation_ids": [],
|
||||
"question": _clean(_string(body, "question", 1000)), "status": "open", "source_ids": [], "passage_ids": [], "citation_ids": [],
|
||||
"assumptions": _text_list(body, "assumptions", []), "unresolved_questions": _text_list(body, "unresolved_questions", []),
|
||||
"updated_at": now_iso(), "notes": [], "revision": 1,
|
||||
}
|
||||
@ -350,7 +356,7 @@ def patch_notebook(request: Request) -> Response:
|
||||
if not isinstance(notes, list) or not all(isinstance(n, dict) for n in notes):
|
||||
raise Invalid("add_notes must be a list of objects")
|
||||
for note in notes:
|
||||
entry = {"at": stamp, "text": _string(note, "text", 2000)}
|
||||
entry = {"at": stamp, "text": _clean(_string(note, "text", 2000))}
|
||||
if note.get("source_id") is not None:
|
||||
entry["source_id"] = _resolve(request.store, SOURCES, note["source_id"])["id"]
|
||||
updated["notes"] = [*updated["notes"], entry]
|
||||
|
||||
@ -25,6 +25,7 @@ import whisper
|
||||
HOST = os.getenv("HERMES_STT_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("HERMES_STT_PORT", "9000"))
|
||||
MODEL_NAME = os.getenv("HERMES_STT_MODEL", "small")
|
||||
ROLLING_MODEL_NAME = os.getenv("HERMES_STT_ROLLING_MODEL", "tiny")
|
||||
CACHE_DIR = Path(os.getenv("HERMES_STT_CACHE", "/cache/whisper"))
|
||||
MAX_AUDIO_BYTES = 30 * 1024 * 1024
|
||||
STREAM_SAMPLE_RATE = 16_000
|
||||
@ -68,6 +69,8 @@ class InferenceGate:
|
||||
self._last_final_wait_ms = 0.0
|
||||
self._last_final_decode_ms = 0.0
|
||||
self._last_background_decode_ms = 0.0
|
||||
self._last_rolling_decode_ms = 0.0
|
||||
self._last_speculative_decode_ms = 0.0
|
||||
self._background_skips = 0
|
||||
|
||||
def run_final(self, callback):
|
||||
@ -87,14 +90,14 @@ class InferenceGate:
|
||||
return callback()
|
||||
finally:
|
||||
with self._condition:
|
||||
self._last_final_decode_ms = (
|
||||
time.monotonic() - decode_started
|
||||
) * 1000
|
||||
self._last_final_decode_ms = (time.monotonic() - decode_started) * 1000
|
||||
self._busy = False
|
||||
self._condition.notify_all()
|
||||
|
||||
def try_background(self, callback):
|
||||
def try_background(self, callback, *, kind: str):
|
||||
"""Run one useful predecode only when no final is active or waiting."""
|
||||
if kind not in {"rolling", "speculative"}:
|
||||
raise ValueError("unknown background inference kind")
|
||||
with self._condition:
|
||||
if self._busy or self._final_waiters:
|
||||
self._background_skips += 1
|
||||
@ -105,9 +108,12 @@ class InferenceGate:
|
||||
return callback()
|
||||
finally:
|
||||
with self._condition:
|
||||
self._last_background_decode_ms = (
|
||||
time.monotonic() - decode_started
|
||||
) * 1000
|
||||
elapsed_ms = (time.monotonic() - decode_started) * 1000
|
||||
self._last_background_decode_ms = elapsed_ms
|
||||
if kind == "rolling":
|
||||
self._last_rolling_decode_ms = elapsed_ms
|
||||
else:
|
||||
self._last_speculative_decode_ms = elapsed_ms
|
||||
self._busy = False
|
||||
self._condition.notify_all()
|
||||
|
||||
@ -119,8 +125,10 @@ class InferenceGate:
|
||||
"final_waiters": self._final_waiters,
|
||||
"last_final_wait_ms": round(self._last_final_wait_ms, 1),
|
||||
"last_final_decode_ms": round(self._last_final_decode_ms, 1),
|
||||
"last_background_decode_ms": round(
|
||||
self._last_background_decode_ms, 1
|
||||
"last_background_decode_ms": round(self._last_background_decode_ms, 1),
|
||||
"last_rolling_decode_ms": round(self._last_rolling_decode_ms, 1),
|
||||
"last_speculative_decode_ms": round(
|
||||
self._last_speculative_decode_ms, 1
|
||||
),
|
||||
"background_skips": self._background_skips,
|
||||
}
|
||||
@ -128,6 +136,7 @@ class InferenceGate:
|
||||
|
||||
INFERENCE_GATE = InferenceGate()
|
||||
STARTUP_WARMUP_MS: float | None = None
|
||||
STARTUP_ROLLING_WARMUP_MS: float | None = None
|
||||
|
||||
|
||||
def _repetitive_token(token: str) -> bool:
|
||||
@ -264,20 +273,19 @@ def _warm_model(model: object) -> float:
|
||||
return (time.monotonic() - started) * 1000
|
||||
|
||||
|
||||
def _transcribe_pcm_rolling(
|
||||
model: object, pcm: bytes, language: str
|
||||
) -> dict | None:
|
||||
def _transcribe_pcm_rolling(model: object, pcm: bytes, language: str) -> dict | None:
|
||||
"""Decode a rolling window only when the single GPU worker is immediately free.
|
||||
|
||||
Partial work is disposable. It must never form an inference backlog ahead of
|
||||
a final utterance or another user's request, so a busy model means this
|
||||
snapshot is skipped and a later audio frame may try again.
|
||||
speech epoch is skipped rather than retried.
|
||||
"""
|
||||
temp_path = ""
|
||||
try:
|
||||
temp_path = _write_pcm_wav(pcm)
|
||||
return INFERENCE_GATE.try_background(
|
||||
lambda: _decode_path(model, temp_path, language)
|
||||
lambda: _decode_path(model, temp_path, language),
|
||||
kind="rolling",
|
||||
)
|
||||
finally:
|
||||
if temp_path:
|
||||
@ -295,7 +303,8 @@ def _transcribe_pcm_speculative(
|
||||
try:
|
||||
temp_path = _write_pcm_wav(pcm)
|
||||
return INFERENCE_GATE.try_background(
|
||||
lambda: _decode_path(model, temp_path, language)
|
||||
lambda: _decode_path(model, temp_path, language),
|
||||
kind="speculative",
|
||||
)
|
||||
finally:
|
||||
if temp_path:
|
||||
@ -462,9 +471,15 @@ def _pcm_rms(pcm: bytes) -> float:
|
||||
class StreamingTranscription:
|
||||
"""Own one turn's bounded PCM, VAD state and speculative Whisper work."""
|
||||
|
||||
def __init__(self, connection: WebSocketConnection, model: object) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
connection: WebSocketConnection,
|
||||
model: object,
|
||||
rolling_model: object | None = None,
|
||||
) -> None:
|
||||
self.connection = connection
|
||||
self.model = model
|
||||
self.rolling_model = rolling_model if rolling_model is not None else model
|
||||
self.turn_id = ""
|
||||
self.language = "auto"
|
||||
self._pcm = bytearray()
|
||||
@ -489,6 +504,7 @@ class StreamingTranscription:
|
||||
self._rolling_previous = ""
|
||||
self._rolling_previous_window_start = 0
|
||||
self._rolling_revision = 0
|
||||
self._rolling_attempted = False
|
||||
|
||||
def _response(self, message_type: str, **values: object) -> dict:
|
||||
return {"type": message_type, "turn_id": self.turn_id, **values}
|
||||
@ -543,7 +559,7 @@ class StreamingTranscription:
|
||||
start_ms = start * 1000 // (STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH)
|
||||
return pcm, start_ms
|
||||
|
||||
def _reset_rolling_history_locked(self) -> None:
|
||||
def _reset_rolling_history_locked(self, *, rearm: bool) -> None:
|
||||
"""Discard provisional agreement after the client's speech epoch changes."""
|
||||
self._rolling_previous = ""
|
||||
self._rolling_previous_window_start = 0
|
||||
@ -551,10 +567,10 @@ class StreamingTranscription:
|
||||
self._rolling_speech_bytes = 0
|
||||
self._rolling_last_speech_bytes = 0
|
||||
self._rolling_last_started_at = 0.0
|
||||
if rearm:
|
||||
self._rolling_attempted = False
|
||||
|
||||
def _begin_rolling_locked(
|
||||
self, now: float
|
||||
) -> tuple[bytes, int, int, int] | None:
|
||||
def _begin_rolling_locked(self, now: float) -> tuple[bytes, int, int, int] | None:
|
||||
"""Reserve one due rolling decode without ever queuing a second one."""
|
||||
total_bytes = len(self._pcm)
|
||||
minimum_bytes = _pcm_bytes_for_ms(ROLLING_MIN_AUDIO_MS)
|
||||
@ -564,17 +580,20 @@ class StreamingTranscription:
|
||||
)
|
||||
if (
|
||||
self._rolling_inflight
|
||||
or self._rolling_attempted
|
||||
or self._rolling_speech_bytes < minimum_bytes
|
||||
or self._rolling_speech_bytes - self._rolling_last_speech_bytes
|
||||
< required_new_bytes
|
||||
or (
|
||||
self._rolling_last_started_at
|
||||
and (now - self._rolling_last_started_at) * 1000
|
||||
< ROLLING_INTERVAL_MS
|
||||
and (now - self._rolling_last_started_at) * 1000 < ROLLING_INTERVAL_MS
|
||||
)
|
||||
):
|
||||
return None
|
||||
pcm, window_start_ms = self._rolling_snapshot_locked()
|
||||
# Reserve the epoch before spawning. A busy gate, decode error or stale
|
||||
# result must not create a retry loop that can collide with the final.
|
||||
self._rolling_attempted = True
|
||||
self._rolling_inflight = True
|
||||
self._rolling_last_started_at = now
|
||||
self._rolling_last_speech_bytes = self._rolling_speech_bytes
|
||||
@ -592,7 +611,11 @@ class StreamingTranscription:
|
||||
def worker() -> None:
|
||||
payload: dict | None = None
|
||||
try:
|
||||
payload = _transcribe_pcm_rolling(self.model, pcm, self.language)
|
||||
payload = _transcribe_pcm_rolling(
|
||||
self.rolling_model,
|
||||
pcm,
|
||||
self.language,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Partial inference is best-effort. The canonical commit path
|
||||
# remains authoritative and reports its own actionable errors.
|
||||
@ -670,7 +693,7 @@ class StreamingTranscription:
|
||||
self._epoch += 1
|
||||
self._cached_key = ""
|
||||
self._cached_payload = None
|
||||
self._reset_rolling_history_locked()
|
||||
self._reset_rolling_history_locked(rearm=True)
|
||||
self._heard_speech = True
|
||||
self._at_eos = False
|
||||
self._silence_bytes = 0
|
||||
@ -687,7 +710,7 @@ class StreamingTranscription:
|
||||
)
|
||||
if self._silence_bytes >= required and not self._at_eos:
|
||||
self._at_eos = True
|
||||
self._reset_rolling_history_locked()
|
||||
self._reset_rolling_history_locked(rearm=False)
|
||||
auto_speculate = True
|
||||
if auto_speculate:
|
||||
self.speculate()
|
||||
@ -705,7 +728,7 @@ class StreamingTranscription:
|
||||
self._client_active = True
|
||||
self._cached_key = ""
|
||||
self._cached_payload = None
|
||||
self._reset_rolling_history_locked()
|
||||
self._reset_rolling_history_locked(rearm=True)
|
||||
|
||||
def speculate(self) -> None:
|
||||
"""Decode a stable snapshot once; only an unchanged turn may consume it."""
|
||||
@ -846,11 +869,19 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
{
|
||||
"ok": True,
|
||||
"model": MODEL_NAME,
|
||||
"rolling_model": ROLLING_MODEL_NAME,
|
||||
"device": "cuda" if torch.cuda.is_available() else "cpu",
|
||||
"inference": INFERENCE_GATE.snapshot(),
|
||||
"startup": {
|
||||
"warmed": STARTUP_WARMUP_MS is not None,
|
||||
"warmed": (
|
||||
STARTUP_WARMUP_MS is not None
|
||||
and STARTUP_ROLLING_WARMUP_MS is not None
|
||||
),
|
||||
"warmup_ms": round(STARTUP_WARMUP_MS or 0.0, 1),
|
||||
"rolling_warmup_ms": round(
|
||||
STARTUP_ROLLING_WARMUP_MS or 0.0,
|
||||
1,
|
||||
),
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": True,
|
||||
@ -909,6 +940,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
session = StreamingTranscription(
|
||||
websocket,
|
||||
self.server.model, # type: ignore[attr-defined]
|
||||
self.server.rolling_model, # type: ignore[attr-defined]
|
||||
)
|
||||
close_code = 1000
|
||||
close_reason = ""
|
||||
@ -1018,16 +1050,32 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def main() -> None:
|
||||
"""Warm Whisper once, then serve concurrent clients through one GPU lock."""
|
||||
global STARTUP_WARMUP_MS
|
||||
global STARTUP_ROLLING_WARMUP_MS, STARTUP_WARMUP_MS
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not torch.cuda.is_available():
|
||||
raise RuntimeError("CUDA is required for the Atlas Whisper service")
|
||||
print(f"[stt] loading Whisper {MODEL_NAME} into CUDA", flush=True)
|
||||
print(f"[stt] loading Whisper {MODEL_NAME} for final CUDA", flush=True)
|
||||
model = whisper.load_model(MODEL_NAME, device="cuda", download_root=str(CACHE_DIR))
|
||||
print(f"[stt] loading Whisper {ROLLING_MODEL_NAME} for rolling CUDA", flush=True)
|
||||
rolling_model = whisper.load_model(
|
||||
ROLLING_MODEL_NAME,
|
||||
device="cuda",
|
||||
download_root=str(CACHE_DIR),
|
||||
)
|
||||
STARTUP_ROLLING_WARMUP_MS = _warm_model(rolling_model)
|
||||
print(
|
||||
"[stt] rolling CUDA warm-up completed in " f"{STARTUP_ROLLING_WARMUP_MS:.1f}ms",
|
||||
flush=True,
|
||||
)
|
||||
# Warm the authoritative model last so the first real final never inherits
|
||||
# lazy CUDA work from either model's startup path.
|
||||
STARTUP_WARMUP_MS = _warm_model(model)
|
||||
print(f"[stt] CUDA warm-up completed in {STARTUP_WARMUP_MS:.1f}ms", flush=True)
|
||||
print(
|
||||
f"[stt] final CUDA warm-up completed in {STARTUP_WARMUP_MS:.1f}ms", flush=True
|
||||
)
|
||||
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
|
||||
server.model = model # type: ignore[attr-defined]
|
||||
server.rolling_model = rolling_model # type: ignore[attr-defined]
|
||||
print(f"[stt] ready on {HOST}:{PORT}", flush=True)
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
|
||||
|
||||
@ -638,12 +638,16 @@
|
||||
if(Number.isFinite(revision)&&revision>partialRevision){
|
||||
partialRevision=revision;
|
||||
const stable=String(payload.stable_transcript||'').trim();
|
||||
if(stable&&active&&captureTurnId===turnId&&state==='listening'){
|
||||
const preview=stable.length>72?stable.slice(0,69)+'…':stable;
|
||||
label.textContent='Listening · '+preview;
|
||||
const provisional=String(payload.transcript||'').trim();
|
||||
const visible=stable||provisional;
|
||||
if(visible&&active&&captureTurnId===turnId&&state==='listening'){
|
||||
const preview=visible.length>72?visible.slice(0,69)+'…':visible;
|
||||
label.textContent='Listening · '+preview+(stable?'':' · provisional');
|
||||
if(stable!==lastPreflightText){
|
||||
lastPreflightText=stable;
|
||||
scheduleVoicePreflight(turnId,revision,stable);
|
||||
if(stable){
|
||||
lastPreflightText=stable;
|
||||
scheduleVoicePreflight(turnId,revision,stable);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,13 +78,22 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:root.dark .messages {
|
||||
:root.dark .messages-shell {
|
||||
background-image:
|
||||
linear-gradient(var(--hermes-grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--hermes-grid) 1px, transparent 1px),
|
||||
radial-gradient(circle at 76% 22%, rgba(76, 164, 205, 0.055), transparent 30rem),
|
||||
radial-gradient(circle at 22% 78%, var(--hermes-violet-muted), transparent 35rem);
|
||||
background-size: 32px 32px, 32px 32px, auto, auto;
|
||||
background-repeat: repeat, repeat, no-repeat, no-repeat;
|
||||
}
|
||||
|
||||
/* The transcript is an accelerated scroll surface. Painting gradients on it
|
||||
produces stale compositor tiles on Chromium as the scroll offset changes,
|
||||
which looks like a dark rectangle moving along the right edge. Keep the
|
||||
scroll surface transparent and paint the texture on its stationary shell. */
|
||||
:root.dark .messages {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
:root.dark .rail {
|
||||
|
||||
83
dockerfiles/hermes-worker-hux/hux_hook/NOTES.md
Normal file
83
dockerfiles/hermes-worker-hux/hux_hook/NOTES.md
Normal file
@ -0,0 +1,83 @@
|
||||
# hux_hook: wiring notes for the agent runtime patch
|
||||
|
||||
`hux_hook` is a stdlib-only library the Hermes agent process imports. HUX
|
||||
(`hermes-hux-foundation`, loopback `127.0.0.1:8790` in the same pod) is the
|
||||
source of truth; the agent never decides an approval, never reads the tenant
|
||||
ledger directly and never persists raw tool arguments or output.
|
||||
|
||||
## Environment the agent process needs
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `HUX_BASE_URL` | default `http://127.0.0.1:8790`; the service is loopback-only |
|
||||
| `HUX_TENANT_SLOT` | `slot-N`, same value the service was started with (`HUX_TENANT_SLOT` on the service side) |
|
||||
| `HUX_SUBJECT` | `usr_<hash>` of the slot owner; the router derives it, the pod env carries it |
|
||||
| `HUX_WORKER_KEY` | shared key mounted read-only under `/runtime-access`; sent as `X-Hux-Relay-Key` |
|
||||
| `HUX_SURFACE` / `HUX_TRUST` | default `worker` / `worker`; the agent hook is never a human surface (SO-35) |
|
||||
| `HUX_TIMEOUT_SECONDS` | default 5 |
|
||||
|
||||
Headers the client sends (exactly `hux/identity.py`): `X-Hermes-Tenant-Identity`,
|
||||
`X-Hux-Subject`, `X-Hux-Surface`, `X-Hux-Trust`, `X-Hux-Relay-Key`, plus
|
||||
`Idempotency-Key` on creates and `If-Match` on revisioned PUTs.
|
||||
|
||||
## Construction
|
||||
|
||||
```python
|
||||
from hux_hook import HuxClient
|
||||
client = HuxClient(os.environ.get("HUX_BASE_URL", "http://127.0.0.1:8790"),
|
||||
{"tenant_slot": os.environ["HUX_TENANT_SLOT"], "subject": os.environ["HUX_SUBJECT"],
|
||||
"surface": "worker", "trust": "worker"},
|
||||
key=os.environ.get("HUX_WORKER_KEY"), timeout=float(os.environ.get("HUX_TIMEOUT_SECONDS", "5")))
|
||||
```
|
||||
|
||||
One client per process. `client.capabilities()` is cached; call
|
||||
`client.forget_capabilities()` on SIGHUP or when the WebUI reports a flag change.
|
||||
|
||||
## Where each call goes in the tool loop
|
||||
|
||||
1. Run start: `emit(client, conversation_id, "run.started", "...", run_id=run_id)`.
|
||||
2. Immediately before executing any tool that is not read-only:
|
||||
`d = before_tool(client, run_id, conversation_id, tool_name, arguments, capability, external, risk)`.
|
||||
- `d.proceed is True`: execute now, with exactly the `arguments` object that was hashed.
|
||||
Re-serialising or "normalising" arguments after the gate breaks SO-37.
|
||||
- `d.reason == "approval_required"`: do not execute. Surface `d.approval_id` to the UI
|
||||
(chat posts `POST /hux/v1/approvals/{id}` with `once|session|always|deny` from a human surface),
|
||||
park the turn, and call `before_tool` again with the same arguments after the decision.
|
||||
The idempotent replay reaches the gate; a `once` approval releases exactly once.
|
||||
- Any other reason (`approval_denied`, `budget_exhausted`, `hux_unavailable`, `autonomy_off`,
|
||||
`service_error:*`, a gate reason): refuse the tool and tell the model why. Never fall back to
|
||||
the upstream gateway approval prompt while `hux.autonomy` is on.
|
||||
Capability mapping is the runtime's job: `read_files`, `write_files`, `shell`, `network`,
|
||||
`web_search`, `send_message`, `memory_write`, `artifact_write`, `spend_tokens`, `delegate`,
|
||||
`deploy`, `external_side_effect` (see `hux/rules.py`). `external=True` for anything that
|
||||
leaves the tenant (messages, network, deploy).
|
||||
3. After every tool: `after_tool(client, run_id, conversation_id, tool_name, ok, bytes_out, turn,
|
||||
argument_hash=canonical_argument_hash(tool_name, arguments), duration_ms=..., exit_code=...)`
|
||||
then `record_spend(client, run_id, conversation_id, tool_calls=1, tokens=<delta>)`.
|
||||
Both are best-effort: they return `None` on failure and never raise.
|
||||
4. Delegation: `record_spend(..., delegations=1)` / `subagents=1` when spawning; the child run
|
||||
uses its own `run_id` and the same `conversation_id`.
|
||||
5. Memory: before proposing a memory write call `memory_gate(client, conversation_id)`;
|
||||
`False` means do not even propose. The write itself still goes through `POST /hux/v1/memory`,
|
||||
which enforces forget, disable and topic rules server-side.
|
||||
6. Stop: when the user cancels, kill the tool processes, then
|
||||
`receipt = on_stop(client, run_id, conversation_id, process_registry_empty=<real registry check>,
|
||||
side_effects=[{"description": ..., "reverted": bool}, ...])`. `None` means no receipt exists and
|
||||
the stop is NOT done: retry, and never report "cancelled" to the user without a receipt (SO-41).
|
||||
`process_registry_empty` must come from the gateway's process registry, not from a timer.
|
||||
7. Run end: `emit(..., "run.completed" | "run.failed", ...)`.
|
||||
|
||||
## What must never happen
|
||||
|
||||
- Raw arguments, file contents, command output, prompts or secrets in any `summary`, `detail`
|
||||
or `evidence`. The library only ever sends tool name, capability, hash, byte counts, status.
|
||||
- Deciding an approval with the worker identity (the service answers 403; do not retry as `chat`).
|
||||
- Executing a tool after `proceed=False` for any reason, including HUX being unreachable.
|
||||
- Logging `HuxServiceError` bodies with request payloads: exceptions carry status/code/message only.
|
||||
|
||||
## Routes used
|
||||
|
||||
`GET /hux/v1/capabilities`, `POST /hux/v1/approvals`, `POST /hux/v1/runs/{id}/gate`,
|
||||
`POST /hux/v1/runs/{id}/budget`, `POST /hux/v1/runs/{id}/stop`,
|
||||
`POST /hux/v1/conversations/{id}/events`, `GET /hux/v1/privacy/policy`,
|
||||
`GET /hux/v1/conversations/{id}`.
|
||||
24
dockerfiles/hermes-worker-hux/hux_hook/__init__.py
Normal file
24
dockerfiles/hermes-worker-hux/hux_hook/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Agent-side hook library for the per-tenant HUX service.
|
||||
|
||||
Stdlib only. The Hermes agent process (tenant pod or Worker) calls these
|
||||
functions around its tool loop so that HUX, not the agent, decides approvals,
|
||||
gates, budgets, stop receipts and what lands on the activity timeline.
|
||||
Side effects fail closed when the service is unreachable; telemetry fails open.
|
||||
"""
|
||||
|
||||
from hux_hook.client import HuxClient, HuxServiceError, HuxUnavailable
|
||||
from hux_hook.hooks import (
|
||||
Decision,
|
||||
after_tool,
|
||||
before_tool,
|
||||
canonical_argument_hash,
|
||||
emit,
|
||||
memory_gate,
|
||||
on_stop,
|
||||
record_spend,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Decision", "HuxClient", "HuxServiceError", "HuxUnavailable", "after_tool", "before_tool",
|
||||
"canonical_argument_hash", "emit", "memory_gate", "on_stop", "record_spend",
|
||||
]
|
||||
178
dockerfiles/hermes-worker-hux/hux_hook/client.py
Normal file
178
dockerfiles/hermes-worker-hux/hux_hook/client.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""Thin urllib client for the HUX service on the pod loopback.
|
||||
|
||||
The client owns three things: the identity headers ``hux.identity`` expects,
|
||||
the mapping of ``hux.error.v1`` bodies to ``HuxServiceError``, and the
|
||||
per-process capabilities cache. It never logs or embeds request bodies in
|
||||
exceptions (SO-07, SO-11): an error carries status, code and the service's
|
||||
own message only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
HEADER_SLOT = "X-Hermes-Tenant-Identity"
|
||||
HEADER_SUBJECT = "X-Hux-Subject"
|
||||
HEADER_SURFACE = "X-Hux-Surface"
|
||||
HEADER_TRUST = "X-Hux-Trust"
|
||||
HEADER_KEY = "X-Hux-Relay-Key"
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:8790"
|
||||
MESSAGE_MAX = 280
|
||||
|
||||
|
||||
class HuxServiceError(Exception):
|
||||
"""The service answered with a ``hux.error.v1`` body (or a non-JSON failure)."""
|
||||
|
||||
def __init__(self, status: int, code: str, message: str) -> None:
|
||||
super().__init__(f"{status} {code}: {message}")
|
||||
self.status = int(status)
|
||||
self.code = str(code)
|
||||
self.message = str(message)[:MESSAGE_MAX]
|
||||
|
||||
|
||||
class HuxUnavailable(HuxServiceError):
|
||||
"""The service could not be reached at all; callers decide open or closed."""
|
||||
|
||||
def __init__(self, message: str = "hux service unreachable") -> None:
|
||||
super().__init__(0, "unavailable", message)
|
||||
|
||||
|
||||
class HuxResponse:
|
||||
"""Status, parsed JSON body and headers of one answer."""
|
||||
|
||||
def __init__(self, status: int, body: Any, headers: Mapping[str, str]) -> None:
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.headers = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
def header(self, name: str) -> str:
|
||||
"""Case-insensitive header lookup; empty when absent."""
|
||||
return self.headers.get(name.lower(), "")
|
||||
|
||||
|
||||
def _error_from(status: int, body: Any) -> HuxServiceError:
|
||||
if isinstance(body, dict) and body.get("schema") == "hux.error.v1":
|
||||
return HuxServiceError(int(body.get("status", status)), str(body.get("code", "invalid")), str(body.get("message", "")))
|
||||
return HuxServiceError(status, "invalid", f"unexpected response {status}")
|
||||
|
||||
|
||||
class HuxClient:
|
||||
"""One tenant identity talking to one HUX base URL.
|
||||
|
||||
``identity`` carries ``tenant_slot``, ``subject``, ``surface`` and
|
||||
``trust``; ``key`` is the relay or worker shared key when ``trust`` needs
|
||||
one. The agent hook normally runs as ``surface=worker, trust=worker``.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = DEFAULT_BASE_URL, identity: Mapping[str, str] | None = None,
|
||||
key: str | None = None, timeout: float = 5) -> None:
|
||||
identity = dict(identity or {})
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.identity = {
|
||||
"tenant_slot": str(identity.get("tenant_slot", "")), "subject": str(identity.get("subject", "")),
|
||||
"surface": str(identity.get("surface", "worker")), "trust": str(identity.get("trust", "worker")),
|
||||
}
|
||||
self._key = key
|
||||
self.timeout = timeout
|
||||
self._capabilities: dict[str, Any] | None = None
|
||||
self._guard = threading.Lock()
|
||||
|
||||
# -- transport -------------------------------------------------------------
|
||||
|
||||
def headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
"""Identity headers exactly as ``hux.identity.resolve`` reads them, plus ``extra``."""
|
||||
out = {
|
||||
HEADER_SLOT: self.identity["tenant_slot"], HEADER_SUBJECT: self.identity["subject"],
|
||||
HEADER_SURFACE: self.identity["surface"], HEADER_TRUST: self.identity["trust"],
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if self._key:
|
||||
out[HEADER_KEY] = self._key
|
||||
for name, value in (extra or {}).items():
|
||||
if value:
|
||||
out[name] = str(value)
|
||||
return out
|
||||
|
||||
def request(self, method: str, path: str, body: Any = None, *, idempotency_key: str | None = None,
|
||||
if_match: int | str | None = None, query: Mapping[str, str] | None = None) -> HuxResponse:
|
||||
"""Send one request; raise ``HuxServiceError`` on 4xx/5xx and ``HuxUnavailable`` on transport failure."""
|
||||
extra: dict[str, str] = {}
|
||||
if idempotency_key:
|
||||
extra["Idempotency-Key"] = idempotency_key
|
||||
if if_match is not None:
|
||||
extra["If-Match"] = str(if_match)
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
||||
extra["Content-Type"] = "application/json"
|
||||
url = self.base_url + path
|
||||
if query:
|
||||
url += "?" + "&".join(f"{k}={urllib.request.quote(str(v), safe='')}" for k, v in query.items())
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=self.headers(extra))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as raw: # noqa: S310 - loopback only
|
||||
response = HuxResponse(raw.status, _decode(raw.read()), dict(raw.headers.items()))
|
||||
except urllib.error.HTTPError as error:
|
||||
payload = _decode(error.read())
|
||||
raise _error_from(error.code, payload) from None
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as error:
|
||||
raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None
|
||||
except http.client.InvalidURL:
|
||||
# http.client refuses control characters and spaces in the path; treat it as our own bad request.
|
||||
raise HuxServiceError(400, "invalid", "malformed request path") from None
|
||||
except http.client.HTTPException as error:
|
||||
raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None
|
||||
if response.status >= 400:
|
||||
raise _error_from(response.status, response.body)
|
||||
return response
|
||||
|
||||
def get(self, path: str, query: Mapping[str, str] | None = None) -> HuxResponse:
|
||||
"""``GET path``."""
|
||||
return self.request("GET", path, query=query)
|
||||
|
||||
def post(self, path: str, body: Any, idempotency_key: str | None = None) -> HuxResponse:
|
||||
"""``POST path`` with an optional ``Idempotency-Key``."""
|
||||
return self.request("POST", path, body, idempotency_key=idempotency_key)
|
||||
|
||||
def put(self, path: str, body: Any, if_match: int | str | None = None) -> HuxResponse:
|
||||
"""``PUT path`` with an optional ``If-Match`` revision."""
|
||||
return self.request("PUT", path, body, if_match=if_match)
|
||||
|
||||
# -- capabilities ------------------------------------------------------------
|
||||
|
||||
def capabilities(self, refresh: bool = False) -> dict[str, Any]:
|
||||
"""Which cards are on, cached per process; unreachable or off means every card reads as off."""
|
||||
with self._guard:
|
||||
if self._capabilities is not None and not refresh:
|
||||
return self._capabilities
|
||||
try:
|
||||
body = self.get("/hux/v1/capabilities").body
|
||||
except HuxServiceError as error:
|
||||
return {"reachable": error.code != "unavailable", "cards": {}, "contract_version": ""}
|
||||
cards = {c["card"]: bool(c.get("enabled")) for c in body.get("cards", []) if isinstance(c, dict) and "card" in c}
|
||||
self._capabilities = {"reachable": True, "cards": cards, "contract_version": str(body.get("contract_version", ""))}
|
||||
return self._capabilities
|
||||
|
||||
def card_enabled(self, card: str) -> bool:
|
||||
"""True only when the service answered and reports ``card`` on."""
|
||||
return bool(self.capabilities()["cards"].get(card))
|
||||
|
||||
def forget_capabilities(self) -> None:
|
||||
"""Drop the cache so the next call re-reads flags (used after a reload signal)."""
|
||||
with self._guard:
|
||||
self._capabilities = None
|
||||
|
||||
|
||||
def _decode(raw: bytes) -> Any:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
191
dockerfiles/hermes-worker-hux/hux_hook/hooks.py
Normal file
191
dockerfiles/hermes-worker-hux/hux_hook/hooks.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Pure hook functions the agent calls around its tool loop.
|
||||
|
||||
Every function takes a ``HuxClient`` and never touches the agent's own state.
|
||||
``before_tool`` is the only call whose answer the agent must obey: it asks for
|
||||
an approval, then asks the gate, and fails closed on any doubt (SO-37, SO-39,
|
||||
SO-40). ``after_tool``, ``record_spend`` and ``emit`` are telemetry: they never
|
||||
raise into the loop and never carry raw arguments or tool output (SO-11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hux_hook.client import HuxClient, HuxServiceError
|
||||
|
||||
CARD_EVENTS = "HUX-01"
|
||||
CARD_AUTONOMY = "HUX-05"
|
||||
CARD_PRIVACY = "HUX-10"
|
||||
SPEND_KEYS = ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")
|
||||
RISKS = ("low", "medium", "high")
|
||||
PATH_SEGMENT = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
|
||||
KEY_CHARS = re.compile(r"[^A-Za-z0-9._:-]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""What the agent may do next; ``proceed`` is False unless the gate released the call."""
|
||||
|
||||
proceed: bool
|
||||
approval_id: str | None
|
||||
reason: str
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
"""Canonical JSON: sorted keys, no whitespace, ASCII escapes; key order and formatting cannot change it."""
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def canonical_argument_hash(tool_name: str, arguments: Any) -> str:
|
||||
"""``sha256:<hex>`` over the canonical JSON of ``{"tool": name, "arguments": ...}``; the gate compares this exact value."""
|
||||
return "sha256:" + hashlib.sha256(canonical_json({"tool": tool_name, "arguments": arguments})).hexdigest()
|
||||
|
||||
|
||||
def call_ref(tool_name: str, argument_hash: str) -> str:
|
||||
"""Evidence id for one tool call: name plus a hash prefix, never the arguments."""
|
||||
return f"{KEY_CHARS.sub('-', tool_name)[:40]}:{argument_hash[7:23]}"
|
||||
|
||||
|
||||
def idempotency_key(run_id: str, kind: str, token: str) -> str:
|
||||
"""A key that satisfies the contract pattern whatever the run id looks like."""
|
||||
key = KEY_CHARS.sub("-", f"{run_id}:{kind}:{token}")
|
||||
return key[:120] if len(key) >= 8 else (key + ".pad").ljust(8, "0")
|
||||
|
||||
|
||||
def _safe_summary(tool_name: str, capability: str, argument_hash: str, size: int) -> str:
|
||||
return f"{tool_name} ({capability}) arguments {argument_hash[:23]} {size} bytes"[:280]
|
||||
|
||||
|
||||
def before_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: str, arguments: Any, capability: str,
|
||||
external: bool = False, risk: str = "medium", turn: int | None = None) -> Decision:
|
||||
"""Request (or replay) the approval for this exact call, then ask the gate. Fails closed.
|
||||
|
||||
A pending approval returns ``proceed=False, reason="approval_required"`` with
|
||||
the approval id so the UI can prompt; the agent calls again with the same
|
||||
arguments once the human has decided and the idempotent replay reaches the gate.
|
||||
"""
|
||||
if not PATH_SEGMENT.match(str(run_id)):
|
||||
return Decision(False, None, "invalid_run_id")
|
||||
if not client.card_enabled(CARD_AUTONOMY):
|
||||
return Decision(False, None, "hux_unavailable" if not client.capabilities()["reachable"] else "autonomy_off")
|
||||
argument_hash = canonical_argument_hash(tool_name, arguments)
|
||||
size = len(canonical_json(arguments))
|
||||
body = {
|
||||
"run_id": run_id, "conversation_id": conversation_id, "capability": capability,
|
||||
"request": {
|
||||
"summary": _safe_summary(tool_name, capability, argument_hash, size),
|
||||
"risk": risk if risk in RISKS else "high", "external": bool(external),
|
||||
"evidence": [{"kind": "tool_call", "id": call_ref(tool_name, argument_hash), "hash": argument_hash}],
|
||||
},
|
||||
}
|
||||
try:
|
||||
approval = client.post("/hux/v1/approvals", body, idempotency_key(run_id, "approval", argument_hash[7:39])).body
|
||||
except HuxServiceError as error:
|
||||
return Decision(False, None, _failure_reason(error))
|
||||
approval_id = approval.get("id") if isinstance(approval, dict) else None
|
||||
status = approval.get("status") if isinstance(approval, dict) else None
|
||||
if status == "pending":
|
||||
return Decision(False, approval_id, "approval_required")
|
||||
if status != "approved":
|
||||
return Decision(False, approval_id, f"approval_{status or 'invalid'}")
|
||||
gate_body = {"capability": capability, "argument_hash": argument_hash, "external": bool(external), "conversation_id": conversation_id}
|
||||
try:
|
||||
verdict = client.post(f"/hux/v1/runs/{run_id}/gate", gate_body).body
|
||||
except HuxServiceError as error:
|
||||
return Decision(False, approval_id, _failure_reason(error))
|
||||
if not isinstance(verdict, dict) or verdict.get("proceed") is not True:
|
||||
reason = verdict.get("reason", "gate_blocked") if isinstance(verdict, dict) else "gate_invalid"
|
||||
return Decision(False, approval_id, str(reason)[:280])
|
||||
return Decision(True, str(verdict.get("approval_id") or approval_id), "released")
|
||||
|
||||
|
||||
def _failure_reason(error: HuxServiceError) -> str:
|
||||
if error.code in {"unavailable", "budget_exhausted", "flag_off", "approval_required"}:
|
||||
return "hux_unavailable" if error.code == "unavailable" else error.code
|
||||
return f"service_error:{error.code}"
|
||||
|
||||
|
||||
def emit(client: HuxClient, conversation_id: str, kind: str, summary: str, detail: dict[str, Any] | None = None,
|
||||
evidence: list[dict[str, Any]] | None = None, sensitivity: str = "personal", run_id: str | None = None,
|
||||
turn: int | None = None, correlation_id: str | None = None, idempotency_key: str | None = None) -> dict[str, Any] | None:
|
||||
"""Best-effort activity event; returns the stored record, or None when it could not be recorded."""
|
||||
body: dict[str, Any] = {"kind": kind, "summary": str(summary)[:280], "sensitivity": sensitivity}
|
||||
if detail:
|
||||
body["detail"] = detail
|
||||
if evidence:
|
||||
body["evidence"] = evidence
|
||||
if run_id:
|
||||
body["run_id"] = run_id
|
||||
if turn is not None:
|
||||
body["turn"] = int(turn)
|
||||
if correlation_id:
|
||||
body["correlation_id"] = correlation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/conversations/{conversation_id}/events", body, idempotency_key)
|
||||
except (HuxServiceError, TypeError, ValueError):
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def after_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: str, ok: bool, bytes_out: int,
|
||||
turn: int | None = None, argument_hash: str | None = None, duration_ms: int | None = None,
|
||||
exit_code: int | None = None) -> dict[str, Any] | None:
|
||||
"""Emit ``tool.result`` with status and sizes only; the output itself never leaves the agent."""
|
||||
detail: dict[str, Any] = {"tool": tool_name, "ok": bool(ok), "bytes": max(0, int(bytes_out))}
|
||||
if duration_ms is not None:
|
||||
detail["duration_ms"] = max(0, int(duration_ms))
|
||||
if exit_code is not None:
|
||||
detail["exit_code"] = int(exit_code)
|
||||
evidence = [{"kind": "tool_call", "id": call_ref(tool_name, argument_hash), "hash": argument_hash}] if argument_hash else None
|
||||
summary = f"{tool_name} {'succeeded' if ok else 'failed'} ({detail['bytes']} bytes)"
|
||||
key = idempotency_key(run_id, "result", f"{turn or 0}:{(argument_hash or 'none')[7:23]}")
|
||||
return emit(client, conversation_id, "tool.result", summary, detail, evidence, run_id=run_id, turn=turn, idempotency_key=key)
|
||||
|
||||
|
||||
def record_spend(client: HuxClient, run_id: str, conversation_id: str | None = None, **increments: int) -> dict[str, Any] | None:
|
||||
"""Add spend to the run budget; returns the new ``hux.budget_state.v1`` or None. Never raises."""
|
||||
body: dict[str, Any] = {k: max(0, int(v)) for k, v in increments.items() if k in SPEND_KEYS}
|
||||
if conversation_id:
|
||||
body["conversation_id"] = conversation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/runs/{run_id}/budget", body)
|
||||
except (HuxServiceError, TypeError, ValueError):
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def on_stop(client: HuxClient, run_id: str, conversation_id: str | None, process_registry_empty: bool,
|
||||
side_effects: list[dict[str, Any]] | None = None, already_complete: bool = False) -> dict[str, Any] | None:
|
||||
"""Write the cancellation receipt (SO-41). None means no receipt exists and the stop is not done."""
|
||||
body: dict[str, Any] = {"process_registry_empty": bool(process_registry_empty), "side_effects": list(side_effects or [])}
|
||||
if already_complete:
|
||||
body["already_complete"] = True
|
||||
if conversation_id:
|
||||
body["conversation_id"] = conversation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/runs/{run_id}/stop", body)
|
||||
except HuxServiceError:
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def memory_gate(client: HuxClient, conversation_id: str) -> bool:
|
||||
"""May the agent propose a memory write from this conversation? Conservative False when in doubt.
|
||||
|
||||
Requires the privacy card to answer (SO-27 header is passed through as a
|
||||
hint only); a conversation whose mode is ``private`` never writes (SO-28).
|
||||
The service still enforces forget and disable state on the write itself.
|
||||
"""
|
||||
try:
|
||||
client.get("/hux/v1/privacy/policy")
|
||||
except HuxServiceError:
|
||||
return False
|
||||
try:
|
||||
record = client.get(f"/hux/v1/conversations/{conversation_id}").body
|
||||
except HuxServiceError as error:
|
||||
return error.status == 404
|
||||
return not (isinstance(record, dict) and record.get("mode") == "private")
|
||||
@ -71,6 +71,17 @@ argument hash a `once` approval is released against travels as
|
||||
send it or the gate can never release. Memory rules skip content-free
|
||||
statuses (`no_store`, `forgotten`, `rejected`).
|
||||
|
||||
Review follow-up (same revision, still additive): `cancellation_receipt`
|
||||
gains optional `revision`. Stored documents in every revisioned family carry
|
||||
the store's `revision`, and a `failed_to_cancel` receipt is the one
|
||||
non-terminal outcome: a later stop that the gateway (worker trust) confirms
|
||||
supersedes it with a revision bump; an identical repeat replays. Only a
|
||||
human actor (router/relay trust on chat, telegram or voice) may write a
|
||||
policy or hold an `allow` grant; `trust: worker` reaches only the routes in
|
||||
`hux.flags.WORKER_ROUTES` (SO-08). External side effects release only
|
||||
against an approval for the same run and argument hash. Cards HUX-06,
|
||||
HUX-09 and HUX-12 declare no routes until they ship.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Codex codes UI against the fixtures, not against the running service.
|
||||
|
||||
@ -62,3 +62,42 @@ Full suite: `testing/tests/test_hermes_hux_*.py` = 325 tests, 99% line / 99% bra
|
||||
| HUX-03 organization API | `b3de70ba` | `hux/organization.py` | `test_hermes_hux_contract_organization.py` (14) | `hux.projects` | Codex owns the UI + migration of upstream WebUI projects; search over `message_text` is a later increment |
|
||||
|
||||
Known gaps carried to the Wave A review: rate limiting (SO-53), hash-chained audit (SO-46), retention scheduler ownership (Codex cron vs service thread), approval expiry applied lazily on read, linear scans for idempotency/dedupe (fine at documented caps).
|
||||
|
||||
## Wave A adversarial review — CLOSED
|
||||
|
||||
A fresh reviewer attacked HEAD `124206b7` (tenant isolation, privacy, autonomy, rollback) and reported 13 findings; the repro harness lives outside the repo. Every finding is closed by a regression test that cites it:
|
||||
|
||||
| # | Severity | Finding | Fix commit |
|
||||
|---|---|---|---|
|
||||
| F1 | critical | worker/api trust could `PUT /policy` and self-approve | `dd80e4bd` — policy writes and allow grants are human-surface only |
|
||||
| F2 | high | worker trust could read every tenant record (SO-08) | `dd80e4bd` — `flags.WORKER_ROUTES` allowlist enforced in `Router.dispatch`; unexpected exceptions become audited 500 records |
|
||||
| F3 | high | memory `edit` skipped topic/sensitivity/private-mode gates | `4417475d` — one `_classify` path for proposals and edits |
|
||||
| F4 | high | a `session` approval for an external effect released unrelated later effects | `dd80e4bd` — external effects match only the same run + argument hash; gate conversation from the run, not the body |
|
||||
| F5 | high | seq duplicated after a crash between append and checkpoint | `4417475d` — seq = max(checkpoint, ledger tail + 1) under the lock |
|
||||
| F6 | medium | gate ignored budget exhaustion | `dd80e4bd` |
|
||||
| F7 | medium | stale unconditional write could resurrect a forgotten memory | `4417475d` — re-read under lock, always write with the loaded revision |
|
||||
| F8 | medium | any caller could assert `process_registry_empty`; failed receipts were sticky | `dd80e4bd` — gateway (worker trust) only; failed receipts supersedable |
|
||||
| F9 | medium | secrets stored verbatim in titles/passages/claims/notebooks; forget left the title | `4417475d` — scrub applied; forget blanks the document. Artifact bodies stay verbatim (user-owned) but are forced `restricted` and audited (`this commit`) |
|
||||
| F10 | medium | SO-46/48/53 claimed but absent | threat model amended (`5acf640d`): tracked as open, not claimed |
|
||||
| F11 | low | stored receipts carry `revision` the schema forbade | `dd80e4bd` — optional `revision` on the receipt |
|
||||
| F12 | low | capabilities advertised routes without handlers | `dd80e4bd` — HUX-06/09/12 routes empty until shipped |
|
||||
| F13 | low | misc (healthz version, ghost conversations from notices, idempotency outside the lock, artifact titles not searchable, unnormalised paths, multi-hash once approvals) | `dd80e4bd` + `4417475d` |
|
||||
|
||||
Suite after repairs: 377 tests, 99% line / 99% branch over `hermes-hux-foundation` and `hermes-worker-hux`. Full repo gate: same 13 pre-existing, unrelated failures as `main`.
|
||||
|
||||
## Worker hook library — DONE
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Commit | `532add3a` |
|
||||
| Files | `dockerfiles/hermes-worker-hux/hux_hook/{__init__,client,hooks}.py`, `NOTES.md` (wiring guide for the runtime patch, which is Codex's file) |
|
||||
| Tests | `test_hermes_hux_policy_hook.py` (11), `test_hermes_hux_contract_hook.py` (10) — real service in-process, end-to-end approval → human decision → gate released once, canary never persisted, unreachable service fails closed for side effects |
|
||||
| Codex needs | Agent container env `HUX_BASE_URL`, `HUX_TENANT_SLOT`, `HUX_SUBJECT`, `HUX_WORKER_KEY`; call order per `NOTES.md`: `before_tool` → execute only on `proceed` → `after_tool`; `record_spend` per turn; `on_stop` returning `None` means the stop is not done |
|
||||
|
||||
## Open items (not blockers for Codex integration)
|
||||
|
||||
- SO-46 hash-chained audit, SO-48 single-writer lock, SO-53 rate limits.
|
||||
- Retention scheduler: `privacy.run_retention` is on-demand; Codex decides cron vs sidecar.
|
||||
- `GET /hux/v1/conversations/{id}/privacy` (forgotten / memory_disabled state) would let the hook stop proposing memory earlier.
|
||||
- Search over `message_text`; artifact sharing (`shared_readonly`).
|
||||
- `testing/quality_contract.json` line-limit globs / managed modules for the two new package paths (Codex's file).
|
||||
|
||||
@ -245,3 +245,25 @@ Sized for 4-8 tenants, one human each, on a 10 Gi PVC shared with the WebUI.
|
||||
- SO-52 Disabling a flag hides routes; TTL, forget and purge continue.
|
||||
- SO-53 Rate limits (30 writes/min, 300 reads/min per subject) return `429` with `Retry-After`.
|
||||
- SO-54 Bodies over 64 KiB (JSON) or 25 MiB (blob) are rejected with `413` before parsing.
|
||||
|
||||
|
||||
## Wave A review outcome (2026-08-24)
|
||||
|
||||
A fresh adversarial review of HEAD `124206b7` produced thirteen findings
|
||||
(see `docs/hux/HANDOFF.md`, "Wave A review"). Corrections to the obligations
|
||||
above as a result:
|
||||
|
||||
- SO-08 is amended: `trust: worker` is the agent hook, not only the operator
|
||||
surface. It may call the hook allowlist in `hux/flags.py` (`WORKER_ROUTES`:
|
||||
capabilities, manifest, releases, approvals create, run gate/budget/stop,
|
||||
event emit, privacy policy read, memory proposal and retrieval, source/
|
||||
passage/citation and artifact creation). Everything else is 403. Policy
|
||||
writes and approval decisions are human-surface only (F1, F2).
|
||||
- SO-29 is satisfied by a source test (no `urllib.request`, `requests`,
|
||||
`httpx` or raw `socket` use in the service modules), not by a module-graph
|
||||
assertion: `http.server` legitimately loads `http.client`.
|
||||
- SO-46 (hash-chained audit), SO-48 (`v1/.lock` single-writer) and SO-53
|
||||
(rate limits) are **not implemented** in this increment and are tracked as
|
||||
open items, not claimed controls.
|
||||
- On-disk documents carry a `revision` field the 1.0.0 fixtures did not have;
|
||||
served bodies strip it where the record schema forbids it (F11).
|
||||
|
||||
@ -320,7 +320,7 @@ spec:
|
||||
requests: {cpu: 250m, memory: 256Mi}
|
||||
limits: {cpu: "1", memory: 2Gi}
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:440d59b8fb77b95c6bef4e39f73fd08dd2ee4d044bf95725b31da1441c173438 # {"$imagepolicy": "hermes:hermes-webui-release"}
|
||||
image: registry.bstein.dev/bstein/hermes-webui:git-e9a6eba30b5ec1b7cdd4aea816131e840d541353-build-16-release@sha256:2e241688a86ea6ef9cd5fc23bf75f97597552c015d1b68b98d471c8a2fe5c369 # {"$imagepolicy": "hermes:hermes-webui-release"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
|
||||
@ -359,6 +359,11 @@
|
||||
},
|
||||
"conversation_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Store revision; bumps when a failed_to_cancel receipt is superseded by a later stop."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -388,7 +388,7 @@ spec:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:440d59b8fb77b95c6bef4e39f73fd08dd2ee4d044bf95725b31da1441c173438 # {"$imagepolicy": "hermes:hermes-webui-release"}
|
||||
image: registry.bstein.dev/bstein/hermes-webui:git-e9a6eba30b5ec1b7cdd4aea816131e840d541353-build-16-release@sha256:2e241688a86ea6ef9cd5fc23bf75f97597552c015d1b68b98d471c8a2fe5c369 # {"$imagepolicy": "hermes:hermes-webui-release"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
|
||||
@ -31,7 +31,7 @@ spec:
|
||||
kubernetes.io/hostname: titan-21
|
||||
containers:
|
||||
- name: stt
|
||||
image: registry.bstein.dev/bstein/hermes-jetson-stt:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:6460847c21f41a3f2184bb4bac51ccb962bd8f79b5f597b60ff17e3e8859f5b2 # {"$imagepolicy": "hermes:hermes-stt-release"}
|
||||
image: registry.bstein.dev/bstein/hermes-jetson-stt:git-af5ee4697bf064958e6ca363678321e606ce3e61-build-17-release@sha256:fa6a185a6e81f7b4690c7abff350a14fa731fbb455e5ff6c46dc253120cfbd21 # {"$imagepolicy": "hermes:hermes-stt-release"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- {name: http, containerPort: 9000, protocol: TCP}
|
||||
|
||||
@ -128,3 +128,19 @@ def test_audit_rows_name_family_verbs(router, artifact):
|
||||
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
|
||||
assert [r["action"] for r in rows] == ["artifacts.create", "artifacts.get"]
|
||||
assert all(contracts.validate("common.schema.json", r, SCHEMAS, "/$defs/audit_outcome") == [] for r in rows)
|
||||
|
||||
|
||||
def test_secret_bearing_content_is_kept_but_forced_restricted(router):
|
||||
"""Review a2-C / SO-12: user artifacts are not rewritten, but a secret pattern forces restricted sensitivity and is audited."""
|
||||
token = "ghp_" + "A" * 36
|
||||
status, record = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "env", "content": f"TOKEN={token}", "sensitivity": "personal"})
|
||||
assert status == 201 and record["sensitivity"] == "restricted"
|
||||
status, version = call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/1")
|
||||
assert status == 200 and token in version["content"]
|
||||
clean = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "clean", "content": "x = 1"})[1]
|
||||
assert clean["sensitivity"] == "personal"
|
||||
status, bumped = call(router, "POST", f"/hux/v1/artifacts/{clean['id']}/versions", {"content": f"key: {token}"}, {**OWNER, "If-Match": "1"})
|
||||
assert status == 201 and bumped["sensitivity"] == "restricted"
|
||||
from hux import audit, store
|
||||
reasons = [r.get("reason") for r in audit.recent(store.TenantStore(router.data_root, identity.resolve(OWNER, {}))) if r["action"].startswith("artifacts.")]
|
||||
assert reasons.count("secret_pattern_restricted") == 2
|
||||
|
||||
@ -361,6 +361,68 @@ def test_flag_off_hides_event_routes(tmp_path):
|
||||
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["code"] == "flag_off"
|
||||
|
||||
|
||||
def test_f5_crash_between_append_and_checkpoint_never_duplicates_a_seq(tmp_path, monkeypatch):
|
||||
"""F5 / SO-15: with the checkpoint left behind the ledger, the next emit takes max(checkpoint, tail + 1) so paging stays complete."""
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
events.emit(s, ident(), CONV, "message.user", "one")
|
||||
original = store.TenantStore.put
|
||||
|
||||
def crash(self, family, record, expected_revision=None):
|
||||
if family == events.SEQ_FAMILY:
|
||||
raise RuntimeError("simulated crash after the ledger append")
|
||||
return original(self, family, record, expected_revision)
|
||||
|
||||
monkeypatch.setattr(store.TenantStore, "put", crash)
|
||||
with pytest.raises(RuntimeError):
|
||||
events.emit(s, ident(), CONV, "message.user", "two")
|
||||
monkeypatch.undo()
|
||||
assert s.get(events.SEQ_FAMILY, events._seq_id(CONV))["next_seq"] == 2 and events.last_seq(s, CONV) == 2
|
||||
third = events.emit(s, ident(), CONV, "message.user", "three")
|
||||
assert third["seq"] == 3 and s.get(events.SEQ_FAMILY, events._seq_id(CONV))["next_seq"] == 4
|
||||
items = call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["items"]
|
||||
assert [(e["seq"], e["summary"]) for e in items] == [(1, "one"), (2, "two"), (3, "three")]
|
||||
assert [e["seq"] for e in call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=2")[1]["items"]] == [3]
|
||||
|
||||
|
||||
def test_f5_last_seq_reads_only_the_tail_and_skips_torn_lines(tmp_path):
|
||||
"""F5: a torn trailing line (crash mid-append) and a missing ledger are both handled."""
|
||||
s = tenant(tmp_path)
|
||||
assert events.last_seq(s, "conv_none0001") == 0
|
||||
for index in range(3):
|
||||
events.emit(s, ident(), CONV, "message.user", f"m{index}")
|
||||
path = s.root / events.FAMILY / f"{CONV}.jsonl"
|
||||
with open(path, "ab") as handle:
|
||||
handle.write(b'{"schema":"hux.event.v1","seq":9')
|
||||
assert events.last_seq(s, CONV) == 3
|
||||
with open(path, "ab") as handle:
|
||||
handle.write(b"\n[]\n\n")
|
||||
assert events.last_seq(s, CONV) == 3
|
||||
path.write_bytes(b"\n\n")
|
||||
assert events.last_seq(s, CONV) == 0
|
||||
|
||||
|
||||
def test_conversation_known_accepts_a_ledger_without_a_checkpoint(tmp_path):
|
||||
"""F5 / SO-18: a conversation whose checkpoint write was lost is still this subject's conversation."""
|
||||
s = tenant(tmp_path)
|
||||
events.emit(s, ident(), CONV, "message.user", "one")
|
||||
s.delete(events.SEQ_FAMILY, events._seq_id(CONV))
|
||||
assert events.conversation_known(s, CONV) is True
|
||||
assert events.conversation_known(s, "conv_other0001") is False
|
||||
assert events.emit(s, ident(), CONV, "message.user", "two")["seq"] == 2
|
||||
|
||||
|
||||
def test_f13d_target_path_is_normalised_before_the_workspace_check(tmp_path):
|
||||
"""F13d / SO-11: ``workspace/../.env`` is not a workspace path; a dotted path inside the workspace is stored normalised."""
|
||||
s = tenant(tmp_path)
|
||||
escaped = events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace/../.env"})
|
||||
assert "target_path" not in escaped["detail"]
|
||||
inside = events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace/a/../notes.md"})
|
||||
assert inside["detail"]["target_path"] == "/opt/data/workspace/notes.md"
|
||||
assert "target_path" not in events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": 7})["detail"]
|
||||
assert "target_path" not in events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace"})["detail"]
|
||||
|
||||
|
||||
def test_events_sources_stay_under_500_lines():
|
||||
for name in ("events.py", "redaction.py"):
|
||||
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500, name
|
||||
|
||||
@ -363,3 +363,74 @@ def test_server_main_wires_environment(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HUX_PORT", "8791")
|
||||
server.main()
|
||||
assert seen == {"root": tmp_path, "host": "127.0.0.1", "port": 8791, "served": True}
|
||||
|
||||
|
||||
# --- F2: worker allowlist, internal errors, health -----------------------------------
|
||||
|
||||
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
|
||||
|
||||
def test_worker_trust_reaches_only_the_allowlisted_routes(tmp_path):
|
||||
"""F2 (high) / SO-08: ``trust: worker`` gets 403 on every route outside ``flags.WORKER_ROUTES``, before the flag check."""
|
||||
router = _router(tmp_path, environ={"HUX_WORKER_KEY": "wk"})
|
||||
denied = [
|
||||
("GET", "/hux/v1/memory/mem_0001aaaa"), ("GET", "/hux/v1/memory/export"), ("GET", "/hux/v1/conversations"),
|
||||
("GET", "/hux/v1/conversations/conv_0001abcd/events"), ("GET", "/hux/v1/approvals"), ("PUT", "/hux/v1/policy"),
|
||||
("GET", "/hux/v1/artifacts"), ("POST", "/hux/v1/conversations/conv_0001abcd/forget"), ("GET", "/hux/v1/privacy/audit"),
|
||||
]
|
||||
for method, path in denied:
|
||||
status, body = _call(router, method, path, WORKER, b"{}" if method != "GET" else b"")
|
||||
assert (status, body["code"]) == (403, "forbidden"), (method, path)
|
||||
for method, path in [("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/runs/run_1/budget"), ("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/memory")]:
|
||||
assert _call(router, method, path, WORKER)[0] == 200, (method, path)
|
||||
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(WORKER, {"HUX_WORKER_KEY": "wk"})))
|
||||
assert [r["outcome"] for r in rows if r["action"] == "memory.read" or r["action"].startswith("policy")][:1] == ["deny"]
|
||||
assert all(r["outcome"] == "deny" for r in rows if r["action"] == "approvals.list")
|
||||
off = _router(tmp_path, flags_value="", environ={"HUX_WORKER_KEY": "wk"})
|
||||
assert _call(off, "GET", "/hux/v1/approvals", WORKER)[0] == 403, "the allowlist answers before the flag does"
|
||||
assert _call(off, "GET", "/hux/v1/capabilities", HEADERS)[0] == 404, "humans still see flag-off as not found"
|
||||
assert flags.worker_may_call("GET", "/hux/v1/releases") and not flags.worker_may_call("GET", "/hux/v1/policy")
|
||||
assert all(any(t == route.template for _, t in flags.WORKER_ROUTES if route.method == _) or (route.method, route.template) not in flags.WORKER_ROUTES for route in router.routes)
|
||||
|
||||
|
||||
def test_unshipped_cards_declare_no_routes():
|
||||
"""F12 (low): HUX-06, HUX-09 and HUX-12 own no routes until they ship, so capabilities never advertises a 404."""
|
||||
assert flags.CARD_ROUTES["HUX-06"] == flags.CARD_ROUTES["HUX-09"] == flags.CARD_ROUTES["HUX-12"] == []
|
||||
|
||||
|
||||
def test_unexpected_handler_exceptions_become_a_500_error_record(tmp_path):
|
||||
"""F2: a non-HuxError in a handler never propagates; the caller sees ``hux.error.v1`` 500 with no detail and the audit says deny."""
|
||||
router = _router(tmp_path)
|
||||
|
||||
def boom(request):
|
||||
raise RuntimeError("secret stack detail")
|
||||
|
||||
router.add("GET", "/hux/v1/boom", "HUX-11", "test.boom", boom)
|
||||
status, body = _call(router, "GET", "/hux/v1/boom")
|
||||
assert status == 500 and body == {"schema": "hux.error.v1", "status": 500, "code": "invalid", "message": "internal error"}
|
||||
assert contracts.validate_record(body, SCHEMAS) == []
|
||||
rows = [(r["action"], r["outcome"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, ident()))]
|
||||
assert rows[-1] == ("test.boom", "deny", "internal error")
|
||||
|
||||
|
||||
def test_healthz_reports_the_contract_version(tmp_path):
|
||||
"""F2: /healthz carries ``flags.CONTRACT_VERSION`` rather than a hard-coded string."""
|
||||
server = serve(_router(tmp_path), "127.0.0.1", 0)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=5)
|
||||
conn.request("GET", "/healthz")
|
||||
assert json.loads(conn.getresponse().read()) == {"status": "ok", "contract_version": flags.CONTRACT_VERSION}
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_no_outbound_network_client_in_the_service(tmp_path):
|
||||
"""F13 / SO-29: no hux module imports urllib.request, requests, httpx or socket; http.server is the only http.* import."""
|
||||
import re
|
||||
banned = re.compile(r"^\s*(?:import|from)\s+(urllib\.request|requests|httpx|socket|http\.client)\b", re.M)
|
||||
for path in sorted((FOUNDATION / "hux").glob("*.py")):
|
||||
assert banned.search(path.read_text()) is None, path
|
||||
http_imports = re.findall(r"^\s*from\s+(http\.\w+)\s+import", path.read_text(), re.M)
|
||||
assert set(http_imports) <= {"http.server"}, (path, http_imports)
|
||||
|
||||
254
testing/tests/test_hermes_hux_contract_hook.py
Normal file
254
testing/tests/test_hermes_hux_contract_hook.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""HuxClient transport contract and the pure helpers of the agent hook.
|
||||
|
||||
Security obligations exercised: SO-04 (the client emits exactly the identity
|
||||
header vocabulary ``hux.identity`` accepts, with the worker key), SO-07 (errors
|
||||
carry status, code and message only; request bodies are never embedded),
|
||||
SO-27 and SO-28 (memory writes need the privacy card and are refused in a
|
||||
private conversation), SO-37 (the argument hash is stable across key order
|
||||
and whitespace), SO-50 (capabilities are read from the resolved flag chain).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import http.client
|
||||
import io
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
||||
HOOK_ROOT = ROOT / "dockerfiles" / "hermes-worker-hux"
|
||||
for entry in (FOUNDATION, HOOK_ROOT):
|
||||
if str(entry) not in sys.path:
|
||||
sys.path.insert(0, str(entry))
|
||||
|
||||
from hux import contracts, identity # noqa: E402
|
||||
from hux.http import serve # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
from hux_hook import HuxClient, HuxServiceError, HuxUnavailable, canonical_argument_hash, emit, memory_gate # noqa: E402
|
||||
from hux_hook import client as client_mod # noqa: E402
|
||||
from hux_hook import hooks # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
SUBJECT = "usr_0123456789abcdef"
|
||||
WORKER = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "worker", "trust": "worker"}
|
||||
HUMAN = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "chat", "trust": "router"}
|
||||
OTHER = {**HUMAN, "subject": "usr_fedcba9876543210"}
|
||||
CANARY = "CANARY-9c1d-SECRET"
|
||||
|
||||
|
||||
def start(tmp_path: Path, flags: str = ALL_ON):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_WORKER_KEY": "wk"})
|
||||
server = serve(router, "127.0.0.1", 0)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return f"http://127.0.0.1:{server.server_address[1]}", server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live(tmp_path):
|
||||
base, server = start(tmp_path)
|
||||
yield base, tmp_path
|
||||
server.shutdown()
|
||||
|
||||
|
||||
# --- headers and identity ---------------------------------------------------------
|
||||
|
||||
def test_headers_match_the_service_vocabulary():
|
||||
"""SO-04: the exact header names identity.resolve reads, key only when present, extras only when non-empty."""
|
||||
worker = HuxClient("http://127.0.0.1:1/", WORKER, key="wk")
|
||||
sent = worker.headers({"Idempotency-Key": "run:approval:1", "If-Match": ""})
|
||||
assert identity.resolve(sent, {"HUX_WORKER_KEY": "wk"}) == identity.Identity("slot-3", SUBJECT, "worker", "worker")
|
||||
assert sent["Idempotency-Key"] == "run:approval:1" and "If-Match" not in sent
|
||||
assert worker.base_url == "http://127.0.0.1:1"
|
||||
plain = HuxClient(identity={"tenant_slot": "slot-3", "subject": SUBJECT}).headers()
|
||||
assert client_mod.HEADER_KEY not in plain and plain[client_mod.HEADER_TRUST] == "worker"
|
||||
assert HuxClient().identity == {"tenant_slot": "", "subject": "", "surface": "worker", "trust": "worker"}
|
||||
|
||||
|
||||
def test_error_mapping_and_no_body_leak(live):
|
||||
"""SO-07: a hux.error.v1 answer becomes HuxServiceError(status, code, message) and the body never appears in it."""
|
||||
base, _ = live
|
||||
human = HuxClient(base, HUMAN)
|
||||
with pytest.raises(HuxServiceError) as bad:
|
||||
human.post("/hux/v1/approvals", {"conversation_id": "conv_0001abcd", "capability": "nope", "secret": CANARY})
|
||||
assert (bad.value.status, bad.value.code) == (400, "invalid") and CANARY not in str(bad.value)
|
||||
with pytest.raises(HuxServiceError) as unauth:
|
||||
HuxClient(base, WORKER, key="wrong").get("/hux/v1/capabilities")
|
||||
assert unauth.value.code == "unauthorized"
|
||||
with pytest.raises(HuxServiceError) as missing:
|
||||
human.get("/hux/v1/no/such/route")
|
||||
assert (missing.value.status, missing.value.code) == (404, "not_found")
|
||||
assert HuxUnavailable().code == "unavailable" and HuxUnavailable().status == 0
|
||||
assert HuxServiceError(500, "x", "m" * 400).message == "m" * 280
|
||||
|
||||
|
||||
def test_transport_edge_cases(monkeypatch):
|
||||
"""Non-JSON failures, a 4xx delivered without an exception and socket errors map to typed errors."""
|
||||
client = HuxClient("http://127.0.0.1:1", WORKER, key="wk")
|
||||
|
||||
def raise_http(*args, **kwargs):
|
||||
raise urllib.error.HTTPError("u", 502, "bad gateway", {}, io.BytesIO(b"<html>"))
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", raise_http)
|
||||
with pytest.raises(HuxServiceError) as html:
|
||||
client.get("/hux/v1/capabilities")
|
||||
assert (html.value.status, html.value.code) == (502, "invalid")
|
||||
|
||||
class Raw:
|
||||
status = 418
|
||||
headers = {"X-Test": "1"}
|
||||
|
||||
def read(self):
|
||||
return b"{bad json"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: Raw())
|
||||
with pytest.raises(HuxServiceError) as teapot:
|
||||
client.put("/hux/v1/policy", {"x": 1}, if_match=3)
|
||||
assert teapot.value.status == 418
|
||||
|
||||
def raise_socket(*args, **kwargs):
|
||||
raise TimeoutError("slow")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", raise_socket)
|
||||
with pytest.raises(HuxUnavailable):
|
||||
client.get("/hux/v1/capabilities", query={"a": "b c"})
|
||||
|
||||
def raise_half_closed(*args, **kwargs):
|
||||
raise http.client.BadStatusLine("gone")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", raise_half_closed)
|
||||
with pytest.raises(HuxUnavailable):
|
||||
client.get("/hux/v1/capabilities")
|
||||
|
||||
|
||||
def test_put_with_if_match_and_get_with_query(live):
|
||||
"""Revisioned writes send If-Match; a stale revision is a conflict; queries reach the service."""
|
||||
base, _ = live
|
||||
human = HuxClient(base, HUMAN)
|
||||
first = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"})
|
||||
assert first.header("ETag") == "1" and first.header("Missing") == ""
|
||||
second = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"}, if_match=1)
|
||||
assert second.body["revision"] == 2
|
||||
with pytest.raises(HuxServiceError) as stale:
|
||||
human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, if_match=1)
|
||||
assert stale.value.code == "conflict"
|
||||
assert human.get("/hux/v1/policy", {"scope": "global"}).body["autonomy"] == "autonomous"
|
||||
|
||||
|
||||
# --- capabilities ---------------------------------------------------------------------
|
||||
|
||||
def test_capabilities_cached_per_process(live, monkeypatch):
|
||||
"""SO-50: one capabilities read per process; refresh and forget re-read; failures are not cached."""
|
||||
base, _ = live
|
||||
worker = HuxClient(base, WORKER, key="wk")
|
||||
calls = []
|
||||
real_get = worker.get
|
||||
monkeypatch.setattr(worker, "get", lambda path, query=None: calls.append(path) or real_get(path, query))
|
||||
assert worker.card_enabled("HUX-05") and worker.card_enabled("HUX-01")
|
||||
assert worker.capabilities()["contract_version"] == contracts.load_flags()["contract_version"]
|
||||
assert len(calls) == 1
|
||||
worker.capabilities(refresh=True)
|
||||
worker.forget_capabilities()
|
||||
worker.capabilities()
|
||||
assert len(calls) == 3
|
||||
monkeypatch.setattr(worker, "get", lambda path, query=None: (_ for _ in ()).throw(HuxServiceError(404, "flag_off", "off")))
|
||||
worker.forget_capabilities()
|
||||
assert worker.capabilities() == {"reachable": True, "cards": {}, "contract_version": ""}
|
||||
dead = HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1)
|
||||
assert dead.capabilities()["reachable"] is False and not dead.card_enabled("HUX-11")
|
||||
|
||||
|
||||
# --- events ---------------------------------------------------------------------------
|
||||
|
||||
def test_emit_is_best_effort_and_redaction_safe(live):
|
||||
"""SO-11: detail outside the allowlist is dropped by the service; failures return None and never raise."""
|
||||
base, root = live
|
||||
human = HuxClient(base, HUMAN)
|
||||
conv = human.post("/hux/v1/conversations", {"title": "t"}).body["id"]
|
||||
worker = HuxClient(base, WORKER, key="wk")
|
||||
record = emit(worker, conv, "tool.call", "shell call", {"tool": "shell", "arguments": {"cmd": CANARY}, "argument_bytes": 7},
|
||||
evidence=[{"kind": "run", "id": "run_1"}], run_id="run_1", turn=2, correlation_id="corr-1", idempotency_key="run_1:call:0001")
|
||||
assert contracts.validate_record(record, SCHEMAS) == [] and record["detail"] == {"tool": "shell", "argument_bytes": 7}
|
||||
assert record["provenance"]["actor"] == {"type": "system", "id": "hux-worker"} and record["correlation_id"] == "corr-1"
|
||||
assert emit(worker, conv, "tool.call", "again", idempotency_key="run_1:call:0001")["id"] == record["id"]
|
||||
assert emit(worker, conv, "not.a.kind", "x") is None
|
||||
assert emit(worker, "conv_unknown0001", "tool.call", "x") is None
|
||||
assert emit(worker, conv, "tool.call", "x", {"tool": object()}) is None
|
||||
assert emit(HuxClient(base, OTHER), conv, "tool.call", "cross tenant") is None
|
||||
private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"]
|
||||
assert emit(worker, private, "tool.call", "private mode writes nothing") is None
|
||||
assert CANARY not in "\n".join(p.read_text(errors="ignore") for p in root.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
# --- memory gate ------------------------------------------------------------------------
|
||||
|
||||
def test_memory_gate(live, tmp_path):
|
||||
"""SO-27, SO-28: allowed only when the privacy card answers and the conversation is not private."""
|
||||
base, _ = live
|
||||
human = HuxClient(base, HUMAN)
|
||||
worker = HuxClient(base, WORKER, key="wk")
|
||||
normal = human.post("/hux/v1/conversations", {"title": "n", "mode": "thoughtful"}).body["id"]
|
||||
private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"]
|
||||
assert memory_gate(worker, normal) is True
|
||||
assert memory_gate(worker, private) is False
|
||||
assert memory_gate(worker, "conv_notknown01") is True
|
||||
assert memory_gate(worker, "bad id") is False
|
||||
assert memory_gate(HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1), normal) is False
|
||||
off_base, off_server = start(tmp_path / "off", "hux.foundation,hux.projects")
|
||||
try:
|
||||
assert memory_gate(HuxClient(off_base, WORKER, key="wk"), normal) is False
|
||||
finally:
|
||||
off_server.shutdown()
|
||||
|
||||
|
||||
# --- pure helpers -------------------------------------------------------------------------
|
||||
|
||||
def test_hash_is_canonical():
|
||||
"""SO-37: key order, whitespace and nesting order of dict keys do not change the hash; values do."""
|
||||
a = canonical_argument_hash("write", {"path": "n.md", "opts": {"b": 1, "a": [1, 2]}})
|
||||
b = canonical_argument_hash("write", {"opts": {"a": [1, 2], "b": 1}, "path": "n.md"})
|
||||
assert a == b and a.startswith("sha256:") and len(a) == 71
|
||||
assert canonical_argument_hash("write", {"path": "n.md ", "opts": {"b": 1, "a": [1, 2]}}) != a
|
||||
assert canonical_argument_hash("other", {"path": "n.md", "opts": {"b": 1, "a": [1, 2]}}) != a
|
||||
assert canonical_argument_hash("w", "raw string") == canonical_argument_hash("w", "raw string")
|
||||
assert hooks.canonical_json({"z": "é", "a": None}) == b'{"a":null,"z":"\\u00e9"}'
|
||||
|
||||
|
||||
def test_key_and_ref_helpers():
|
||||
"""Idempotency keys always satisfy the contract pattern; call refs never contain arguments."""
|
||||
assert hooks.idempotency_key("r", "a", "") == "r:a:.pad"
|
||||
assert hooks.idempotency_key("r", "a", "x") == "r:a:x.pad"
|
||||
key = hooks.idempotency_key("run id/with spaces", "approval", "f" * 200)
|
||||
assert len(key) == 120 and " " not in key and "/" not in key
|
||||
assert hooks.call_ref("my tool/x", "sha256:" + "ab" * 32) == "my-tool-x:abababababababab"
|
||||
assert hooks._failure_reason(HuxServiceError(404, "flag_off", "")) == "flag_off"
|
||||
assert hooks._failure_reason(HuxServiceError(0, "unavailable", "")) == "hux_unavailable"
|
||||
assert hooks._failure_reason(HuxServiceError(409, "conflict", "")) == "service_error:conflict"
|
||||
|
||||
|
||||
def test_library_is_stdlib_only_small_and_documented():
|
||||
"""Every module ≤ 500 lines, every public function and module documented, no third-party imports."""
|
||||
for path in sorted((HOOK_ROOT / "hux_hook").glob("*.py")):
|
||||
source = path.read_text()
|
||||
assert len(source.splitlines()) <= 500, path
|
||||
tree = ast.parse(source)
|
||||
assert ast.get_docstring(tree), path
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef | ast.ClassDef) and not node.name.startswith("_"):
|
||||
assert ast.get_docstring(node), f"{path.name}:{node.name}"
|
||||
if isinstance(node, ast.Import | ast.ImportFrom):
|
||||
root = (node.names[0].name if isinstance(node, ast.Import) else node.module or "").split(".")[0]
|
||||
assert root in {"http", "json", "threading", "urllib", "collections", "typing", "hashlib", "re", "dataclasses", "hux_hook", "__future__"}, root
|
||||
@ -10,7 +10,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@ -213,23 +212,42 @@ def test_search_ranks_title_over_tag_over_project_and_says_what_is_indexed(route
|
||||
assert call(router, "GET", "/hux/v1/search?q=cabinet", headers=OTHER)[1]["items"] == []
|
||||
|
||||
|
||||
def test_search_uses_artifact_titles_when_the_artifacts_lane_offers_them(router, monkeypatch):
|
||||
def test_f13c_search_finds_conversations_by_artifact_title(router, monkeypatch):
|
||||
"""F13c: an artifact filed under a conversation makes that conversation searchable by the artifact title; the lane may be absent."""
|
||||
conversation = make_conversation(router, title="Plain")
|
||||
fake = types.ModuleType("hux.artifacts")
|
||||
fake.artifact_titles = lambda store, conversation_id: ["Supplier comparison sheet"] if conversation_id == conversation["id"] else []
|
||||
monkeypatch.setitem(sys.modules, "hux.artifacts", fake)
|
||||
monkeypatch.setattr(hux, "artifacts", fake, raising=False)
|
||||
assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"]] == [conversation["id"]]
|
||||
bare = types.ModuleType("hux.artifacts")
|
||||
monkeypatch.setitem(sys.modules, "hux.artifacts", bare)
|
||||
monkeypatch.setattr(hux, "artifacts", bare, raising=False)
|
||||
assert call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"] == []
|
||||
other = make_conversation(router, title="Other")
|
||||
status, artifact, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "markdown", "title": "Supplier comparison sheet", "content": "x", "conversation_id": conversation["id"]})
|
||||
assert status == 201, artifact
|
||||
status, body, _ = call(router, "GET", "/hux/v1/search?q=supplier")
|
||||
assert [c["id"] for c in body["items"]] == [conversation["id"]] and body["scores"][conversation["id"]] == 1
|
||||
s = store.TenantStore(router.data_root, identity.resolve(HEADERS, {}))
|
||||
assert organization.artifact_titles(s, conversation) == ["Supplier comparison sheet"] and organization.artifact_titles(s, other) == []
|
||||
# The conversation's own artifact_ids list is indexed as well, without duplicates.
|
||||
s.put(organization.CONVERSATIONS, {**s.get(organization.CONVERSATIONS, other["id"]), "artifact_ids": [artifact["id"], "art_missing0001"]}, 1)
|
||||
assert organization.artifact_titles(s, other) == ["Supplier comparison sheet"]
|
||||
assert sorted(c["id"] for c in call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"]) == sorted([conversation["id"], other["id"]])
|
||||
monkeypatch.setitem(sys.modules, "hux.artifacts", None)
|
||||
monkeypatch.delattr(hux, "artifacts", raising=False)
|
||||
assert call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/search?q=plain")[1]["items"] == [conversation]
|
||||
|
||||
|
||||
def test_f9_titles_tags_names_and_descriptions_are_secret_scrubbed(router):
|
||||
"""F9 / SO-07: a token pasted into a title, tag, project name or description is scrubbed before it is stored or indexed."""
|
||||
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
project = make_project(router, name=f"proj {token}", description="password: hunter2")
|
||||
assert token not in project["name"] and project["description"] == "[redacted:password]"
|
||||
# A tag can only be [a-z0-9-], so a scrubbed tag fails the contract instead of being stored.
|
||||
assert call(router, "POST", "/hux/v1/conversations", {"title": "t", "tags": [token]})[0] == 400
|
||||
conversation = make_conversation(router, title=f"token {token}", tags=["ok"])
|
||||
assert conversation["title"] == "token [redacted:github_token]" and conversation["tags"] == ["ok"]
|
||||
status, patched, _ = call(router, "PATCH", f"/hux/v1/conversations/{conversation['id']}", {"title": f"again {token}"}, {**HEADERS, "If-Match": "1"})
|
||||
assert status == 200 and token not in patched["title"]
|
||||
assert call(router, "GET", f"/hux/v1/search?q={token}")[1]["items"] == []
|
||||
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_flag_off_hides_the_card(tmp_path):
|
||||
off = build_router(tmp_path, {"HUX_FLAGS": ""})
|
||||
status, body, _ = call(off, "GET", "/hux/v1/projects")
|
||||
|
||||
209
testing/tests/test_hermes_hux_memory_repairs.py
Normal file
209
testing/tests/test_hermes_hux_memory_repairs.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""HUX-02 memory review repairs: edit shaping (F3), stale-write resurrection (F7), idempotency race (F13b).
|
||||
|
||||
Security obligations exercised: SO-21 (deny topics never reach memory, even
|
||||
through an edit), SO-22 (no_store and forget are content-free and final),
|
||||
SO-23 (a forgotten entry never retrieves again), SO-28 (private mode refuses
|
||||
memory writes), SO-44 (revision checks hold under concurrency).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
||||
if str(FOUNDATION) not in sys.path:
|
||||
sys.path.insert(0, str(FOUNDATION))
|
||||
|
||||
from hux import contracts, identity, memory, privacy, store # noqa: E402
|
||||
from hux.errors import Conflict # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
USER = {"proposed_by": "user", "approval_mode": "automatic"}
|
||||
|
||||
|
||||
def ident() -> identity.Identity:
|
||||
return identity.Identity(tenant_slot="slot-3", subject="usr_0123456789abcdef", surface="chat", trust="router")
|
||||
|
||||
|
||||
def router_for(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=None):
|
||||
raw = json.dumps(body).encode() if body is not None else b""
|
||||
response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, raw)
|
||||
return response.status, response.body
|
||||
|
||||
|
||||
def tenant(tmp_path) -> store.TenantStore:
|
||||
return store.TenantStore(tmp_path, ident())
|
||||
|
||||
|
||||
def conversation(router) -> str:
|
||||
status, body = call(router, "POST", "/hux/v1/conversations", {"title": "tea"})
|
||||
assert status == 201
|
||||
return body["id"]
|
||||
|
||||
|
||||
def active(router, conv, content="I like tea") -> dict:
|
||||
status, body = call(router, "POST", "/hux/v1/memory", {"content": content, "conversation_id": conv, **USER})
|
||||
assert (status, body["status"]) == (201, "active"), body
|
||||
return body
|
||||
|
||||
|
||||
def valid(record) -> dict:
|
||||
assert contracts.validate_record(record, SCHEMAS) == [], record
|
||||
return record
|
||||
|
||||
|
||||
# --- F3: edit goes through the same shaping as a proposal ---------------------------
|
||||
|
||||
def test_f3_edit_with_deny_topic_becomes_no_store_and_leaves_the_original(tmp_path):
|
||||
"""F3 / SO-21 / SO-22: a location + minors edit is refused content-free; the old entry is not superseded."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
original = active(router, conv)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "my home address is 12 Main Street; my daughter is 6"}, {"If-Match": "1"})
|
||||
assert status == 202 and valid(body)["status"] == "no_store" and body["content"] == "" and "topic" not in body
|
||||
assert body["sensitivity"] == "restricted" and body["retrievable"] is False and "supersedes" not in body
|
||||
assert body["audit"][0]["note"] == "restricted; topic=location" and "Main Street" not in json.dumps(body)
|
||||
assert body["id"] in memory.tombstoned(tenant(tmp_path))
|
||||
status, again = call(router, "GET", f"/hux/v1/memory/{original['id']}")
|
||||
assert status == 200 and again["status"] == "active" and again["revision"] == 1 and again["content"] == "I like tea"
|
||||
assert [hit["content"] for hit in memory.retrieve(tenant(tmp_path), ["address"])] == []
|
||||
assert [hit["id"] for hit in memory.retrieve(tenant(tmp_path), ["tea"])] == [original["id"]]
|
||||
kinds = [row["kind"] for row in tenant(tmp_path).read("events", conv)]
|
||||
assert kinds[-1] == "memory.suppressed"
|
||||
|
||||
|
||||
def test_f3_edit_with_sensitive_content_is_proposed_with_ask(tmp_path):
|
||||
"""F3 / SO-21: the sensitivity floor from detected topics applies to edits, so a health edit waits for approval."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
original = active(router, conv)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I take lithium for my diagnosis"})
|
||||
assert status == 200 and valid(body)["status"] == "proposed" and body["approval_mode"] == "ask"
|
||||
assert body["sensitivity"] == "sensitive" and body["topic"] == "health" and body["retrievable"] is False
|
||||
assert body["supersedes"] == original["id"] and [row["action"] for row in body["audit"]] == ["edited"]
|
||||
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "forgotten"
|
||||
|
||||
|
||||
def test_f3_edit_scrubs_secrets_and_floors_credentials(tmp_path):
|
||||
"""F3 / SO-07: a secret in edited content is scrubbed and the credentials topic makes the edit no_store."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
original = active(router, conv)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"})
|
||||
assert status == 202 and body["status"] == "no_store" and body["sensitivity"] == "restricted"
|
||||
leaked = [path for path in tmp_path.rglob("*.json*") if "ghp_ABCDEFGHIJ" in path.read_text()]
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_f3_edit_refuses_private_mode_and_declines_when_memory_disabled(tmp_path):
|
||||
"""F3 / SO-28: private mode is a 403 for edits too; disable_memory_here turns the edit into no_store."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
original = active(router, conv)
|
||||
privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", True)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"})
|
||||
assert status == 202 and body["status"] == "no_store" and body["audit"][0]["note"].startswith("memory_disabled")
|
||||
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active"
|
||||
privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", False)
|
||||
assert call(router, "PATCH", f"/hux/v1/conversations/{conv}", {"mode": "private"})[0] == 200
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"})
|
||||
assert status == 403 and body["code"] == "forbidden"
|
||||
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active"
|
||||
|
||||
|
||||
def test_f3_edit_still_needs_content(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
original = active(router, conversation(router))
|
||||
assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": " "})[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "x" * 2001})[0] == 400
|
||||
|
||||
|
||||
# --- F7: a stale record cannot resurrect a forgotten entry ------------------------
|
||||
|
||||
def test_f7_stale_approve_after_forget_is_a_conflict(tmp_path, monkeypatch):
|
||||
"""F7 / SO-22 / SO-44: approve holding a pre-reject snapshot (the a2-B interleaving) is refused and the entry stays rejected."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
status, proposed = call(router, "POST", "/hux/v1/memory", {"content": "I take lithium daily", "conversation_id": conv, "proposed_by": "user", "approval_mode": "ask"})
|
||||
assert (status, proposed["status"]) == (201, "proposed")
|
||||
stale = dict(proposed)
|
||||
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/reject")[1]["status"] == "rejected"
|
||||
monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve")
|
||||
assert status == 409 and body["code"] == "conflict"
|
||||
monkeypatch.undo()
|
||||
status, after = call(router, "GET", f"/hux/v1/memory/{proposed['id']}")
|
||||
assert after["status"] == "rejected" and after["content"] == "" and after["revision"] == 2
|
||||
assert memory.retrieve(tenant(tmp_path), ["lithium"]) == []
|
||||
assert call(router, "GET", "/hux/v1/memory/export")[1]["items"] == []
|
||||
|
||||
|
||||
def test_f7_stale_remove_retrieval_cannot_overwrite_a_forget(tmp_path, monkeypatch):
|
||||
"""F7: the unconditional retrieval branch re-reads the entry under the lock, so the a5 race ends in 409 and no restore works."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
entry = active(router, conv, "I take lithium daily")
|
||||
stale = dict(entry)
|
||||
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/forget")[1]["status"] == "forgotten"
|
||||
monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale)
|
||||
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/remove_retrieval")[0] == 409
|
||||
monkeypatch.undo()
|
||||
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/restore_retrieval")[0] == 409
|
||||
assert call(router, "GET", f"/hux/v1/memory/{entry['id']}")[1]["status"] == "forgotten"
|
||||
assert memory.retrieve(tenant(tmp_path), ["lithium"]) == []
|
||||
|
||||
|
||||
def test_f7_transition_checks_if_match_against_the_fresh_copy(tmp_path):
|
||||
"""F7 / SO-44: If-Match is compared with the stored revision, not the caller's stale snapshot."""
|
||||
router = router_for(tmp_path)
|
||||
entry = active(router, conversation(router))
|
||||
s = tenant(tmp_path)
|
||||
memory._set_retrievable(s, entry, False, {"type": "user", "id": "usr_0123456789abcdef"}, 1)
|
||||
try:
|
||||
memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"}, expected=1)
|
||||
except Conflict as error:
|
||||
assert "revision 1 does not match current revision 2" in str(error)
|
||||
else:
|
||||
raise AssertionError("stale If-Match must conflict")
|
||||
assert memory.load(s, entry["id"])["status"] == "active"
|
||||
stored = memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"})
|
||||
assert stored["status"] == "forgotten" and stored["revision"] == 3
|
||||
|
||||
|
||||
# --- F13b: Idempotency-Key is checked under the lock ------------------------------
|
||||
|
||||
def test_f13b_concurrent_proposals_with_one_key_create_exactly_one_record(tmp_path):
|
||||
"""F13b: eight threads racing one Idempotency-Key produce one memory entry; seven are 200 replays."""
|
||||
router = router_for(tmp_path)
|
||||
results: list[tuple[int, str]] = []
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def propose() -> None:
|
||||
barrier.wait(5)
|
||||
status, body = call(router, "POST", "/hux/v1/memory", {"content": "I like tea", **USER}, {"Idempotency-Key": "same-key-0001"})
|
||||
results.append((status, body["id"]))
|
||||
|
||||
threads = [threading.Thread(target=propose) for _ in range(8)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(10)
|
||||
assert sorted(status for status, _ in results) == [200] * 7 + [201]
|
||||
assert len({memory_id for _, memory_id in results}) == 1
|
||||
assert len(call(router, "GET", "/hux/v1/memory")[1]["items"]) == 1
|
||||
assert len(tenant(tmp_path).read(memory.IDEM_FAMILY, "keys")) == 1
|
||||
|
||||
|
||||
def test_memory_module_stays_under_500_lines():
|
||||
assert len((FOUNDATION / "hux" / "memory.py").read_text().splitlines()) <= 500
|
||||
@ -269,9 +269,10 @@ def test_gate_session_is_reusable_within_the_conversation(router):
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
||||
assert gate(router, capability="shell")[1]["proceed"] is True
|
||||
assert gate(router, capability="shell", argument_hash=OTHER_HASH)[1]["proceed"] is True
|
||||
assert gate(router, run_id="run_later", capability="shell", conversation_id=CONV)[1]["proceed"] is True
|
||||
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False
|
||||
assert gate(router, run_id="run_later", capability="shell", conversation_id="conv_elsewhere01")[1]["proceed"] is False
|
||||
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is True, "unknown run: the approval's conversation applies (F4)"
|
||||
call(router, "POST", "/hux/v1/runs/run_elsewhere/budget", {"conversation_id": "conv_elsewhere01", "tokens": 1}, WORKER)
|
||||
status, body = gate(router, run_id="run_elsewhere", capability="shell", conversation_id=CONV)
|
||||
assert body["proceed"] is False, "the run's own conversation wins over the body's claim (F4)"
|
||||
assert gate(router, capability="write_files")[1]["proceed"] is False
|
||||
|
||||
|
||||
@ -290,7 +291,7 @@ def test_gate_requires_external_approvals_for_external_effects_and_respects_expi
|
||||
def test_gate_rejects_bad_bodies_and_other_tenants(router):
|
||||
assert gate(router, capability="teleport")[0] == 400
|
||||
assert gate(router, argument_hash="md5:zz")[0] == 400
|
||||
assert gate(router, conversation_id="nope")[0] == 400
|
||||
assert gate(router, conversation_id="nope")[0] == 200, "the body's conversation_id is ignored, never validated (F4)"
|
||||
assert call(router, "POST", "/hux/v1/runs/run_9f/gate", [], WORKER)[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/runs/{'r' * 121}/gate", {"capability": "shell", "argument_hash": HASH}, WORKER)[0] == 400
|
||||
record = pending(router)
|
||||
@ -307,3 +308,52 @@ def test_events_module_absence_is_tolerated(router, monkeypatch):
|
||||
assert call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)[0] == 201
|
||||
policy.emit(None, None, None, "x", "no conversation means no event")
|
||||
assert budgets.hashes_of({"request": {}}) == set()
|
||||
|
||||
|
||||
# --- F4 / F6: external effects, run conversation and budgets at the gate --------------
|
||||
|
||||
def test_external_effects_release_only_for_the_same_run_and_arguments(router):
|
||||
"""F4 (high) / SO-39: a session or always approval for an external effect never releases another run or other arguments."""
|
||||
record = pending(router, "send_message", external=True)
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
||||
assert gate(router, capability="send_message", external=True)[1]["proceed"] is True
|
||||
assert gate(router, capability="send_message", external=True)[1]["proceed"] is True, "session is reusable for the same run and hash"
|
||||
status, body = gate(router, capability="send_message", external=True, argument_hash=OTHER_HASH)
|
||||
assert body["proceed"] is False and "different arguments" in body["reason"]
|
||||
status, body = gate(router, run_id="run_z", capability="send_message", external=True, conversation_id=CONV)
|
||||
assert body["proceed"] is False and body["reason"] == "no approval for this run and capability"
|
||||
assert gate(router, run_id="run_z", capability="send_message", external=False)[1]["proceed"] is False, "an external approval is not a general one"
|
||||
always = pending(router, "send_message", external=True, run_id="run_y")
|
||||
call(router, "POST", f"/hux/v1/approvals/{always['id']}", {"choice": "always"})
|
||||
assert gate(router, run_id="run_y", capability="send_message", external=True)[1]["proceed"] is True
|
||||
assert gate(router, run_id="run_w", capability="send_message", external=True)[1]["proceed"] is False, "always never spans runs for external effects"
|
||||
|
||||
|
||||
def test_once_approvals_name_exactly_one_tool_call(router):
|
||||
"""F4 / SO-37: a request carrying two tool_call hashes is refused so a once approval designates one hash."""
|
||||
body = request_body("shell")
|
||||
body["request"]["evidence"] = [{"kind": "tool_call", "id": "a", "hash": HASH}, {"kind": "tool_call", "id": "b", "hash": OTHER_HASH}]
|
||||
status, error = call(router, "POST", "/hux/v1/approvals", body, WORKER)
|
||||
assert (status, error["code"]) == (400, "invalid") and "exactly one tool_call" in error["message"]
|
||||
body["request"]["evidence"] = [{"kind": "tool_call", "id": "a", "hash": HASH}, {"kind": "file", "id": "notes.md"}]
|
||||
assert call(router, "POST", "/hux/v1/approvals", body, WORKER)[0] == 201, "other evidence kinds do not count"
|
||||
body["request"]["evidence"] = "not-a-list"
|
||||
assert call(router, "POST", "/hux/v1/approvals", body, WORKER)[0] == 400, "the contract still rejects it, without a 500"
|
||||
|
||||
|
||||
def test_gate_refuses_when_the_run_budget_is_exhausted(router, events):
|
||||
"""F6 (medium) / SO-40: an approved effect still does not proceed once any limit is spent."""
|
||||
record = pending(router, "shell")
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
||||
assert gate(router, capability="shell")[1]["proceed"] is True
|
||||
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": CONV}, "autonomy": "safe", "budgets": {"tool_calls_per_run": 2}})
|
||||
call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)
|
||||
status, body = gate(router, capability="shell")
|
||||
assert body == {"proceed": False, "reason": "budget_exhausted", "exhausted": ["tool_calls_per_run"]}
|
||||
assert events[-1]["kind"] == "budget.exhausted" and events[-1]["run_id"] == "run_9f" and events[-1]["conversation_id"] == CONV
|
||||
assert gate(router, run_id="run_fresh", capability="shell")[1]["proceed"] is True, "the budget is per run"
|
||||
assert budgets.run_conversation(router_store(router), "run_nobody") is None
|
||||
|
||||
|
||||
def router_store(router):
|
||||
return store.TenantStore(router.data_root, identity.resolve(HEADERS, {}))
|
||||
|
||||
236
testing/tests/test_hermes_hux_policy_hook.py
Normal file
236
testing/tests/test_hermes_hux_policy_hook.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Agent-side HUX hook driven end-to-end against the real service over HTTP.
|
||||
|
||||
Security obligations exercised: SO-35 (only the human surface decides; the
|
||||
hook's worker identity cannot), SO-36 (a ``once`` approval releases the gate
|
||||
exactly once), SO-37 (the gate compares the canonical argument hash the hook
|
||||
recorded at request time), SO-39 (an external side effect under
|
||||
``autonomous`` still waits for a human), SO-40 (an exhausted budget blocks new
|
||||
approvals), SO-41 (a stop is done only when the receipt exists), SO-11 and
|
||||
SO-07 (raw arguments and tool output never reach the tenant ledger).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
||||
HOOK_ROOT = ROOT / "dockerfiles" / "hermes-worker-hux"
|
||||
for entry in (FOUNDATION, HOOK_ROOT):
|
||||
if str(entry) not in sys.path:
|
||||
sys.path.insert(0, str(entry))
|
||||
|
||||
from hux import contracts # noqa: E402
|
||||
from hux.http import serve # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
from hux_hook import HuxClient, HuxServiceError, after_tool, before_tool, canonical_argument_hash, on_stop, record_spend # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
SUBJECT = "usr_0123456789abcdef"
|
||||
IDENTITY = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "worker", "trust": "worker"}
|
||||
HUMAN = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "chat", "trust": "router"}
|
||||
CANARY = "CANARY-7f3a-SECRET-VALUE"
|
||||
ARGS = {"path": "notes.md", "content": CANARY}
|
||||
RUN = "run_hook_1"
|
||||
|
||||
|
||||
def start(tmp_path: Path, flags: str = ALL_ON) -> tuple[str, object]:
|
||||
"""Run the real service on an ephemeral loopback port for one test."""
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_WORKER_KEY": "wk"})
|
||||
server = serve(router, "127.0.0.1", 0)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return f"http://127.0.0.1:{server.server_address[1]}", server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path):
|
||||
base, server = start(tmp_path)
|
||||
human = HuxClient(base, HUMAN)
|
||||
conversation = human.post("/hux/v1/conversations", {"title": "hook test"}).body["id"]
|
||||
yield {"base": base, "root": tmp_path, "agent": HuxClient(base, IDENTITY, key="wk"), "human": human, "conv": conversation}
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def set_policy(human: HuxClient, autonomy: str, budgets: dict | None = None) -> None:
|
||||
body = {"scope": {"level": "global"}, "autonomy": autonomy}
|
||||
if budgets:
|
||||
body["budgets"] = budgets
|
||||
human.put("/hux/v1/policy", body)
|
||||
|
||||
|
||||
def ledger_text(root: Path) -> str:
|
||||
return "\n".join(p.read_text(errors="ignore") for p in root.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
def events_of(human: HuxClient, conversation: str) -> list[dict]:
|
||||
items = human.get(f"/hux/v1/conversations/{conversation}/events").body["items"]
|
||||
for item in items:
|
||||
assert contracts.validate_record(item, SCHEMAS) == []
|
||||
return items
|
||||
|
||||
|
||||
# --- approval -> pending -> human decision -> gate once ------------------------------
|
||||
|
||||
def test_once_approval_releases_exactly_once(service):
|
||||
"""SO-35, SO-36, SO-37: pending until a human decides, then one release; the second gate is blocked."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
first = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files", risk="low", turn=1)
|
||||
assert (first.proceed, first.reason) == (False, "approval_required")
|
||||
assert first.approval_id and first.approval_id.startswith("apr_")
|
||||
record = human.get(f"/hux/v1/approvals/{first.approval_id}").body
|
||||
assert contracts.validate_record(record, SCHEMAS) == []
|
||||
assert record["request"]["evidence"][0]["hash"] == canonical_argument_hash("write_file", ARGS)
|
||||
assert CANARY not in json.dumps(record)
|
||||
with pytest.raises(HuxServiceError) as denied:
|
||||
agent.post(f"/hux/v1/approvals/{first.approval_id}", {"choice": "once"})
|
||||
assert denied.value.status == 403
|
||||
human.post(f"/hux/v1/approvals/{first.approval_id}", {"choice": "once"})
|
||||
second = before_tool(agent, RUN, conv, "write_file", {"content": CANARY, "path": "notes.md"}, "write_files", risk="low")
|
||||
assert second == second.__class__(True, first.approval_id, "released")
|
||||
third = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files", risk="low")
|
||||
assert third.proceed is False and "consumed" in third.reason
|
||||
kinds = [e["kind"] for e in events_of(human, conv)]
|
||||
assert kinds.count("side_effect.released") == 1 and "side_effect.blocked" in kinds and "approval.requested" in kinds
|
||||
assert CANARY not in ledger_text(service["root"])
|
||||
|
||||
|
||||
def test_different_arguments_after_approval_are_blocked(service):
|
||||
"""SO-37: an approval for one argument hash never releases another."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
pending = before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell")
|
||||
human.post(f"/hux/v1/approvals/{pending.approval_id}", {"choice": "once"})
|
||||
other = before_tool(agent, RUN, conv, "shell", {"cmd": "rm -rf /"}, "shell")
|
||||
assert other.proceed is False and other.reason == "approval_required"
|
||||
|
||||
|
||||
def test_denied_and_session_choices(service):
|
||||
"""A denial fails closed; a session grant lets a later call in the same conversation auto-approve."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
pending = before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell")
|
||||
human.post(f"/hux/v1/approvals/{pending.approval_id}", {"choice": "deny"})
|
||||
assert before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell") == pending.__class__(False, pending.approval_id, "approval_denied")
|
||||
pending2 = before_tool(agent, RUN, conv, "shell", {"cmd": "pwd"}, "shell")
|
||||
human.post(f"/hux/v1/approvals/{pending2.approval_id}", {"choice": "session"})
|
||||
assert before_tool(agent, "run_hook_2", conv, "shell", {"cmd": "whoami"}, "shell").proceed is True
|
||||
|
||||
|
||||
def test_external_side_effect_under_autonomous_still_asks(service):
|
||||
"""SO-39: autonomy never auto-allows an external side effect."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
set_policy(human, "autonomous")
|
||||
decision = before_tool(agent, RUN, conv, "send_email", {"to": "x@example.com"}, "send_message", external=True, risk="high")
|
||||
assert (decision.proceed, decision.reason) == (False, "approval_required")
|
||||
local = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
|
||||
assert local.proceed is True and local.reason == "released"
|
||||
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").proceed is False
|
||||
|
||||
|
||||
def test_budget_exhaustion_blocks_new_approvals(service):
|
||||
"""SO-40: once the run budget is spent the hook reports budget_exhausted and never proceeds."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
set_policy(human, "autonomous", {"tool_calls_per_run": 2})
|
||||
state = record_spend(agent, RUN, conv, tool_calls=2, tokens=10, bogus=5)
|
||||
assert state is not None and contracts.validate_record(state, SCHEMAS) == []
|
||||
assert state["exhausted"] == ["tool_calls_per_run"]
|
||||
decision = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
|
||||
assert decision == decision.__class__(False, None, "budget_exhausted")
|
||||
assert record_spend(agent, RUN, conv, tokens=-3)["spent"]["tokens"] == 10
|
||||
assert record_spend(agent, RUN, tool_calls=1)["spent"]["tool_calls"] == 3
|
||||
|
||||
|
||||
def test_stop_receipt_is_written_once(service):
|
||||
"""SO-41: the receipt reports what really happened and a repeat returns the same record."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
effects = [{"description": "partial notes.md", "reverted": True}]
|
||||
receipt = on_stop(agent, RUN, conv, process_registry_empty=True, side_effects=effects)
|
||||
assert contracts.validate_record(receipt, SCHEMAS) == []
|
||||
assert receipt["outcome"] == "cancelled" and receipt["side_effects"] == effects
|
||||
assert on_stop(agent, RUN, conv, process_registry_empty=False)["id"] == receipt["id"]
|
||||
failed = on_stop(agent, "run_hook_3", None, process_registry_empty=False)
|
||||
assert failed["outcome"] == "failed_to_cancel" and "conversation_id" not in failed
|
||||
assert on_stop(agent, "run_hook_4", conv, True, already_complete=True)["outcome"] == "already_complete"
|
||||
assert [e["kind"] for e in events_of(human, conv)].count("run.cancelled") == 2
|
||||
|
||||
|
||||
def test_after_tool_records_status_only(service):
|
||||
"""SO-11: tool.result carries sizes and status; the output and arguments never land."""
|
||||
agent, conv = service["agent"], service["conv"]
|
||||
digest = canonical_argument_hash("shell", {"cmd": CANARY})
|
||||
event = after_tool(agent, RUN, conv, "shell", True, 512, turn=3, argument_hash=digest, duration_ms=40, exit_code=0)
|
||||
assert event["kind"] == "tool.result" and event["detail"] == {"tool": "shell", "ok": True, "bytes": 512, "duration_ms": 40, "exit_code": 0}
|
||||
assert event["evidence"][0]["hash"] == digest and event["turn"] == 3
|
||||
replay = after_tool(agent, RUN, conv, "shell", True, 512, turn=3, argument_hash=digest)
|
||||
assert replay["id"] == event["id"]
|
||||
plain = after_tool(agent, RUN, conv, "shell", False, -1)
|
||||
assert plain["detail"] == {"tool": "shell", "ok": False, "bytes": 0} and "evidence" not in plain
|
||||
assert CANARY not in ledger_text(service["root"])
|
||||
|
||||
|
||||
def test_bad_ids_and_service_errors_fail_closed(service):
|
||||
"""Malformed run or conversation ids never reach a tool execution."""
|
||||
agent, conv = service["agent"], service["conv"]
|
||||
assert before_tool(agent, "run/../x", conv, "shell", {}, "shell").reason == "invalid_run_id"
|
||||
assert before_tool(agent, RUN, "not an id", "shell", {}, "shell").reason == "service_error:invalid"
|
||||
assert before_tool(agent, RUN, conv, "shell", {}, "teleport").reason == "service_error:invalid"
|
||||
|
||||
|
||||
def test_gate_failures_fail_closed(service, monkeypatch):
|
||||
"""A gate that errors, answers nonsense or says no keeps the tool from running."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
set_policy(human, "autonomous")
|
||||
real_post = agent.post
|
||||
|
||||
def broken_gate(path, body, idempotency_key=None):
|
||||
if path.endswith("/gate"):
|
||||
raise HuxServiceError(500, "invalid", "boom")
|
||||
return real_post(path, body, idempotency_key)
|
||||
|
||||
monkeypatch.setattr(agent, "post", broken_gate)
|
||||
decision = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
|
||||
assert decision.proceed is False and decision.reason == "service_error:invalid" and decision.approval_id
|
||||
|
||||
class Odd:
|
||||
body = "not a dict"
|
||||
|
||||
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: Odd() if path.endswith("/gate") else real_post(path, body, idempotency_key))
|
||||
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "gate_invalid"
|
||||
|
||||
class NoProceed:
|
||||
body = {"proceed": False}
|
||||
|
||||
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: NoProceed() if path.endswith("/gate") else real_post(path, body, idempotency_key))
|
||||
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "gate_blocked"
|
||||
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: Odd())
|
||||
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "approval_invalid"
|
||||
|
||||
|
||||
def test_unreachable_service_fails_closed_for_gate_and_open_for_telemetry(tmp_path):
|
||||
"""No service: the gate says no, telemetry returns None, nothing raises into the loop."""
|
||||
base, server = start(tmp_path)
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
agent = HuxClient(base, IDENTITY, key="wk", timeout=1)
|
||||
decision = before_tool(agent, RUN, "conv_0001abcd", "shell", {"cmd": "ls"}, "shell")
|
||||
assert decision == decision.__class__(False, None, "hux_unavailable")
|
||||
assert after_tool(agent, RUN, "conv_0001abcd", "shell", True, 1) is None
|
||||
assert record_spend(agent, RUN, "conv_0001abcd", tool_calls=1) is None
|
||||
assert on_stop(agent, RUN, "conv_0001abcd", True) is None
|
||||
|
||||
|
||||
def test_autonomy_flag_off_is_reported_and_fails_closed(tmp_path):
|
||||
"""SO-50: with hux.autonomy off, capabilities says so and the hook refuses every side effect."""
|
||||
base, server = start(tmp_path, "hux.foundation,hux.activity_timeline,hux.projects")
|
||||
try:
|
||||
agent = HuxClient(base, IDENTITY, key="wk")
|
||||
caps = agent.capabilities()
|
||||
assert caps["reachable"] and caps["cards"]["HUX-11"] and caps["cards"]["HUX-01"] and not caps["cards"]["HUX-05"]
|
||||
decision = before_tool(agent, RUN, "conv_0001abcd", "shell", {"cmd": "ls"}, "shell")
|
||||
assert decision == decision.__class__(False, None, "autonomy_off")
|
||||
finally:
|
||||
server.shutdown()
|
||||
@ -20,7 +20,7 @@ FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
||||
if str(FOUNDATION) not in sys.path:
|
||||
sys.path.insert(0, str(FOUNDATION))
|
||||
|
||||
from hux import audit, contracts, identity, policy, rules, store # noqa: E402
|
||||
from hux import audit, contracts, errors, identity, policy, rules, store # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
@ -189,3 +189,34 @@ def test_helpers_round_trip_time_and_actors():
|
||||
assert policy.run_key("run_9f") == policy.run_key("run_9f") and len(policy.run_key("x")) == 32
|
||||
assert policy.public({"a": 1, "_b": 2, "revision": 3}) == {"a": 1}
|
||||
assert policy.public({"a": 1, "_b": 2, "revision": 3}, revisioned=True) == {"a": 1, "revision": 3}
|
||||
|
||||
|
||||
# --- F1: only humans write policy ---------------------------------------------------
|
||||
|
||||
def test_only_a_human_actor_may_write_policy_or_hold_allow_grants(tmp_path):
|
||||
"""F1 (critical): worker and api surfaces cannot rewrite the policy, escalate autonomy or plant allow grants."""
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
|
||||
worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
api = {**HEADERS, "X-Hux-Surface": "api"}
|
||||
relay = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"}
|
||||
escalate = {"scope": {"level": "global"}, "autonomy": "autonomous", "grants": [{"capability": "network", "decision": "allow"}]}
|
||||
for headers in (worker, api):
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, headers)
|
||||
assert (status, body["code"]) == (403, "forbidden"), headers
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, headers)
|
||||
assert status == 403, "even a harmless-looking write is a policy write"
|
||||
status, body, _ = call(router, "GET", "/hux/v1/policy", headers=api)
|
||||
assert status == 200 and body["autonomy"] == "safe" and body["grants"] == [], "nothing leaked through"
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, relay)
|
||||
assert status == 200 and body["autonomy"] == "autonomous" and body["grants"][0]["granted_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
|
||||
rows = [(r["action"], r["outcome"]) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "policy.write"]
|
||||
assert rows == [("policy.write", "deny")] * 4 + [("policy.write", "allow")]
|
||||
# The helpers assert the invariant even when a caller reaches them without the route.
|
||||
system = identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "worker", "worker")
|
||||
with pytest.raises(errors.Forbidden):
|
||||
policy.normalise_grants([{"capability": "shell", "decision": "allow"}], system)
|
||||
assert policy.normalise_grants([{"capability": "shell", "decision": "deny"}], system)[0]["granted_by"] == {"type": "system", "id": "worker"}
|
||||
with pytest.raises(errors.Forbidden):
|
||||
policy.add_grant(store.TenantStore(tmp_path, system), system, "global", None, "shell", timedelta(hours=1))
|
||||
assert policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "chat", "router"))
|
||||
assert not policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "api", "router"))
|
||||
|
||||
@ -103,9 +103,9 @@ def test_budget_rejects_bad_bodies(router, body):
|
||||
|
||||
def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_path):
|
||||
side_effects = [{"description": "Partial file left in workspace", "reverted": True, "evidence": {"kind": "file", "id": "notes.md"}}]
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": side_effects, "conversation_id": CONV}, HEADERS)
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": side_effects, "conversation_id": CONV})
|
||||
assert status == 201 and valid(body)["outcome"] == "cancelled"
|
||||
assert body["requested_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
|
||||
assert body["requested_by"] == {"type": "system", "id": "worker"}
|
||||
assert body["requested_at"] == body["acknowledged_at"] == body["completed_at"]
|
||||
assert body["side_effects"] == side_effects and body["conversation_id"] == CONV and "revision" not in body
|
||||
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
|
||||
@ -116,6 +116,28 @@ def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_
|
||||
assert rows == [("runs.stop", "cancelled"), ("runs.stop", "replayed")]
|
||||
|
||||
|
||||
def test_only_the_gateway_may_vouch_for_an_empty_registry_and_a_failed_receipt_can_be_superseded(router, events, tmp_path):
|
||||
"""F8 / SO-41: a human surface asserting ``process_registry_empty`` gets ``failed_to_cancel``; the worker's later real cancel supersedes it."""
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "conversation_id": CONV}, HEADERS)
|
||||
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
|
||||
first_requested = body["requested_at"]
|
||||
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True}, HEADERS)
|
||||
assert (status, again) == (200, body), "an identical failed stop replays"
|
||||
status, done = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": [{"description": "x", "reverted": True}]})
|
||||
assert status == 201 and valid(done)["outcome"] == "cancelled" and done["requested_at"] == first_requested and "completed_at" in done
|
||||
assert done["conversation_id"] == CONV and done["requested_by"] == {"type": "system", "id": "worker"}
|
||||
tenant = store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))
|
||||
stored = tenant.get("receipts", done["id"])
|
||||
assert stored["revision"] == 2 and contracts.validate_record(stored, SCHEMAS) == [], "stored receipts carry revision (F11)"
|
||||
status, third = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
|
||||
assert (status, third) == (200, done), "a terminal receipt never changes again"
|
||||
rows = [r.get("reason") for r in audit.recent(tenant) if r["action"] == "runs.stop"]
|
||||
assert rows == ["registry_state_not_from_gateway", "replayed", "superseded:cancelled", "replayed"]
|
||||
assert [e["summary"] for e in events] == ["Run stopped: failed_to_cancel (registry_state_not_from_gateway)", "Run stopped: cancelled (cancelled)"]
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_h/stop", {"already_complete": True}, HEADERS)
|
||||
assert valid(body)["outcome"] == "already_complete", "already_complete needs no registry claim"
|
||||
|
||||
|
||||
def test_stop_outcomes_follow_the_process_registry(router):
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_a/stop", {"process_registry_empty": False, "side_effects": []})
|
||||
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
|
||||
|
||||
@ -90,6 +90,7 @@ def test_policy_route_serves_rules_and_reports_stale_audit(tmp_path):
|
||||
|
||||
def test_notice_is_recorded_scoped_and_emitted(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "hello")
|
||||
status, body, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": CONV, "controls": ["dismiss", "forget_this_conversation", "bogus"]})
|
||||
assert status == 201 and body["schema"] == "hux.privacy_notice.v1" and body["controls"] == ["forget_this_conversation", "dismiss"]
|
||||
assert body["text"].startswith("This looks like a health topic")
|
||||
@ -98,7 +99,7 @@ def test_notice_is_recorded_scoped_and_emitted(tmp_path):
|
||||
assert s.read(privacy.FAMILY, "notices") == [body]
|
||||
state = privacy.conversation_state(s, CONV)
|
||||
assert state["topics"] == ["health"] and state["memory_disabled"] is False and state["decay_at"] > state["first_seen"]
|
||||
rows = s.read(events.FAMILY, CONV)
|
||||
rows = s.read(events.FAMILY, CONV)[1:]
|
||||
assert [r["kind"] for r in rows] == ["privacy.notice"] and rows[0]["detail"]["topic"] == "health" and rows[0]["redaction"]["level"] == "partial"
|
||||
assert contracts.validate_record(rows[0], SCHEMAS) == []
|
||||
status, again, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "relationships", "conversation_id": CONV})
|
||||
@ -142,7 +143,7 @@ def test_forget_route_redacts_events_and_marks_state(tmp_path):
|
||||
events.emit(s, ident(), CONV, "message.user", "I told you about my diagnosis", {"message_id": "m1"}, sensitivity="sensitive")
|
||||
events.emit(s, ident(), CONV, "message.assistant", "noted")
|
||||
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/forget")
|
||||
assert status == 200 and body == {"conversation_id": CONV, "forgotten": True, "memory_forgotten": 0, "events_redacted": 2}
|
||||
assert status == 200 and body == {"conversation_id": CONV, "forgotten": True, "memory_forgotten": 0, "events_redacted": 2, "document_blanked": False}
|
||||
rows = s.read(events.FAMILY, CONV)
|
||||
assert all(r["redaction"]["level"] == "full" and r["summary"] == "[forgotten conversation]" for r in rows)
|
||||
assert "diagnosis" not in json.dumps(rows)
|
||||
@ -170,3 +171,38 @@ def test_flag_off_hides_privacy_routes(tmp_path):
|
||||
|
||||
def test_privacy_module_stays_under_500_lines():
|
||||
assert len((FOUNDATION / "hux" / "privacy.py").read_text().splitlines()) <= 500
|
||||
|
||||
|
||||
def test_f13a_notice_for_an_unknown_conversation_does_not_create_a_ghost_ledger(tmp_path):
|
||||
"""F13a / SO-18: the notice is stored and scoped, but no event ledger or checkpoint is conjured for an unknown conversation."""
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
status, body, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": "conv_ghost0001"})
|
||||
assert status == 201 and s.read(privacy.FAMILY, "notices") == [body]
|
||||
assert privacy.conversation_state(s, "conv_ghost0001")["topics"] == ["health"]
|
||||
assert events.conversation_known(s, "conv_ghost0001") is False and s.read(events.FAMILY, "conv_ghost0001") == []
|
||||
assert [(r["action"], r["reason"]) for r in audit.recent(s) if r["action"] == "privacy.notice"] == [("privacy.notice", "unknown_conversation")]
|
||||
assert call(router, "GET", "/hux/v1/conversations/conv_ghost0001/events", HEADERS)[0] == 404
|
||||
|
||||
|
||||
def test_f9_forget_blanks_the_conversation_document(tmp_path):
|
||||
"""F9 / SO-22: forgetting a HUX-03 conversation blanks its title and tags and archives it, so nothing of it is searchable."""
|
||||
router = router_for(tmp_path)
|
||||
status, doc, _ = call(router, "POST", "/hux/v1/conversations", HEADERS, {"title": "Doctor visit notes", "tags": ["health", "private"]})
|
||||
assert status == 201
|
||||
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{doc['id']}/forget", HEADERS)
|
||||
assert status == 200 and body["document_blanked"] is True
|
||||
status, after, _ = call(router, "GET", f"/hux/v1/conversations/{doc['id']}", HEADERS)
|
||||
assert after["title"] == "[forgotten]" and after["tags"] == [] and after["archived"] is True and after["revision"] == 2
|
||||
assert contracts.validate_record(after, SCHEMAS) == []
|
||||
assert call(router, "GET", "/hux/v1/search?q=doctor", HEADERS)[1]["items"] == []
|
||||
assert privacy._blank_document(tenant(tmp_path), "conv_none000001") is False
|
||||
|
||||
|
||||
def test_f9_forget_tolerates_a_missing_organization_lane(tmp_path, monkeypatch):
|
||||
"""F9: without the HUX-03 module there is no document to blank and forget still succeeds."""
|
||||
import hux
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hux.organization", None)
|
||||
monkeypatch.delattr(hux, "organization", raising=False)
|
||||
assert privacy._blank_document(tenant(tmp_path), CONV) is False
|
||||
|
||||
@ -227,6 +227,20 @@ def test_validate_citations_reports_integrity_problems(router, monkeypatch):
|
||||
assert cite(router, [passage(router, source(router, "https://b.example/")["id"])["id"]], claim="silent", conversation_id="conv_0001abcd")["support"] == "supports"
|
||||
|
||||
|
||||
def test_f9_source_title_passage_text_and_citation_claim_are_scrubbed(router):
|
||||
"""F9 / SO-07: secrets in free-text research fields never persist; the passage hash covers the scrubbed text."""
|
||||
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
src = source(router, title=f"notes {token}")
|
||||
assert src["title"] == "notes [redacted:github_token]"
|
||||
psg = passage(router, src["id"], f"password: hunter2 and {token}")
|
||||
assert psg["text"] == "[redacted:password] and [redacted:github_token]"
|
||||
assert psg["hash"] == "sha256:" + hashlib.sha256(psg["text"].encode()).hexdigest()
|
||||
cit = cite(router, [psg["id"]], claim=f"claim {token}", note=f"note {token}")
|
||||
assert token not in cit["claim"] and token not in cit["note"]
|
||||
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_research_modules_stay_under_500_lines():
|
||||
assert len((FOUNDATION / "hux" / "research.py").read_text().splitlines()) <= 500
|
||||
|
||||
|
||||
@ -160,3 +160,15 @@ def test_notebook_paths_are_tenant_scoped(router):
|
||||
assert (tenant.root / "notebooks" / f"{record['id']}.json").exists()
|
||||
assert call(router, "GET", "/hux/v1/notebooks/nb_missing000001")[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/notebooks/NB")[0] == 404
|
||||
|
||||
|
||||
def test_f9_notebook_free_text_is_scrubbed(router, evidence):
|
||||
"""F9 / SO-07: question, assumptions, unresolved questions and notes are secret-scrubbed on create and patch."""
|
||||
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
record = notebook(router, question=f"q {token}", assumptions=[f"a {token}"], unresolved_questions=[f"u {token}"])
|
||||
assert record["question"] == "q [redacted:github_token]" and record["assumptions"] == ["a [redacted:github_token]"]
|
||||
assert record["unresolved_questions"] == ["u [redacted:github_token]"]
|
||||
status, patched, _ = call(router, "PATCH", f"/hux/v1/notebooks/{record['id']}", {"add_notes": [{"text": f"n {token}"}], "assumptions": [f"b {token}"]}, {"If-Match": "1"})
|
||||
assert status == 200 and patched["notes"][0]["text"] == "n [redacted:github_token]" and patched["assumptions"] == ["b [redacted:github_token]"]
|
||||
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
|
||||
assert leaked == []
|
||||
|
||||
198
testing/tests/test_hermes_stt_rolling_model.py
Normal file
198
testing/tests/test_hermes_stt_rolling_model.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Release gates for Hermes' disposable tiny rolling transcription model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVER = ROOT / "dockerfiles" / "hermes-jetson-stt-server.py"
|
||||
DOCKERFILE = ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt"
|
||||
PIPELINE = ROOT / "ci" / "Jenkinsfile.hermes-voice-image"
|
||||
|
||||
|
||||
def _load_server(monkeypatch):
|
||||
"""Import the server without requiring image-only CUDA dependencies."""
|
||||
spec = importlib.util.spec_from_file_location("hermes_stt_dual_model", SERVER)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"torch",
|
||||
SimpleNamespace(
|
||||
cuda=SimpleNamespace(is_available=lambda: False, synchronize=lambda: None)
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class _Connection:
|
||||
"""Capture asynchronous stream responses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[dict] = []
|
||||
self.condition = threading.Condition()
|
||||
|
||||
def send_json(self, payload: dict) -> None:
|
||||
with self.condition:
|
||||
self.messages.append(payload)
|
||||
self.condition.notify_all()
|
||||
|
||||
def wait_for(self, message_type: str, timeout: float = 1.0) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
with self.condition:
|
||||
while time.monotonic() < deadline:
|
||||
for message in self.messages:
|
||||
if message.get("type") == message_type:
|
||||
return message
|
||||
self.condition.wait(deadline - time.monotonic())
|
||||
raise AssertionError(f"missing {message_type}: {self.messages}")
|
||||
|
||||
|
||||
class _Model:
|
||||
"""Return deterministic Whisper output while recording model ownership."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def transcribe(self, path: str, **options: object) -> dict:
|
||||
self.calls.append({"path": path, "options": options})
|
||||
return {
|
||||
"text": self.text,
|
||||
"language": "en",
|
||||
"segments": [
|
||||
{
|
||||
"text": self.text,
|
||||
"no_speech_prob": 0.01,
|
||||
"avg_logprob": -0.1,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _start(session) -> None:
|
||||
session.start(
|
||||
{
|
||||
"type": "start",
|
||||
"turn_id": "dual-model-turn",
|
||||
"format": "pcm_s16le",
|
||||
"sample_rate": 16_000,
|
||||
"language": "auto",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _speech(samples: int = 1_600) -> bytes:
|
||||
return struct.pack(f"<{samples}h", *([12_000] * samples))
|
||||
|
||||
|
||||
def _wait_idle(session) -> None:
|
||||
deadline = time.monotonic() + 1.0
|
||||
with session._lock:
|
||||
while session._rolling_inflight and time.monotonic() < deadline:
|
||||
session._lock.wait(deadline - time.monotonic())
|
||||
assert not session._rolling_inflight
|
||||
|
||||
|
||||
def test_rolling_uses_tiny_once_while_final_remains_small(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
|
||||
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
|
||||
final_model = _Model("authoritative small")
|
||||
rolling_model = _Model("provisional tiny")
|
||||
connection = _Connection()
|
||||
session = module.StreamingTranscription(connection, final_model, rolling_model)
|
||||
_start(session)
|
||||
|
||||
session.append(_speech())
|
||||
partial = connection.wait_for("partial")
|
||||
assert partial["transcript"] == "provisional tiny"
|
||||
assert partial["stable_transcript"] == ""
|
||||
assert len(rolling_model.calls) == 1
|
||||
assert final_model.calls == []
|
||||
|
||||
session.append(_speech())
|
||||
_wait_idle(session)
|
||||
assert len(rolling_model.calls) == 1
|
||||
session.commit()
|
||||
assert connection.wait_for("final")["transcript"] == "authoritative small"
|
||||
assert len(final_model.calls) == 1
|
||||
|
||||
|
||||
def test_eos_does_not_rearm_but_resumed_speech_does(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
|
||||
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
|
||||
final_model = _Model("final")
|
||||
rolling_model = _Model("rolling")
|
||||
session = module.StreamingTranscription(_Connection(), final_model, rolling_model)
|
||||
_start(session)
|
||||
|
||||
session.append(_speech())
|
||||
_wait_idle(session)
|
||||
assert session._rolling_attempted is True
|
||||
session.append(bytes(24_000))
|
||||
assert session._at_eos is True
|
||||
assert session._rolling_attempted is True
|
||||
|
||||
session.resume()
|
||||
assert session._rolling_attempted is False
|
||||
session.append(_speech())
|
||||
_wait_idle(session)
|
||||
assert len(rolling_model.calls) == 2
|
||||
|
||||
|
||||
def test_dual_warmup_and_separate_timing_are_declared(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
model = _Model("warm")
|
||||
synchronized: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
module.torch.cuda, "synchronize", lambda: synchronized.append(True)
|
||||
)
|
||||
|
||||
elapsed = module._warm_model(model)
|
||||
|
||||
assert elapsed >= 0
|
||||
assert module.ROLLING_MIN_AUDIO_MS == 1_800
|
||||
assert len(model.calls) == 1
|
||||
assert model.calls[0]["options"]["language"] is None
|
||||
assert synchronized == [True]
|
||||
assert not Path(model.calls[0]["path"]).exists()
|
||||
source = SERVER.read_text()
|
||||
rolling_warm = "STARTUP_ROLLING_WARMUP_MS = _warm_model(rolling_model)"
|
||||
final_warm = "STARTUP_WARMUP_MS = _warm_model(model)"
|
||||
assert source.index(rolling_warm) < source.index(final_warm)
|
||||
assert source.index(final_warm) < source.index("server = ThreadingHTTPServer")
|
||||
assert 'kind="rolling"' in source
|
||||
assert 'kind="speculative"' in source
|
||||
assert '"last_rolling_decode_ms"' in source
|
||||
assert '"last_speculative_decode_ms"' in source
|
||||
|
||||
|
||||
def test_tiny_model_is_checksum_pinned_read_only_in_the_runtime_image():
|
||||
dockerfile = DOCKERFILE.read_text()
|
||||
digest = "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9"
|
||||
assert f"--checksum=sha256:{digest} --chmod=0444" in dockerfile
|
||||
assert f"/models/{digest}/tiny.pt" in dockerfile
|
||||
assert "HERMES_STT_ROLLING_MODEL=tiny" in dockerfile
|
||||
assert "('large-v3-turbo.pt','small.pt','tiny.pt')" in dockerfile
|
||||
|
||||
|
||||
def test_dual_model_contract_is_an_exact_voice_release_gate():
|
||||
pipeline = PIPELINE.read_text()
|
||||
assert "testing/tests/test_hermes_stt_rolling_model.py" in pipeline
|
||||
source = SERVER.read_text()
|
||||
assert '"path": "/v1/audio/transcriptions/stream"' in source
|
||||
assert '"inference": INFERENCE_GATE.snapshot()' in source
|
||||
assert '"rolling_model": ROLLING_MODEL_NAME' in source
|
||||
assert "STARTUP_ROLLING_WARMUP_MS is not None" in source
|
||||
@ -239,7 +239,7 @@ def test_stream_speculation_is_reused_for_unchanged_commit(monkeypatch):
|
||||
assert model.calls[0][1]["condition_on_previous_text"] is False
|
||||
|
||||
|
||||
def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch):
|
||||
def test_active_speech_emits_only_one_disposable_rolling_partial(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
connection = _Connection()
|
||||
model = _SequencedModel(["please open", "please open my calendar"])
|
||||
@ -261,13 +261,12 @@ def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch):
|
||||
"window_start_ms": 0,
|
||||
}
|
||||
|
||||
# Audio progress is required in addition to wall-clock rate limiting.
|
||||
# More PCM in the same epoch must not launch disposable GPU work again.
|
||||
session._rolling_last_started_at = 0.0
|
||||
session.append(_speech(samples=12_000))
|
||||
second = connection.wait_for_revision(2)
|
||||
assert second["transcript"] == "please open my calendar"
|
||||
assert second["stable_transcript"] == "please open"
|
||||
assert len(model.calls) == 2
|
||||
time.sleep(0.02)
|
||||
assert len(model.calls) == 1
|
||||
assert not any(message.get("revision") == 2 for message in connection.messages)
|
||||
|
||||
|
||||
def test_rolling_window_is_bounded_but_final_decode_uses_full_utterance(monkeypatch):
|
||||
@ -338,7 +337,8 @@ def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
|
||||
|
||||
def occupy_gpu():
|
||||
module.INFERENCE_GATE.try_background(
|
||||
lambda: (entered.set(), release.wait(2.0))
|
||||
lambda: (entered.set(), release.wait(2.0)),
|
||||
kind="rolling",
|
||||
)
|
||||
|
||||
worker = threading.Thread(target=occupy_gpu)
|
||||
@ -353,12 +353,12 @@ def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
|
||||
release.set()
|
||||
worker.join(timeout=1.0)
|
||||
|
||||
# A later audio interval gets another opportunity once the final-priority
|
||||
# GPU lock is free; the skipped snapshot did not leave a queued worker.
|
||||
# A skipped epoch is never retried, so it cannot race the final later.
|
||||
session._rolling_last_started_at = 0.0
|
||||
session.append(_speech())
|
||||
assert connection.wait_for_revision(1)["transcript"] == "hello"
|
||||
assert len(model.calls) == 1
|
||||
time.sleep(0.02)
|
||||
assert connection.messages == []
|
||||
assert model.calls == []
|
||||
|
||||
|
||||
def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
|
||||
@ -369,7 +369,8 @@ def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
|
||||
|
||||
first = threading.Thread(
|
||||
target=lambda: module.INFERENCE_GATE.try_background(
|
||||
lambda: (first_entered.set(), release_first.wait(2.0))
|
||||
lambda: (first_entered.set(), release_first.wait(2.0)),
|
||||
kind="rolling",
|
||||
)
|
||||
)
|
||||
first.start()
|
||||
@ -382,7 +383,7 @@ def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
|
||||
while module.INFERENCE_GATE._final_waiters < 1 and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
assert module.INFERENCE_GATE._final_waiters == 1
|
||||
assert module.INFERENCE_GATE.try_background(lambda: "ran") is None
|
||||
assert module.INFERENCE_GATE.try_background(lambda: "ran", kind="rolling") is None
|
||||
release_first.set()
|
||||
first.join(timeout=1.0)
|
||||
final.join(timeout=1.0)
|
||||
@ -464,36 +465,3 @@ def test_stream_contract_rejects_wrong_format_and_oversize(monkeypatch):
|
||||
monkeypatch.setattr(module, "MAX_STREAM_AUDIO_BYTES", 4)
|
||||
with pytest.raises(module.WebSocketError, match="too long"):
|
||||
session.append(bytes(6))
|
||||
|
||||
|
||||
def test_stream_health_contract_is_declared_in_source():
|
||||
source = SERVER.read_text()
|
||||
|
||||
assert '"path": "/v1/audio/transcriptions/stream"' in source
|
||||
assert '"format": "pcm_s16le"' in source
|
||||
assert '"server_vad": True' in source
|
||||
assert '"speculative": True' in source
|
||||
assert '"inference": INFERENCE_GATE.snapshot()' in source
|
||||
assert '"minimum_audio_ms": ROLLING_MIN_AUDIO_MS' in source
|
||||
assert '"warmed": STARTUP_WARMUP_MS is not None' in source
|
||||
assert "MAX_STREAM_AUDIO_BYTES" in source
|
||||
|
||||
|
||||
def test_startup_warms_canonical_decode_before_serving(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
model = _Model()
|
||||
synchronized: list[bool] = []
|
||||
monkeypatch.setattr(module.torch.cuda, "synchronize", lambda: synchronized.append(True))
|
||||
|
||||
elapsed = module._warm_model(model)
|
||||
|
||||
assert elapsed >= 0
|
||||
assert module.ROLLING_MIN_AUDIO_MS == 1_800
|
||||
assert len(model.calls) == 1
|
||||
assert model.calls[0][1]["language"] is None
|
||||
assert synchronized == [True]
|
||||
assert not Path(model.calls[0][0]).exists()
|
||||
source = SERVER.read_text()
|
||||
assert source.index("STARTUP_WARMUP_MS = _warm_model(model)") < source.index(
|
||||
"server = ThreadingHTTPServer"
|
||||
)
|
||||
|
||||
@ -42,7 +42,10 @@ def test_barge_in_cancels_owned_turn_and_reuses_pcm_lookback():
|
||||
assert "reusedCapture.lookback.forEach" in source
|
||||
assert "const bytes=new Uint8Array(16)" in source
|
||||
assert "window.crypto.getRandomValues(bytes)" in source
|
||||
assert "captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)" in source
|
||||
assert (
|
||||
"captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)"
|
||||
in source
|
||||
)
|
||||
|
||||
|
||||
def test_streamed_chunks_share_one_audio_timeline_until_final_drain():
|
||||
@ -71,7 +74,10 @@ def test_barge_cancel_settles_before_new_turn_baseline_and_observer():
|
||||
)
|
||||
assert region.index("rememberAssistantBaseline()") < region.index("window.send()")
|
||||
assert region.index("window.send()") < region.index("startResponseObserver(token)")
|
||||
assert "suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'" in source
|
||||
assert (
|
||||
"suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'"
|
||||
in source
|
||||
)
|
||||
|
||||
|
||||
def test_barge_handoff_never_uses_a_truncated_container_fallback():
|
||||
@ -96,7 +102,14 @@ def test_rolling_partials_are_feedback_only_and_wav_fallback_is_owned():
|
||||
)[0]
|
||||
|
||||
assert "payload.stable_transcript" in partial
|
||||
assert "label.textContent='Listening · '+preview" in partial
|
||||
assert "const provisional=String(payload.transcript||'').trim()" in partial
|
||||
assert "const visible=stable||provisional" in partial
|
||||
assert (
|
||||
"label.textContent='Listening · '+preview+(stable?'':' · provisional')"
|
||||
in partial
|
||||
)
|
||||
assert "if(stable){" in partial
|
||||
assert "scheduleVoicePreflight(turnId,revision,stable)" in partial
|
||||
assert "composer.value" not in partial
|
||||
assert "window.send" not in partial
|
||||
assert "signal:controller.signal" in fallback
|
||||
@ -113,7 +126,10 @@ def test_stt_client_queue_and_canonical_archive_are_strictly_bounded():
|
||||
assert "queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES" in source
|
||||
assert "Streaming transcription backpressure limit exceeded" in source
|
||||
assert "if(flushTimer||settled||!queue.length" in source
|
||||
assert "flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)" in source
|
||||
assert (
|
||||
"flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)"
|
||||
in source
|
||||
)
|
||||
assert "function clearQueue(){queue=[];queuedBytes=0;clearFlushTimer();}" in source
|
||||
assert "takeFallbackBlob:function()" in source
|
||||
assert "pcm16WavBlob(archive,16000)" in source
|
||||
|
||||
@ -359,8 +359,12 @@ def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None:
|
||||
assert "--hermes-grid: rgba(72, 207, 204, 0.025)" in dark
|
||||
assert ".empty-logo .hermes-agent-portrait" in css
|
||||
assert "width: 112px" in css
|
||||
assert ":root.dark .messages" in css
|
||||
assert ":root.dark .messages-shell" in css
|
||||
assert "background-size: 32px 32px, 32px 32px, auto, auto" in css
|
||||
assert "background-repeat: repeat, repeat, no-repeat, no-repeat" in css
|
||||
messages = css.rsplit(":root.dark .messages {", 1)[1].split("}", 1)[0]
|
||||
assert "background-image: none" in messages
|
||||
assert "radial-gradient" not in messages
|
||||
assert ":root.dark .session-item.active" in css
|
||||
assert ":root.dark .suggestion:focus-visible" in css
|
||||
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user