Compare commits

...

2 Commits

Author SHA1 Message Date
jenkins
c0a9c92ee4 hermes(worker): stage inert HUX foundation on the worker instance
worker.bstein.dev (the hermes-agent Deployment) gains the same HUX
shape as chat, staged and inert: a foundation-only hux sidecar on the
reviewed WebUI image line (Flux setters bound, 5s probe budgets), an
init that provisions the HMAC identity as slot-100 on the durable home
subtree (create-once context key, O_EXCL subject binding, per-pod
worker key; no relay/router/evidence keys so those trusts fail closed),
and observe-only hook env in the agent container with the runtime
plugin mounted but deliberately NOT enabled - activation is a reviewed
one-line flip per docs/hux/WORKER-PLAN.md, which carries the rollout,
verification gates, canary/rollback ladder and open questions.
Cross-surface continuity remains unclaimed until the live gates pass.
7 new topology-adaptive delivery gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 13:54:27 -03:00
jenkins
3bca7b7465 hermes(hux): inline approval prompts and ask-not-deny defaults
Re-enforcement prerequisites, code-side complete: the autonomy runtime
now docks pending-approval cards above the composer (newest first, cap
three, aria-live, allow-once / always / deny wired to the existing
decide route with idempotency; polling gated to active turns and
fail-tolerant), so parked tool calls are never silent. The default
capability matrix no longer denies by default: network and web_search
ask below autonomous (visible prompt) and nothing resolves to deny
except explicit grants or private mode; SO-39 stays intact - deploy and
external side effects always ask and external never auto-allows.
rules.py at 100% line+branch; 744 hux-lane tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 13:53:00 -03:00
12 changed files with 995 additions and 25 deletions

View File

@ -46,9 +46,9 @@ CAPABILITIES = (
"read_files", "write_files", "shell", "network", "web_search", "send_message",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect",
)
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
_READ_ONLY = frozenset({"read_files", "spend_tokens"})
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
_EXTERNAL = frozenset({"network", "deploy"})
_NETWORK = frozenset({"network", "web_search"})
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
MODE_CATALOG: dict[str, dict[str, Any]] = {
@ -142,7 +142,13 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
def default_capability_matrix() -> dict[str, dict[str, str]]:
"""Autonomy level -> capability -> allow|ask|deny. Deploy and external side effects always ask."""
"""Autonomy level -> capability -> allow|ask. Deploy and external side effects always ask (SO-39).
Network access (``network``/``web_search``) asks below ``autonomous`` instead of
denying: for a chat assistant whose core value includes browsing, the right
default is a visible approval prompt, never a silent block. No default is
``deny``; denials only come from explicit grants or private mode.
"""
matrix: dict[str, dict[str, str]] = {}
for level in ("ask_first", "safe", "autonomous"):
row: dict[str, str] = {}
@ -151,10 +157,9 @@ def default_capability_matrix() -> dict[str, dict[str, str]]:
row[capability] = "ask"
elif capability in _READ_ONLY:
row[capability] = "allow"
elif capability in _MUTATING:
else:
# _NETWORK and _MUTATING park with a visible prompt below autonomous.
row[capability] = "ask" if level != "autonomous" else "allow"
elif capability in _EXTERNAL:
row[capability] = {"ask_first": "ask", "safe": "deny", "autonomous": "allow"}[level]
matrix[level] = row
return matrix

View File

@ -117,6 +117,48 @@
border-color: color-mix(in srgb, #22d3ee 30%, currentColor);
}
/* Inline approval prompts: a fixed dock near the composer so parked tool
calls surface in the chat flow instead of hiding in the drawer. */
.hux-runtime-inline-dock {
bottom: 6.5rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
left: 0;
margin: 0 auto;
max-height: 50vh;
max-width: 44rem;
overflow-y: auto;
padding: 0 0.75rem;
pointer-events: none;
position: fixed;
right: 0;
z-index: 60;
}
.hux-runtime-inline-dock[hidden] {
display: none;
}
.hux-runtime-inline-title {
align-self: flex-start;
background: Canvas;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
color: CanvasText;
font-size: 0.625rem;
margin: 0;
padding: 0.15rem 0.6rem;
}
.hux-runtime-approval.hux-runtime-inline-card {
background: Canvas;
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.25);
color: CanvasText;
margin-top: 0;
pointer-events: auto;
}
@container (max-width: 34rem) {
.hux-runtime-summary {
grid-template-columns: 1fr;

View File

@ -101,7 +101,8 @@
!identity || !actor || !exact(actor, ['type', 'id'], ['display']) || actor.type !== 'user' || actor.id !== identity.userRef) return null;
} else if (value.decision !== undefined) return null;
return {id: value.id, runId: value.run_id, capability: value.capability, summary: request.summary,
risk: request.risk, external: request.external, status: value.status, expiresAt: value.expires_at};
risk: request.risk, external: request.external, status: value.status,
requestedAt: value.requested_at, expiresAt: value.expires_at};
}
function normalizeApprovalPage(raw, conversationId) {
@ -203,7 +204,10 @@
let client = null; let autonomyRoot = null; let privacyRoot = null; let policy = null; let approvals = [];
let receipt = null; let privacyPolicy = null; let audits = []; let activeTopic = null; let privacyNotice = null;
let noticeShown = false; let noticePending = false; let privacyMessage = ''; let stopMessage = '';
let inlineDock = null; let pollTimer = null;
const issuedApprovals = new Set();
const pollMs = Number.isSafeInteger(settings.approvalPollMs) && settings.approvalPollMs >= 1 ? settings.approvalPollMs : 4000;
function timers() { return settings.timers || globalThis; }
function assertClient(candidate, flag) {
if (!candidate || candidate.apiVersion !== 'hux.v1' || !candidate.identity ||
@ -252,6 +256,64 @@
issuedApprovals.delete(approvalId); approvals = approvals.filter((item) => item.id !== approvalId); renderAutonomy(); return resolved;
}
async function refreshApprovals() {
if (!client) return null;
try {
const response = await request('/approvals?status=pending', {method: 'GET'});
const page = normalizeApprovalPage(await response.json(), settings.conversationId);
if (!page) return null;
approvals = page; page.forEach((item) => issuedApprovals.add(item.id));
renderAutonomy(); return page;
} catch (_) { return null; }
}
function startApprovalPolling() {
if (pollTimer !== null) return;
pollTimer = timers().setInterval(() => {
if (typeof settings.canStopModelResponse === 'function' && !settings.canStopModelResponse()) return;
void refreshApprovals();
}, pollMs);
if (pollTimer && typeof pollTimer.unref === 'function') pollTimer.unref();
}
function stopApprovalPolling() {
if (pollTimer === null) return;
timers().clearInterval(pollTimer); pollTimer = null;
}
function mountInlineDock() {
const host = settings.inlineHost || doc.body || null;
if (inlineDock || !host) return;
inlineDock = el(doc, 'section', {class: 'hux-runtime-inline-dock', role: 'region',
'aria-label': 'Approvals needed', 'aria-live': 'polite'});
inlineDock.hidden = true; host.appendChild(inlineDock);
}
function approvalCard(item, choices, extraClass) {
const card = el(doc, 'article', {class: `hux-runtime-approval${extraClass} risk-${item.risk}`});
card.appendChild(el(doc, 'strong', {}, item.summary));
card.appendChild(el(doc, 'p', {}, `${item.capability.replaceAll('_', ' ')} · ${item.risk} risk${item.external ? ' · external' : ''}`));
const actions = el(doc, 'div', {class: 'hux-runtime-actions'});
choices.forEach(([choice, label]) => {
const button = el(doc, 'button', {type: 'button'}, label);
button.addEventListener('click', () => { void decideApproval(item.id, choice).catch(() => card.appendChild(safeError(doc))); });
actions.appendChild(button);
});
card.appendChild(actions); return card;
}
function renderInline() {
if (!inlineDock) return;
inlineDock.replaceChildren();
const pending = (approvals || []).slice().sort((a, b) => b.requestedAt.localeCompare(a.requestedAt));
inlineDock.hidden = pending.length === 0;
if (!pending.length) return;
inlineDock.appendChild(el(doc, 'h2', {class: 'hux-runtime-inline-title'}, 'Approval needed'));
pending.slice(0, 3).forEach((item) => inlineDock.appendChild(approvalCard(item,
[['once', 'Allow once'], ['always', 'Always allow'], ['deny', 'Deny']], ' hux-runtime-inline-card')));
if (pending.length > 3) inlineDock.appendChild(status(doc, `${pending.length - 3} more waiting in the workspace drawer.`));
}
async function stopRun() {
if (typeof settings.stopModelResponse !== 'function') throw new TypeError('No owned model response is bound to this view');
const model = await settings.stopModelResponse();
@ -277,6 +339,7 @@
}
function renderAutonomy() {
renderInline();
if (!autonomyRoot) return; autonomyRoot.replaceChildren();
if (!policy || !approvals) { autonomyRoot.appendChild(status(doc, 'Loading autonomy controls…')); return; }
const title = el(doc, 'h2', {class: 'hux-runtime-title'}, 'Autonomy and approvals'); autonomyRoot.appendChild(title);
@ -290,17 +353,8 @@
picker.appendChild(save); autonomyRoot.appendChild(picker);
const queue = el(doc, 'section', {'aria-labelledby': 'hux-runtime-approvals'});
queue.appendChild(el(doc, 'h3', {id: 'hux-runtime-approvals'}, `Pending approvals (${approvals.length})`));
approvals.forEach((item) => {
const card = el(doc, 'article', {class: `hux-runtime-approval risk-${item.risk}`});
card.appendChild(el(doc, 'strong', {}, item.summary));
card.appendChild(el(doc, 'p', {}, `${item.capability.replaceAll('_', ' ')} · ${item.risk} risk${item.external ? ' · external' : ''}`));
const actions = el(doc, 'div', {class: 'hux-runtime-actions'});
[['once', 'Allow once'], ['session', 'Allow this session'], ['always', 'Always allow'], ['deny', 'Deny']].forEach(([choice, label]) => {
const button = el(doc, 'button', {type: 'button'}, label);
button.addEventListener('click', () => { void decideApproval(item.id, choice).catch(() => card.appendChild(safeError(doc))); }); actions.appendChild(button);
});
card.appendChild(actions); queue.appendChild(card);
});
approvals.forEach((item) => queue.appendChild(approvalCard(item,
[['once', 'Allow once'], ['session', 'Allow this session'], ['always', 'Always allow'], ['deny', 'Deny']], '')));
autonomyRoot.appendChild(queue);
const canStop = typeof settings.canStopModelResponse === 'function' && settings.canStopModelResponse();
if (canStop) { const stop = el(doc, 'button', {type: 'button', class: 'hux-runtime-stop'}, 'Stop model response');
@ -372,9 +426,11 @@
}
const autonomyExtension = {id: 'autonomy-controls', flag: 'hux.autonomy', label: 'Autonomy', order: 30, render(context) {
client = assertClient(context.client, 'hux.autonomy'); autonomyRoot = context.container; renderAutonomy();
client = assertClient(context.client, 'hux.autonomy'); autonomyRoot = context.container;
mountInlineDock(); renderAutonomy();
const target = autonomyRoot;
void loadAutonomy().catch(() => { if (autonomyRoot === target) target.replaceChildren(safeError(doc)); });
startApprovalPolling();
}};
const privacyExtension = {id: 'privacy-controls', flag: 'hux.privacy', label: 'Privacy', order: 40, render(context) {
client = assertClient(context.client, 'hux.privacy'); privacyRoot = context.container; renderPrivacy();
@ -385,13 +441,16 @@
const removeAutonomy = shell.register(autonomyExtension); const removePrivacy = shell.register(privacyExtension);
return () => { removePrivacy(); removeAutonomy(); }; }
function destroy() {
stopApprovalPolling();
if (inlineDock && inlineDock.parentNode) inlineDock.parentNode.removeChild(inlineDock);
inlineDock = null;
client = null; autonomyRoot = null; privacyRoot = null; policy = null; approvals = [];
receipt = null; privacyPolicy = null; audits = []; activeTopic = null; privacyNotice = null;
stopMessage = ''; issuedApprovals.clear();
}
return Object.freeze({extensions: Object.freeze([autonomyExtension, privacyExtension]), register, destroy,
showPrivacyNotice, clearPrivacyNotice() { activeTopic = null; privacyNotice = null; if (privacyRoot) renderPrivacy(); },
savePolicy, decideApproval, stopRun, privacyChoice});
savePolicy, decideApproval, refreshApprovals, stopRun, privacyChoice});
}
function createAutonomyPrivacyRuntime(options) {

238
docs/hux/WORKER-PLAN.md Normal file
View File

@ -0,0 +1,238 @@
# Worker HUX integration plan (worker.bstein.dev)
Status: PREPARED, working tree only — reviewed and applied at a controlled
moment by the coordinator. The hermes-agent pod is the live automation brain;
nothing here is pushed, merged, or reconciled by the preparing agent.
## Which workload is the Worker
`worker.bstein.dev` → Ingress `hermes-agent-dashboard` → Service
`oauth2-proxy-hermes-agent` (selector `app: hermes-agent`) → the
`oauth2-proxy` container inside the **`hermes-agent` Deployment**
(`services/hermes/agent-deployment.yaml`), which proxies loopback
`127.0.0.1:9119` (dashboard) and `127.0.0.1:7681` (terminal). The
`hermes` Deployment in `services/hermes/deployment.yaml` is the DASHBOARD
(triage) workload and is untouched. All edits land in
`agent-deployment.yaml` + `agent-configmap.yaml` only.
## Design summary
Mirrors the reviewed chat topology (`chat-statefulset.yaml`), adapted to a
single-replica `Recreate` Deployment:
- **`hux` sidecar** on the SAME reviewed WebUI image line as chat
(`registry.bstein.dev/bstein/hermes-webui`, Flux marker
`hermes:hermes-webui-release`, plus `:tag`/`:digest` setters for
`HUX_IMAGE_TAG`/`HUX_IMAGE_DIGEST`). `python -m hux.server`,
`PYTHONPATH=/opt/hermes-hux`, binds `127.0.0.1:8790` only; port 8790
appears in no Service, oauth2-proxy upstream, or NetworkPolicy. Probes are
loopback `/healthz` exec probes with the corrected 5s timeout budget (the
chat canary's 2s budget caused transient probe kills).
- **Flags**: `HUX_FLAGS=hux.foundation` only. `HUX_TOOL_ENFORCEMENT=0`
(observe-only). Card widening and enforcement are separate, gated commits.
- **Identity**: new `init-hux-runtime` init container provisions, exactly as
chat's (same HMAC scheme `usr_ + HMAC-SHA256(context-key,
"hux.subject.id.v1\0<slot>")`), a persistent 32-byte context key (0600),
an immutable `O_EXCL` subject binding (0440, verified on every restart), a
redaction canary, and a **worker transport key** in a `Memory` emptyDir
(0400, regenerated every pod replacement). Slot is pinned to **`slot-100`**
(the service's `SLOT_RE` requires `slot-[0-9]{1,3}`; 100 is disjoint from
the chat tenants' slot-0..3). Surface `worker`, trust `worker`.
**No relay key, no router key, no evidence key**: with those env/file
inputs absent, `hux/identity.py` fails those trusts closed — worker trust
is the only live caller class on this instance.
- **Storage**: the store is a subtree of the existing durable
`hermes-agent-home` PVC — `hux/` at the claim root, mounted into the
sidecar as `/var/lib/hux` via fixed `subPath: hux` (NOT `$(POD_NAME)`:
Deployment pod names churn, and identity must survive replacement). The
`hermes` container receives only read-only views:
`/run/hermes-hux-context` = `hux/context`, `/run/hermes-hux-subject` =
`hux/binding`. Chat's `hermes-chat-hux-data` claim is not referenced.
- **Agent hook wiring** (`hermes` gateway container): `HUX_BASE_URL`,
`HUX_RUNTIME_ENABLED=1`, `HUX_TOOL_ENFORCEMENT=0`,
`HUX_TENANT_SLOT=slot-100`, `HUX_WORKER_KEY_FILE`, `HUX_SUBJECT_FILE`,
`HUX_CONTEXT_KEY_FILE`, `HUX_TIMEOUT_SECONDS=3`, per
`dockerfiles/hermes-worker-hux/hux_hook/NOTES.md`. No key or subject is
ever a literal env value. The `hermes-hux-runtime-plugin` ConfigMap
(already generated by `kustomization.yaml` for chat) is mounted read-only
at `/opt/data/plugins/hux-runtime`, but `hux-runtime` is **deliberately
absent from `plugins.enabled`** in `agent-configmap.yaml`, so the whole
hook is inert until the activation commit flips that one line.
### Storage justification (home-PVC subtree vs dedicated PVC)
Chosen: `hermes-agent-home` subtree. Reasons:
1. Self-contained: no new PVC resource. `pvc.yaml` and `kustomization.yaml`
stay untouched, and the pod can never be Pending on an unbound claim —
this pod is the live automation brain and must always schedule.
2. Single replica + `Recreate` strategy = single writer on the RWO claim;
chat needed RWX + `subPathExpr` because four pods share one claim. Here
one pod owns everything.
3. Durability is identical to the rest of the coordinator state (Kanban DB,
workspace) — one claim to back up, one failure domain.
Honest caveat, stated for review: the `hermes` container runs as root and
mounts the whole home claim at `/opt/data`, so the store subtree is
*technically readable* at `/opt/data/hux` by the agent process. On chat the
dedicated claim makes the store physically unreachable; here the boundary is
"the agent only *uses* the loopback API" plus 0700 modes (which do not bind
root). This is acceptable for a single-operator, single-subject instance
whose pod is already one trust domain — but if harder isolation is wanted,
the follow-up in "Coordinator TODOs" moves the store to a dedicated
`hermes-agent-hux-data` claim (a two-line volume swap plus data copy).
## Release rendering / Flux automation impact (coordinator, do not edit ci/)
- Flux `ImageUpdateAutomation` for hermes uses `update: {strategy: Setters,
path: services/hermes}` — the new markers in `agent-deployment.yaml` are
inside that path, so Flux keeps the sidecar image + tag/digest bindings
current with zero renderer changes.
- `ci/scripts/hermes_webui_flux_release.py` rewrites only
`StatefulSet/hermes-chat-tenant` (1–3 consumers) and `Deployment/hermes`
(exactly 1); it never opens `agent-deployment.yaml`, so its
`expected_images` contracts are unaffected. `test_hermes_image_automation.py`
counts markers only in the chat and dashboard files — verified unaffected.
- OPTIONAL renderer extension (coordinator decision): add a third target
`(agent_manifest, "Deployment", "hermes-agent", "hermes-agent-deployment.yaml",
1)` plus HUX build-metadata binding for the agent file, so the release
belt covers the Worker too. Requires matching updates to
`test_hermes_webui_release.py` expectations. Not required for correctness
(Flux setters are authoritative); listed for completeness.
- **Operational consequence**: once merged, every WebUI release digest bump
rewrites `agent-deployment.yaml` and therefore **rolls the hermes-agent pod
(Recreate ⇒ brief worker.bstein.dev outage, minutes)** on every WebUI
release. Chat already rolls on those releases; the Worker joining that
cadence must be an accepted trade. If not accepted, the alternative is
pinning the sidecar image without markers and bumping it manually — that
deviates from the "same reviewed image line" requirement and is NOT what
is prepared here.
## Coordinator TODOs (files outside this prepare's ownership)
1. **None required for kustomize/dry-run correctness.** All referenced
objects (`hermes-hux-runtime-plugin` ConfigMap, `hermes-agent-home` PVC)
already exist in the build. `testing/quality_contract.json` already globs
`testing/**/*.py`, so the new test needs no registration.
2. **At activation** (separate commit, after staged-state gates pass):
- add `- hux-runtime` to `plugins.enabled` in `agent-configmap.yaml`;
- update `testing/tests/test_hermes_agent_layout.py`
(`test_agent_uses_one_native_kanban_control_plane` asserts
`config["plugins"]["enabled"] == ["auto-router"]` — exact equality) to
the new list. The prepared delivery test intentionally pins the staged
state (`"hux-runtime" not in plugins.enabled`) and must be updated in
the same activation commit.
3. **Optional hardening**: dedicated `hermes-agent-hux-data` PVC (RWO,
astreae, 5Gi) in `pvc.yaml` + swap the sidecar's `home/subPath: hux`
volume for it; copy `hux/` content across during a quiet window.
4. **Optional**: renderer third target (above).
5. Pre-existing note: `test_hermes_ai_usage_exporter.py` pins
`ai.bstein.dev/config-rev == "20260823-dual-provider-quota-health"` while
the manifest carries `"20260823-image-release-automation"` — mismatched
before this work; the config-rev annotation was deliberately left
untouched here.
## Rollout steps (exact)
1. Review this diff; run the full gate locally
(`PYTHONPATH=. .venv/bin/pytest -q testing/tests/test_hermes_worker_hux_delivery.py`
plus the agent/webui suites) and `kustomize build services/hermes`.
2. Merge to `main` in a **quiet window** (no cli-lane tasks in flight:
check the Kanban board and `/api/status`; the Recreate roll kills live
worker sessions — durable CLI lanes resume, native children do not).
3. Let Flux reconcile. The pod recreates once with the sidecar. Staged
state: hux serving loopback foundation-only; hook completely inert
(plugin not enabled).
4. Verify the staged gates (below). Leave the staged state soaking for at
least one WebUI release cycle to observe the digest-roll behavior.
5. Activation commit (coordinator TODO 2) — the hook starts observing:
events/spend posted from real worker turns, zero enforcement.
6. Card widening (activity_timeline first), each with its lifecycle gate and
a delivery-test flag-pin update. `hux.autonomy` is BLOCKED on the
human-surface question below. Enforcement stays 0 throughout this plan.
## Verification gates (all must pass before any wider claim)
Staged (after step 3):
- Pod `hermes-agent-*` all containers Ready, zero restart loops; public
`https://worker.bstein.dev` 200 behind SSO; terminal reachable.
- Sidecar loopback health: from the hux container,
`python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8790/healthz').read())"`
→ `status: ok`.
- Identity provisioning: `hux/binding/subject` exists (0440, `usr_` +
64 hex), `hux/context/context-key` 32 bytes 0600; record
`sha256(context-key)` and the subject value.
- Capabilities matrix on the loopback: no headers → 401; worker key + wrong
slot → 401; worker key + `slot-100` + bound subject → 200 with ONLY
HUX-11 foundation routes; any card route → 404 `flag_off`. Confirm relay
trust 401s (no relay key exists).
- **Identity persistence across a pod replacement**: delete the pod (or ride
the next WebUI release roll); after the new pod is Ready, re-hash
context-key and re-read the subject — both MUST be identical. The
worker-key MUST differ (rotation proof).
- Store isolation spot check: no Service/NetworkPolicy exposes 8790; store
dirs 0700 uid 10000.
Activated (after step 5):
- Drive one real worker turn (dashboard chat or a Kanban dispatch) and read
`GET /hux/v1/conversations/…/events` via worker trust on the loopback:
the turn's events appear with `Idempotency-Key`-deduped rows and no raw
arguments/output in any summary (redaction canary from
`hux/context/redaction-canary` never appears in stored records).
- Gateway, terminal, cli-lane-runner all still Ready; a cli-lane task
completes end-to-end (the hook must never block at enforcement 0).
Cross-surface continuity is NOT claimed until every gate above has passed on
the live Worker — and even then it is a separate, explicit follow-up (the
chat and worker instances have distinct subjects and stores by design; any
continuity feature is new work, not a side effect of this rollout).
## Canary / rollback
This is a single-replica Deployment: there is no partition canary. The
canary IS the staged inert state (steps 3–4) — sidecar live, hook off — held
for a soak period before activation. Roll it as a Flux-tracked change only.
Rollback ladder (fastest first):
1. **Activation rollback**: revert the activation commit (remove
`hux-runtime` from `plugins.enabled`). One pod roll; sidecar keeps
running; hook inert again.
2. **Full rollback**: revert the integration commit(s) touching
`agent-deployment.yaml`/`agent-configmap.yaml`. Flux recreates the pod
without sidecar/init/env. The `hux/` subtree on the home claim is
retained data, not referenced by anything — harmless, removable later.
3. **Emergency (cluster is broken, Flux unusable)**: Brad applies
`kubectl -n hermes rollout undo deployment/hermes-agent` — then
reconciles Git to match, since the agent itself must never mutate
workloads.
What to watch during any roll: `kubectl -n hermes get pods -w` until ALL
containers Ready (13 app containers once rendered: the 12 in
`agent-deployment.yaml` including `hux`, plus `execution-pool-coordinator`
from `execution-coordinator-patch.yaml`); `https://worker.bstein.dev/api/status` 200; Kanban
dispatch resumes (`cli-lane-runner` readiness); Vault agent-inject succeeded
(init order unchanged); zero CrashLoopBackOff on `hux`.
## Open questions
1. **Approvals need a human surface.** This instance has only worker trust;
`hux.autonomy` decisions are rejected for worker callers by design (F1),
so enabling autonomy/enforcement on the Worker requires a human-surface
path first (candidates: a BFF on the worker dashboard asserting router
trust like chat's, or driving decisions from the chat instance — which
reopens cross-surface identity). Until decided, `hux.autonomy` and
`HUX_TOOL_ENFORCEMENT=1` are out of scope.
2. **Terminal and cli-lane-runner containers** run their own Hermes
processes but were intentionally NOT wired (env absent → plugin
`HUX_RUNTIME_ENABLED` gate keeps them clean even after activation). If
observation should cover TUI/lane turns too, mirror the `hermes`
container's env+mounts onto them in a follow-up.
3. **`HUX_PROJECT_SOURCE`** is `profile:default` for parity with chat;
a worker-specific source (e.g. `profile:worker`) would partition worker
activity into its own HUX project — decide before activation, changing it
later re-keys the derived project id.
4. Whether to take Coordinator TODO 3 (dedicated PVC) before or after
activation.

View File

@ -118,6 +118,11 @@ data:
plugins:
enabled:
- auto-router
# hux-runtime is STAGED, not enabled. The plugin ConfigMap is mounted
# at /opt/data/plugins/hux-runtime and the observe-only environment is
# wired in agent-deployment.yaml, but the Worker HUX hook stays inert
# until `- hux-runtime` is added here by the activation commit after
# the sidecar's live gates pass (docs/hux/WORKER-PLAN.md).
skills:
creation_nudge_interval: 15

View File

@ -245,6 +245,123 @@ spec:
resources:
requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi}
# Worker HUX identity provisioning. Mirrors the chat tenants'
# init-hux-runtime, adapted to the single-replica Deployment: the
# subtree name is the fixed literal "hux" (Deployment pod names churn,
# so ${HOSTNAME} would break identity persistence across restarts) on
# the durable hermes-agent-home claim, and the slot is pinned to
# slot-100 — outside the chat tenants' slot-0..slot-3 range. Only a
# worker transport key is provisioned: the Worker has no relay or
# human browser surface, so relay trust stays fail-closed by absence.
- name: init-hux-runtime
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:
- |
umask 077
worker_root="/hux-data/hux"
export HUX_INIT_ROOT="${worker_root}"
mkdir -p \
"${worker_root}/binding" \
"${worker_root}/context" \
"${worker_root}/store" \
/hux-worker
chown 10000:10000 \
"${worker_root}" \
"${worker_root}/binding" \
"${worker_root}/context" \
"${worker_root}/store" \
/hux-worker
chmod 0700 \
"${worker_root}" \
"${worker_root}/binding" \
"${worker_root}/context" \
"${worker_root}/store" \
/hux-worker
if [ ! -e "${worker_root}/context/context-key" ]; then
dd if=/dev/urandom of="${worker_root}/context/.context-key.tmp" bs=32 count=1 2>/dev/null
chown 10000:10000 "${worker_root}/context/.context-key.tmp"
chmod 0600 "${worker_root}/context/.context-key.tmp"
mv "${worker_root}/context/.context-key.tmp" "${worker_root}/context/context-key"
fi
test "$(wc -c < "${worker_root}/context/context-key")" -eq 32
chown 10000:10000 "${worker_root}/context/context-key"
chmod 0600 "${worker_root}/context/context-key"
HUX_INIT_SLOT="slot-100" \
/opt/hermes/.venv/bin/python - <<'PY'
import hashlib
import hmac
import os
import stat
from pathlib import Path
root = Path(os.environ["HUX_INIT_ROOT"])
key = (root / "context/context-key").read_bytes()
slot = os.environ["HUX_INIT_SLOT"]
subject = "usr_" + hmac.new(
key,
b"hux.subject.id.v1\0" + slot.encode("ascii"),
hashlib.sha256,
).hexdigest()
target = root / "binding/subject"
expected = (subject + "\n").encode("ascii")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(target, flags, 0o440)
except FileExistsError:
info = target.lstat()
if (
not stat.S_ISREG(info.st_mode)
or info.st_uid != 10000
or stat.S_IMODE(info.st_mode) != 0o440
or info.st_nlink != 1
or target.read_bytes() != expected
):
raise SystemExit("persistent HUX subject binding is unsafe")
else:
try:
os.write(descriptor, expected)
os.fchown(descriptor, 10000, 10000)
os.fchmod(descriptor, 0o440)
os.fsync(descriptor)
finally:
os.close(descriptor)
PY
if [ ! -e "${worker_root}/context/redaction-canary" ]; then
dd if=/dev/urandom bs=32 count=1 2>/dev/null \
| sha256sum | cut -d ' ' -f 1 \
> "${worker_root}/context/.redaction-canary.tmp"
chown 10000:10000 "${worker_root}/context/.redaction-canary.tmp"
chmod 0400 "${worker_root}/context/.redaction-canary.tmp"
mv "${worker_root}/context/.redaction-canary.tmp" "${worker_root}/context/redaction-canary"
fi
target=/hux-worker/worker-key
if [ ! -e "${target}" ]; then
dd if=/dev/urandom bs=32 count=1 2>/dev/null \
| sha256sum | cut -d ' ' -f 1 > "${target}.tmp"
chown 10000:10000 "${target}.tmp"
chmod 0400 "${target}.tmp"
mv "${target}.tmp" "${target}"
fi
test "$(wc -c < "${target}")" -eq 65
chown 10000:10000 "${target}"
chmod 0400 "${target}"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
add: [CHOWN, DAC_OVERRIDE, FOWNER]
runAsUser: 0
runAsGroup: 0
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /hux-data}
- {name: hux-worker-key, mountPath: /hux-worker}
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 50m, memory: 32Mi}
- name: stage-runtime-access
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
@ -649,6 +766,20 @@ spec:
- {name: HERMES_CODEX_HEALTH_PATH, value: /opt/data/provider-health/codex.json}
- {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
- {name: HERMES_IMAGE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
# Worker HUX hook wiring (observe-only). The hux-runtime plugin is
# mounted but stays OUT of plugins.enabled until the activation
# commit (docs/hux/WORKER-PLAN.md), so this environment is inert
# until that deliberate flip. Enforcement is pinned to 0: the hook
# observes and emits, it never gates worker tools in this stage.
- {name: HUX_BASE_URL, value: 'http://127.0.0.1:8790'}
- {name: HUX_RUNTIME_ENABLED, value: "1"}
- {name: HUX_TOOL_ENFORCEMENT, value: "0"}
- {name: HUX_TENANT_SLOT, value: slot-100}
- {name: HUX_WORKER_KEY_FILE, value: /run/hermes-hux-worker/worker-key}
- {name: HUX_SUBJECT_FILE, value: /run/hermes-hux-subject/subject}
- {name: HUX_CONTEXT_KEY_FILE, value: /run/hermes-hux-context/context-key}
- {name: HUX_PROJECT_SOURCE, value: 'profile:default'}
- {name: HUX_TIMEOUT_SECONDS, value: "3"}
volumeMounts:
- {name: claude-oauth-access, mountPath: /claude-oauth-access, readOnly: true}
- {name: home, mountPath: /opt/data}
@ -672,6 +803,10 @@ spec:
- {name: scm-boundary, mountPath: /opt/scm, readOnly: true}
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
- {name: tmp, mountPath: /tmp}
- {name: hux-runtime-plugin, mountPath: /opt/data/plugins/hux-runtime, readOnly: true}
- {name: hux-worker-key, mountPath: /run/hermes-hux-worker, readOnly: true}
- {name: home, mountPath: /run/hermes-hux-context, subPath: hux/context, readOnly: true}
- {name: home, mountPath: /run/hermes-hux-subject, subPath: hux/binding, readOnly: true}
startupProbe:
exec:
command: [curl, -fsS, http://127.0.0.1:9119/api/status]
@ -1219,6 +1354,71 @@ spec:
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "3", memory: 3Gi}
# Worker HUX service: the same reviewed WebUI image line as the chat
# tenants (one artifact, one Flux image policy), loopback-only, with
# foundation as the only enabled card until the Worker lifecycle gates
# in docs/hux/WORKER-PLAN.md pass on this instance.
- name: hux
image: registry.bstein.dev/bstein/hermes-webui:git-043aa9ee89c13fc0f4fb270f489c09a0e2f18b12-build-23-release@sha256:b3b5830d08deb5b2f7fcc9e5c62ee9e71ad39a7e56df7bfbc583be424a9a87d6 # {"$imagepolicy": "hermes:hermes-webui-release"}
imagePullPolicy: IfNotPresent
command: [/opt/hermes/.venv/bin/python, -m, hux.server]
ports:
- {name: hux-loopback, containerPort: 8790, protocol: TCP}
env:
- {name: PYTHONPATH, value: /opt/hermes-hux}
- {name: PYTHONDONTWRITEBYTECODE, value: "1"}
- {name: HOME, value: /tmp}
- {name: HUX_BIND, value: 127.0.0.1}
- {name: HUX_PORT, value: "8790"}
- {name: HUX_TENANT_SLOT, value: slot-100}
- {name: HUX_DATA_ROOT, value: /var/lib/hux/store}
- {name: HUX_FLAGS, value: hux.foundation}
- {name: HUX_WORKER_KEY_FILE, value: /run/hermes-hux-worker/worker-key}
- {name: HUX_SUBJECT_BINDING_FILE, value: /var/lib/hux/binding/subject}
- {name: HUX_CONTEXT_KEY_FILE, value: /var/lib/hux/context/context-key}
- {name: HUX_CANARY_FILE, value: /var/lib/hux/context/redaction-canary}
- name: HUX_IMAGE_TAG
value: git-043aa9ee89c13fc0f4fb270f489c09a0e2f18b12-build-23-release # {"$imagepolicy": "hermes:hermes-webui-release:tag"}
- name: HUX_IMAGE_DIGEST
value: sha256:b3b5830d08deb5b2f7fcc9e5c62ee9e71ad39a7e56df7bfbc583be424a9a87d6 # {"$imagepolicy": "hermes:hermes-webui-release:digest"}
- {name: HUX_READS_PER_MINUTE, value: "600"}
- {name: HUX_WRITES_PER_MINUTE, value: "120"}
- {name: HUX_REQUEST_TIMEOUT_SECONDS, value: "10"}
volumeMounts:
- {name: home, mountPath: /var/lib/hux, subPath: hux}
- {name: hux-worker-key, mountPath: /run/hermes-hux-worker, readOnly: true}
- {name: hux-tmp, mountPath: /tmp}
readinessProbe:
exec:
command:
- /opt/hermes/.venv/bin/python
- -c
- "import json,urllib.request; body=json.load(urllib.request.urlopen('http://127.0.0.1:8790/healthz', timeout=2)); assert body['status']=='ok'"
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 12
livenessProbe:
exec:
command:
- /opt/hermes/.venv/bin/python
- -c
- "import json,urllib.request; body=json.load(urllib.request.urlopen('http://127.0.0.1:8790/healthz', timeout=2)); assert body['status']=='ok'"
initialDelaySeconds: 20
periodSeconds: 20
timeoutSeconds: 5
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 256Mi}
volumes:
- name: home
persistentVolumeClaim:
@ -1300,3 +1500,23 @@ spec:
- name: ttyd-index
emptyDir:
sizeLimit: 2Mi
- name: hux-worker-key
emptyDir:
medium: Memory
sizeLimit: 1Mi
- name: hux-tmp
emptyDir:
sizeLimit: 64Mi
- name: hux-runtime-plugin
configMap:
name: hermes-hux-runtime-plugin
items:
- {key: __init__.py, path: __init__.py}
- {key: context_ids.py, path: context_ids.py}
- {key: emitters.py, path: emitters.py}
- {key: runtime.py, path: runtime.py}
- {key: tool_policy.py, path: tool_policy.py}
- {key: plugin.yaml, path: plugin.yaml}
- {key: hux-hook-init.py, path: hux_hook/__init__.py}
- {key: hux-hook-client.py, path: hux_hook/client.py}
- {key: hux-hook-hooks.py, path: hux_hook/hooks.py}

View File

@ -160,16 +160,24 @@ def test_capability_matrix_shape():
assert row["external_side_effect"] == "ask", level
assert row["read_files"] == "allow"
assert matrix["ask_first"]["shell"] == "ask"
assert matrix["safe"]["network"] == "deny"
assert matrix["safe"]["network"] == "ask", "browsing under safe parks a visible prompt, never a silent deny"
assert matrix["safe"]["web_search"] == "ask"
assert matrix["ask_first"]["network"] == "ask" and matrix["ask_first"]["web_search"] == "ask"
assert matrix["autonomous"]["shell"] == "allow"
assert matrix["autonomous"]["network"] == "allow"
assert matrix["autonomous"]["web_search"] == "allow"
assert "deny" not in {value for row in matrix.values() for value in row.values()}, "no default silently denies"
assert set(policy.CAPABILITIES) == set(SCHEMAS["permission.schema.json"]["$defs"]["capability"]["enum"])
def test_effective_decision_honours_grants_expiry_and_deny():
base = copy.deepcopy(EXAMPLES["policy"])
assert policy.effective_decision(base, "network", NOW) == "ask"
assert policy.effective_decision(base, "network", datetime(2026, 9, 1, tzinfo=timezone.utc)) == "deny"
base["grants"][0]["decision"] = "allow"
assert policy.effective_decision(base, "network", NOW) == "allow"
assert policy.effective_decision(base, "network", datetime(2026, 9, 1, tzinfo=timezone.utc)) == "ask", (
"an expired allow grant falls back to the safe matrix, which asks for network"
)
base["grants"] = [{"capability": "shell", "decision": "allow"}, {"capability": "shell", "decision": "deny"}]
assert policy.effective_decision(base, "shell", NOW) == "deny"
base["grants"] = [{"capability": "deploy", "decision": "allow"}]

View File

@ -98,8 +98,10 @@ def test_safe_policy_auto_allows_reads_and_queues_mutations(router, frozen, even
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body
assert body["expires_at"] == policy.iso(T0 + timedelta(hours=24))
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network"), WORKER)
assert status == 201 and valid(body)["status"] == "denied" and body["decision"]["choice"] == "deny"
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.resolved"]
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body, "safe parks network visibly"
status, body = call(router, "POST", "/hux/v1/approvals", request_body("web_search"), WORKER)
assert status == 201 and valid(body)["status"] == "pending", "safe parks web_search visibly"
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.requested", "approval.requested"]
assert events[0]["evidence"] == [{"kind": "approval", "id": events[0]["evidence"][0]["id"]}]

View File

@ -107,7 +107,29 @@ def test_grants_get_server_set_expiry_and_actor(router, frozen):
assert policy.resolve_request(body, "shell", False) == "ask", "expired grant falls back to the matrix"
assert policy.resolve_request(body, "network", False) == "allow"
frozen["now"] = T0 + timedelta(days=31)
assert policy.resolve_request(body, "network", False) == "deny", "safe denies network once the grant lapses"
assert policy.resolve_request(body, "network", False) == "ask", "safe asks for network once the grant lapses"
def test_safe_and_ask_first_park_network_with_a_visible_prompt(router):
"""The chat surface browses: network and web_search resolve to ask, never a silent deny."""
matrix = rules.default_capability_matrix()
for level in ("ask_first", "safe"):
assert matrix[level]["network"] == "ask" and matrix[level]["web_search"] == "ask", level
assert matrix["autonomous"]["network"] == "allow" and matrix["autonomous"]["web_search"] == "allow"
assert "deny" not in {value for row in matrix.values() for value in row.values()}
status, body, _ = call(router, "GET", "/hux/v1/policy")
assert status == 200 and body["autonomy"] == "safe"
for capability in ("network", "web_search"):
assert policy.resolve_request(body, capability, external=False) == "ask"
assert policy.resolve_request(body, capability, external=True) == "ask"
# SO-39 stays intact: external effects never auto-allow, even under autonomous.
status, autonomous, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"})
assert status == 200
assert policy.resolve_request(autonomous, "network", external=False) == "allow"
assert policy.resolve_request(autonomous, "network", external=True) == "ask"
assert policy.resolve_request(autonomous, "web_search", external=True) == "ask"
for always_ask in ("deploy", "external_side_effect"):
assert policy.resolve_request(autonomous, always_ask, external=False) == "ask"
def test_put_honours_if_match_and_audits_unconditional_writes(router, tmp_path):

View File

@ -39,6 +39,8 @@ def test_runtime_pair_is_scoped_dependency_free_and_inert():
assert 'credentials: \'same-origin\'' in js and "cache: 'no-store'" in js
assert "'If-Match'" in js and "'Idempotency-Key'" in js
assert "Stop receipt" in js and "GENERIC_NOTICE" in js
assert "hux-runtime-inline-dock" in js and "hux-runtime-inline-dock" in css
assert "'aria-live': 'polite'" in js and "Approvals needed" in js
assert ".hux-runtime" in css and "body" not in css and ":root" not in css
assert "focus-visible" in css and "prefers-reduced-motion" in css
assert len(js.splitlines()) < 500 and len(css.splitlines()) < 500
@ -68,5 +70,6 @@ def test_runtime_uses_canonical_backend_routes_and_explicit_safety_language():
assert route in js
assert "Pending approvals" in js
assert "Allow once" in js and "Always allow" in js and "Deny" in js
assert "Approval needed" in js and "more waiting in the workspace drawer" in js
assert "prior sensitive details are not repeated" in js
assert "process_registry_empty: false" in js

View File

@ -497,3 +497,94 @@ test('event handlers contain failed mutations without claiming success', async (
container: plainRoot}); await ticks();
assert.equal(allText(plainRoot).includes('external'), false);
});
function inlineItems(count) {
return Array.from({length: count}, (_, index) => approval({id: `apr_inline000${index + 1}`,
requested_at: `2026-08-24T10:0${index + 1}:00Z`,
request: {...approval().request, summary: `req ${index + 1}`}}));
}
test('inline approval prompts stack newest first near the chat flow and decide in place', async () => {
const inlineHost = new FakeNode('main');
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage(inlineItems(4))),
response(200, approval({id: 'apr_inline0004', requested_at: '2026-08-24T10:04:00Z',
request: {...approval().request, summary: 'req 4'}, status: 'approved',
decision: {choice: 'always', by: {type: 'user', id: IDENTITY.userRef}, at: STAMP}})),
response(200, policy(), {ETag: '1'}), response(200, approvalPage([]))]);
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher, inlineHost});
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
const dock = inlineHost.children[0];
assert.equal(dock.attributes.role, 'region');
assert.equal(dock.attributes['aria-live'], 'polite');
assert.equal(dock.attributes['aria-label'], 'Approvals needed');
assert.equal(dock.hidden, false);
const cards = dock.children.filter((node) => node.tagName === 'ARTICLE');
assert.equal(cards.length, 3, 'at most three prompts are visible');
assert.deepEqual(cards.map((card) => card.children[0].textContent), ['req 4', 'req 3', 'req 2']);
assert.match(allText(dock), /1 more waiting in the workspace drawer/);
assert(findText(cards[0], 'Allow once') && findText(cards[0], 'Always allow') && findText(cards[0], 'Deny'));
assert.equal(findText(cards[0], 'Allow this session'), undefined, 'inline offers exactly three choices');
findText(cards[0], 'Always allow').click(); await ticks();
assert.match(h.calls[2].init.headers['Idempotency-Key'], /^hux:approval-always:/);
assert.deepEqual(JSON.parse(h.calls[2].init.body), {choice: 'always'});
const after = inlineHost.children[0].children.filter((node) => node.tagName === 'ARTICLE');
assert.deepEqual(after.map((card) => card.children[0].textContent), ['req 3', 'req 2', 'req 1']);
assert.equal(allText(inlineHost).includes('more waiting'), false);
assert.match(allText(root), /Pending approvals \(3\)/, 'drawer stays in sync');
runtime.extensions[0].render({client: h.client, container: root}); await ticks();
assert.equal(inlineHost.children.length, 1, 'a re-render never mounts a second dock');
runtime.destroy(); runtime.destroy();
assert.equal(inlineHost.children.length, 0, 'destroy removes the inline dock');
});
test('parked approvals poll only while a turn is active and stop with the runtime', async () => {
let active = true; let tick = null; let cleared = 0;
const timersApi = {setInterval(callback, ms) { tick = callback; assert.equal(ms, 4000); return 0; },
clearInterval(handle) { assert.equal(handle, 0); cleared += 1; }};
const inlineHost = new FakeNode('main');
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage([])),
response(200, approvalPage([approval()])), response(500, {}),
response(200, {items: 'nope', next: null})]);
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher,
inlineHost, timers: timersApi, canStopModelResponse: () => active});
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
assert.equal(inlineHost.children[0].hidden, true, 'no pending approvals keeps the dock hidden');
active = false; tick(); await ticks();
assert.equal(h.calls.length, 2, 'no polling without an active turn');
active = true; tick(); await ticks();
assert.equal(h.calls.length, 3);
assert.equal(inlineHost.children[0].hidden, false, 'a parked approval surfaces within one poll');
assert.match(allText(inlineHost), /Send the finished report/);
tick(); await ticks();
tick(); await ticks();
assert.match(allText(inlineHost), /Send the finished report/, 'poll failures keep the last prompt');
runtime.destroy(); runtime.destroy();
assert.equal(cleared, 1);
assert.equal(await runtime.refreshApprovals(), null, 'a destroyed runtime never fetches');
});
test('inline dock defaults to the document element root and tolerates a missing approvals page', async () => {
const bodyDoc = {createElement: (tag) => new FakeNode(tag), body: new FakeNode('div')};
const fallback = api.createRuntime({document: bodyDoc, conversationId: CONVERSATION,
approvalPollMs: 3600000, fetcher: async () => response(500, {})});
fallback.extensions[0].render({client: harness([]).client, container: new FakeNode('section')}); await ticks();
assert.equal(bodyDoc.body.children[0].attributes.class, 'hux-runtime-inline-dock');
assert.equal(bodyDoc.body.children[0].hidden, true);
fallback.destroy();
assert.equal(bodyDoc.body.children.length, 0);
let tick = null;
const timersApi = {setInterval(callback) { tick = callback; return 9; }, clearInterval() {}};
const inlineHost = new FakeNode('main');
const bad = harness([response(200, policy(), {ETag: '1'}), response(200, {items: 'nope', next: null}),
response(200, policy({autonomy: 'ask_first', revision: 2}), {ETag: '2'})]);
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: bad.fetcher,
inlineHost, timers: timersApi});
const root = new FakeNode('section'); runtime.extensions[0].render({client: bad.client, container: root}); await ticks();
assert.match(allText(root), /temporarily unavailable/);
await runtime.savePolicy('ask_first');
assert.equal(inlineHost.children[0].hidden, true, 'no approvals page renders no inline prompt');
tick(); await ticks();
assert.equal(bad.responses.length, 0);
runtime.destroy();
});

View File

@ -0,0 +1,275 @@
"""Flux delivery gates for the Worker (hermes-agent) HUX service boundary.
Topology-adaptive like the chat gates in ``test_hermes_hux_delivery.py``:
while the agent Deployment carries no ``hux`` sidecar the manifests must
contain zero partial Worker HUX wiring; once the sidecar exists, the full
Worker boundary below is enforced strictly. The Worker instance is the
``hermes-agent`` Deployment behind worker.bstein.dev (via the
``oauth2-proxy-hermes-agent`` Service), NOT the dashboard ``hermes``
Deployment. See ``docs/hux/WORKER-PLAN.md``.
"""
import re
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[2]
SERVICE = ROOT / "services/hermes"
AGENT = SERVICE / "agent-deployment.yaml"
AGENT_CONFIG = SERVICE / "agent-configmap.yaml"
CHAT = SERVICE / "chat-statefulset.yaml"
KUSTOMIZATION = SERVICE / "kustomization.yaml"
WEBUI_IMAGE = "registry.bstein.dev/bstein/hermes-webui"
WEBUI_MARKER = '"$imagepolicy": "hermes:hermes-webui-release"'
TAG_MARKER = '"$imagepolicy": "hermes:hermes-webui-release:tag"'
DIGEST_MARKER = '"$imagepolicy": "hermes:hermes-webui-release:digest"'
WORKER_SLOT = "slot-100"
def _deployment() -> dict:
return yaml.safe_load(AGENT.read_text(encoding="utf-8"))
def _pod() -> dict:
return _deployment()["spec"]["template"]["spec"]
def _hux_active() -> bool:
return any(item["name"] == "hux" for item in _pod()["containers"])
def _require_activation() -> None:
if not _hux_active():
pytest.skip("Worker HUX activation topology is not staged in this tree")
def _named(items: list[dict], name: str) -> dict:
return next(item for item in items if item["name"] == name)
def _env(container: dict) -> dict[str, str]:
return {item["name"]: item.get("value", "") for item in container.get("env", [])}
def _mounts(container: dict) -> list[dict]:
return container.get("volumeMounts", [])
def test_worker_hux_topology_is_all_or_nothing() -> None:
"""A partial Worker HUX rollout (wiring without the sidecar) never ships."""
pod = _pod()
if _hux_active():
return
assert not any(item["name"].startswith("hux") for item in pod["containers"])
assert not any(
item["name"] == "init-hux-runtime" for item in pod.get("initContainers", [])
)
assert not any(item["name"].startswith("hux-") for item in pod["volumes"])
for container in pod["containers"]:
assert not any(name.startswith("HUX_") for name in _env(container))
assert WEBUI_IMAGE not in AGENT.read_text(encoding="utf-8")
def test_worker_hux_sidecar_is_loopback_only_on_the_reviewed_webui_image() -> None:
"""One reviewed WebUI artifact serves chat and Worker; loopback is the only path."""
_require_activation()
pod = _pod()
hux = _named(pod["containers"], "hux")
values = _env(hux)
raw = AGENT.read_text(encoding="utf-8")
assert hux["image"].startswith(WEBUI_IMAGE + ":")
assert "@sha256:" in hux["image"]
# Exactly one WebUI consumer in this Deployment, on the Flux-managed line.
assert raw.count(WEBUI_MARKER) == 1
marked_line = next(line for line in raw.splitlines() if WEBUI_MARKER in line)
assert hux["image"] in marked_line
# The same reviewed image line as the chat tenants: the repo-wide Flux
# Setters run (update.path services/hermes) rewrites both files together.
chat_pod = yaml.safe_load(CHAT.read_text(encoding="utf-8"))["spec"]["template"][
"spec"
]
assert _named(chat_pod["containers"], "webui")["image"] == hux["image"]
assert hux["command"] == ["/opt/hermes/.venv/bin/python", "-m", "hux.server"]
assert values["PYTHONPATH"] == "/opt/hermes-hux"
assert values["PYTHONDONTWRITEBYTECODE"] == "1"
assert values["HUX_BIND"] == "127.0.0.1"
assert values["HUX_PORT"] == "8790"
assert values["HUX_TENANT_SLOT"] == WORKER_SLOT
assert values["HUX_DATA_ROOT"] == "/var/lib/hux/store"
# Foundation-only start: widening the Worker flag set is a deliberate,
# reviewed change gated on the WORKER-PLAN lifecycle checks, so this pin
# is updated in the same commit that turns each card on.
assert {flag for flag in values["HUX_FLAGS"].split(",") if flag} == {
"hux.foundation"
}
assert hux["securityContext"]["readOnlyRootFilesystem"] is True
assert hux["securityContext"]["capabilities"]["drop"] == ["ALL"]
assert hux["securityContext"]["runAsUser"] == 10000
for probe in ("readinessProbe", "livenessProbe"):
assert "tcpSocket" not in hux[probe]
command = " ".join(hux[probe]["exec"]["command"])
assert "127.0.0.1" in command
assert "8790" in command
assert "/healthz" in command
# The chat canary's transient exit-137s were probe kills under a 2s
# budget; the Worker ships with the corrected 5s budget from day one.
assert hux[probe]["timeoutSeconds"] >= 5
# Loopback only: no Service, proxy upstream, or NetworkPolicy exposes 8790.
assert "8790" not in (SERVICE / "service.yaml").read_text(encoding="utf-8")
assert "8790" not in (SERVICE / "oauth2-proxy.yaml").read_text(encoding="utf-8")
assert "port: 8790" not in (SERVICE / "networkpolicy.yaml").read_text(
encoding="utf-8"
)
def test_worker_hux_store_is_a_private_durable_subtree() -> None:
"""The Worker owns its own store subtree and never shares chat's claim."""
_require_activation()
pod = _pod()
hux = _named(pod["containers"], "hux")
hermes = _named(pod["containers"], "hermes")
# The store lives on the durable hermes-agent-home claim under the fixed
# "hux" subtree (Deployment pod names churn, so no $(POD_NAME) scoping).
assert {
"name": "home",
"mountPath": "/var/lib/hux",
"subPath": "hux",
} in _mounts(hux)
assert not any("subPathExpr" in item for item in _mounts(hux))
# The agent gets only its read-only key/context views of that subtree;
# ledger records are reachable from the agent only through loopback HTTP.
assert {
"name": "home",
"mountPath": "/run/hermes-hux-context",
"subPath": "hux/context",
"readOnly": True,
} in _mounts(hermes)
assert {
"name": "home",
"mountPath": "/run/hermes-hux-subject",
"subPath": "hux/binding",
"readOnly": True,
} in _mounts(hermes)
for container in pod["containers"]:
if container["name"] == "hux":
continue
assert not any(
item.get("mountPath") == "/var/lib/hux" for item in _mounts(container)
)
if container["name"] != "hermes":
assert not any(
item["name"].startswith("hux-") for item in _mounts(container)
)
volumes = {item["name"]: item for item in pod["volumes"]}
assert volumes["hux-worker-key"]["emptyDir"]["medium"] == "Memory"
# Single surface, single operator: no relay key and no evidence trust on
# the Worker instance — those trusts fail closed by absence.
assert "hux-relay-key" not in volumes
assert "hux-evidence-key" not in volumes
raw = AGENT.read_text(encoding="utf-8")
assert "hermes-chat-hux-data" not in raw
def test_worker_hux_init_provisions_durable_identity_and_rotating_key() -> None:
"""Identity survives pod replacement; the transport key rotates with it."""
_require_activation()
pod = _pod()
init = _named(pod["initContainers"], "init-hux-runtime")
script = init["args"][0]
assert 'worker_root="/hux-data/hux"' in script
assert "${HOSTNAME}" not in script
assert "$(POD_NAME)" not in script
assert 'HUX_INIT_SLOT="slot-100"' in script
assert "chown -R" not in script
assert 'if [ ! -e "${worker_root}/context/context-key" ]' in script
assert "bs=32 count=1" in script
assert 'chmod 0600 "${worker_root}/context/context-key"' in script
assert 'b"hux.subject.id.v1\\0" + slot.encode("ascii")' in script
assert 'target = root / "binding/subject"' in script
assert "os.O_EXCL" in script
assert "or stat.S_IMODE(info.st_mode) != 0o440" in script
assert "target=/hux-worker/worker-key" in script
assert "chmod 0400" in script
assert "relay" not in script
assert init["securityContext"]["capabilities"] == {
"drop": ["ALL"],
"add": ["CHOWN", "DAC_OVERRIDE", "FOWNER"],
}
assert {"name": "home", "mountPath": "/hux-data"} in _mounts(init)
assert {"name": "hux-worker-key", "mountPath": "/hux-worker"} in _mounts(init)
def test_worker_hux_hook_env_is_file_backed_and_observe_only() -> None:
"""The agent hook is wired for observation: file-backed keys, enforcement 0."""
_require_activation()
pod = _pod()
hermes = _env(_named(pod["containers"], "hermes"))
assert hermes["HUX_BASE_URL"] == "http://127.0.0.1:8790"
assert hermes["HUX_RUNTIME_ENABLED"] == "1"
# Observe-only pin: flipping enforcement on the Worker is its own gated
# change (approvals need a human surface this instance does not have yet).
assert hermes["HUX_TOOL_ENFORCEMENT"] == "0"
assert hermes["HUX_TENANT_SLOT"] == WORKER_SLOT
assert hermes["HUX_WORKER_KEY_FILE"] == "/run/hermes-hux-worker/worker-key"
assert hermes["HUX_SUBJECT_FILE"] == "/run/hermes-hux-subject/subject"
assert hermes["HUX_CONTEXT_KEY_FILE"] == "/run/hermes-hux-context/context-key"
assert hermes["HUX_TIMEOUT_SECONDS"] == "3"
forbidden = {"HUX_WORKER_KEY", "HUX_RELAY_KEY", "HUX_ROUTER_KEY", "HUX_SUBJECT"}
for container in pod["containers"]:
assert not forbidden & set(_env(container))
def test_worker_hux_runtime_plugin_is_staged_but_inert() -> None:
"""The vendored hook package is mounted, yet nothing activates it."""
_require_activation()
pod = _pod()
hermes = _named(pod["containers"], "hermes")
plugin = _named(pod["volumes"], "hux-runtime-plugin")["configMap"]
items = {item["key"]: item["path"] for item in plugin["items"]}
assert plugin["name"] == "hermes-hux-runtime-plugin"
assert {
"hux-hook-init.py": "hux_hook/__init__.py",
"hux-hook-client.py": "hux_hook/client.py",
"hux-hook-hooks.py": "hux_hook/hooks.py",
}.items() <= items.items()
assert {
"name": "hux-runtime-plugin",
"mountPath": "/opt/data/plugins/hux-runtime",
"readOnly": True,
} in _mounts(hermes)
rendered = KUSTOMIZATION.read_text(encoding="utf-8")
assert "hux-hook-client.py=plugins/hux-runtime/hux_hook/client.py" in rendered
# Staged, not active: the activation commit adds hux-runtime to
# plugins.enabled (docs/hux/WORKER-PLAN.md); until then the wiring is inert.
configmap = yaml.safe_load(AGENT_CONFIG.read_text(encoding="utf-8"))
config = yaml.safe_load(configmap["data"]["config.yaml"])
assert "hux-runtime" not in config["plugins"]["enabled"]
def test_worker_hux_build_bindings_use_flux_setters() -> None:
"""Sidecar build provenance is bound by the same Flux setters as chat."""
_require_activation()
raw = AGENT.read_text(encoding="utf-8")
hux = _env(_named(_pod()["containers"], "hux"))
assert raw.count(TAG_MARKER) == 1
assert raw.count(DIGEST_MARKER) == 1
release = re.fullmatch(
r"git-([0-9a-f]{40})-build-[1-9][0-9]*-release", hux["HUX_IMAGE_TAG"]
)
assert release is not None
assert hux["HUX_IMAGE_DIGEST"].startswith("sha256:")
image = _named(_pod()["containers"], "hux")["image"]
assert image.split(":git-", 1)[1].split("@", 1)[0] == hux[
"HUX_IMAGE_TAG"
].removeprefix("git-")
assert image.endswith("@" + hux["HUX_IMAGE_DIGEST"])