Compare commits
44 Commits
5558c24f86
...
8f2abac3dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f2abac3dc | ||
|
|
d50d014572 | ||
|
|
ffa003d3fb | ||
|
|
e2ad645733 | ||
|
|
dc034cb738 | ||
|
|
e40fc5ec5d | ||
|
|
469e52fd20 | ||
|
|
c089a5ec2a | ||
|
|
2eedcd2066 | ||
|
|
0755e1bbd7 | ||
|
|
b3d4538b4f | ||
|
|
b2cfbc94e8 | ||
|
|
13359769dc | ||
|
|
659f70ee03 | ||
|
|
71c05cf9c0 | ||
|
|
0b56d04e9d | ||
|
|
9e99470fef | ||
|
|
bd63b568e1 | ||
|
|
b66c762f5d | ||
|
|
47165274f0 | ||
|
|
1eda927b71 | ||
|
|
31c5eae5ae | ||
|
|
53d7c2c586 | ||
|
|
a1070449a7 | ||
|
|
438180a99f | ||
|
|
964103f02b | ||
|
|
a260506983 | ||
|
|
f59f24a427 | ||
|
|
98c7c6184f | ||
|
|
18b980d6fa | ||
|
|
e1ef110c8a | ||
|
|
a75ca29934 | ||
|
|
681b040885 | ||
|
|
6964a9d8c8 | ||
|
|
74c6549965 | ||
|
|
aeef0f8484 | ||
|
|
1b1a14e972 | ||
|
|
b8fb72fbcc | ||
|
|
3ebee2cc15 | ||
|
|
9f0ffe031c | ||
|
|
01abf89270 | ||
|
|
b9b7126ee1 | ||
|
|
6a5e0d872b | ||
|
|
b4145861d2 |
339
ci/Jenkinsfile.hermes-chat-router-image
Normal file
339
ci/Jenkinsfile.hermes-chat-router-image
Normal file
@ -0,0 +1,339 @@
|
||||
pipeline {
|
||||
agent {
|
||||
kubernetes {
|
||||
defaultContainer 'python'
|
||||
yaml """
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
labels:
|
||||
atlas.bstein.dev/workload: hermes-chat-router-image-builder
|
||||
spec:
|
||||
serviceAccountName: hermes-image-builder
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: arm64
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values: [titan-20]
|
||||
imagePullSecrets:
|
||||
- name: harbor-bstein-robot
|
||||
containers:
|
||||
- name: jnlp
|
||||
image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests: {cpu: 25m, memory: 128Mi}
|
||||
limits: {cpu: 500m, memory: 512Mi}
|
||||
- name: python
|
||||
image: registry.bstein.dev/bstein/python@sha256:269541d3387baae008df4608ead893dba2b5cdaad1a5a380731a88992d34b808
|
||||
command: ["sleep"]
|
||||
args: ["99d"]
|
||||
tty: true
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests: {cpu: 25m, memory: 64Mi}
|
||||
limits: {cpu: 250m, memory: 256Mi}
|
||||
- name: golang
|
||||
image: golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191
|
||||
command: ["sleep"]
|
||||
args: ["99d"]
|
||||
tty: true
|
||||
env:
|
||||
- {name: GOCACHE, value: /tmp/go-cache}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 128Mi}
|
||||
limits: {cpu: "1", memory: 1Gi}
|
||||
- name: kaniko
|
||||
image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e
|
||||
command: ["/busybox/sh", "-c"]
|
||||
args: ["/busybox/sleep 99d"]
|
||||
tty: true
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]
|
||||
privileged: false
|
||||
runAsUser: 0
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 2Gi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 2Gi
|
||||
ephemeral-storage: 5Gi
|
||||
"""
|
||||
}
|
||||
}
|
||||
parameters {
|
||||
booleanParam(
|
||||
name: 'PUBLISH_IMAGE',
|
||||
defaultValue: false,
|
||||
description: 'Publish the reviewed main revision to Harbor.'
|
||||
)
|
||||
string(
|
||||
name: 'EXPECTED_SOURCE_REVISION',
|
||||
defaultValue: '',
|
||||
description: 'Full reviewed commit that must be contained by atlas/titan-iac main.'
|
||||
)
|
||||
string(
|
||||
name: 'CONFIRM_PUBLISH',
|
||||
defaultValue: '',
|
||||
description: 'Enter PUBLISH HERMES CHAT ROUTER to confirm the release.'
|
||||
)
|
||||
}
|
||||
environment {
|
||||
HERMES_IMAGE = 'registry.bstein.dev/bstein/hermes-chat-router'
|
||||
}
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '100', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '100'))
|
||||
skipDefaultCheckout(true)
|
||||
timeout(time: 45, unit: 'MINUTES')
|
||||
}
|
||||
stages {
|
||||
stage('Checkout reviewed source') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
stage('Enforce release boundary') {
|
||||
steps {
|
||||
container('jnlp') {
|
||||
sh '''
|
||||
set -eu
|
||||
mkdir -p build
|
||||
test "${PUBLISH_IMAGE}" = "true"
|
||||
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES CHAT ROUTER"
|
||||
case "${EXPECTED_SOURCE_REVISION}" in
|
||||
*[!0-9a-f]*|'')
|
||||
echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
|
||||
main_revision="$(git rev-parse HEAD)"
|
||||
test "${main_revision}" = "$(git rev-parse origin/main)"
|
||||
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
|
||||
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
|
||||
actual_revision="$(git rev-parse HEAD)"
|
||||
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
|
||||
test -z "$(git status --porcelain)"
|
||||
test -f dockerfiles/Dockerfile.hermes-chat-router
|
||||
case "${BUILD_NUMBER}" in
|
||||
''|0*|*[!0-9]*)
|
||||
echo "BUILD_NUMBER must be a positive decimal integer" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' \
|
||||
"${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \
|
||||
> build/hermes-chat-router.destination
|
||||
printf '%s\n' "${actual_revision}" \
|
||||
> build/hermes-chat-router.source-revision
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Validate reviewed router source') {
|
||||
steps {
|
||||
container('python') {
|
||||
sh '''
|
||||
set -eu
|
||||
python3 -m pip install --disable-pip-version-check --no-cache-dir \
|
||||
--target=/tmp/hermes-chat-router-test-deps \
|
||||
pytest==8.3.4 PyYAML==6.0.2
|
||||
PYTHONPATH=/tmp/hermes-chat-router-test-deps \
|
||||
python3 -m pytest -q \
|
||||
testing/tests/test_hermes_chat_router_release.py \
|
||||
testing/tests/test_hermes_oci_promote.py \
|
||||
testing/tests/test_hermes_image_automation.py
|
||||
'''
|
||||
}
|
||||
container('golang') {
|
||||
sh '''
|
||||
set -eu
|
||||
cd services/hermes/router
|
||||
GO111MODULE=off CGO_ENABLED=0 go test ./...
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Reject replay before publish') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'harbor-robot',
|
||||
usernameVariable: 'HARBOR_USER',
|
||||
passwordVariable: 'HARBOR_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -eu
|
||||
set +x
|
||||
destination="$(cat build/hermes-chat-router.destination)"
|
||||
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||
python3 ci/scripts/hermes_chat_router_release.py assert-absent \
|
||||
--source-revision "${source_revision}" \
|
||||
--build-number "${BUILD_NUMBER}" \
|
||||
--destination "${destination}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Build and publish without a daemon') {
|
||||
steps {
|
||||
container('kaniko') {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'harbor-robot',
|
||||
usernameVariable: 'HARBOR_USER',
|
||||
passwordVariable: 'HARBOR_PASSWORD'
|
||||
)]) {
|
||||
sh '''#!/busybox/sh
|
||||
set -eu
|
||||
set +x
|
||||
config_path=/kaniko/.docker/config.json
|
||||
destination="$(cat build/hermes-chat-router.destination)"
|
||||
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||
umask 077
|
||||
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
|
||||
/busybox/mkdir -p /kaniko/.docker
|
||||
/busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
|
||||
unset HARBOR_USER HARBOR_PASSWORD auth
|
||||
trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
|
||||
umask 022
|
||||
/kaniko/executor \
|
||||
--registry-mirror=harbor-core.harbor.svc.cluster.local \
|
||||
--insecure-registry=harbor-core.harbor.svc.cluster.local \
|
||||
--context="dir://${WORKSPACE}" \
|
||||
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-chat-router" \
|
||||
--destination="${destination}" \
|
||||
--digest-file="${WORKSPACE}/build/hermes-chat-router.digest" \
|
||||
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-chat-router.image" \
|
||||
--label="org.opencontainers.image.revision=${source_revision}" \
|
||||
--label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \
|
||||
--label="org.opencontainers.image.title=hermes-chat-router" \
|
||||
--cleanup \
|
||||
--push-retry=3
|
||||
/busybox/chmod 644 \
|
||||
build/hermes-chat-router.digest build/hermes-chat-router.image
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Render reviewed Flux handoff') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'harbor-robot',
|
||||
usernameVariable: 'HARBOR_USER',
|
||||
passwordVariable: 'HARBOR_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -eu
|
||||
set +x
|
||||
destination="$(cat build/hermes-chat-router.destination)"
|
||||
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||
python3 ci/scripts/hermes_chat_router_release.py render \
|
||||
--digest-file build/hermes-chat-router.digest \
|
||||
--image-file build/hermes-chat-router.image \
|
||||
--source-revision "${source_revision}" \
|
||||
--build-number "${BUILD_NUMBER}" \
|
||||
--destination "${destination}" \
|
||||
--manifest services/hermes/chat-router.yaml \
|
||||
--output-dir build/hermes-chat-router-release
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Verify and archive release evidence') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
expected_files="$(printf '%s\n' \
|
||||
build/hermes-chat-router.destination \
|
||||
build/hermes-chat-router.digest \
|
||||
build/hermes-chat-router.image \
|
||||
build/hermes-chat-router.source-revision \
|
||||
build/hermes-chat-router-release/hermes-chat-router-deployment.yaml \
|
||||
build/hermes-chat-router-release/hermes-chat-router-image-update.patch \
|
||||
build/hermes-chat-router-release/hermes-chat-router-image.json \
|
||||
| LC_ALL=C sort)"
|
||||
actual_files="$(find build -type f -print | LC_ALL=C sort)"
|
||||
test "${actual_files}" = "${expected_files}"
|
||||
destination="$(cat build/hermes-chat-router.destination)"
|
||||
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||
python3 ci/scripts/hermes_chat_router_release.py verify-evidence \
|
||||
--digest-file build/hermes-chat-router.digest \
|
||||
--image-file build/hermes-chat-router.image \
|
||||
--source-revision "${source_revision}" \
|
||||
--build-number "${BUILD_NUMBER}" \
|
||||
--destination "${destination}" \
|
||||
--manifest services/hermes/chat-router.yaml \
|
||||
--output-dir build/hermes-chat-router-release
|
||||
'''
|
||||
archiveArtifacts(
|
||||
artifacts: 'build/hermes-chat-router.destination,build/hermes-chat-router.digest,build/hermes-chat-router.image,build/hermes-chat-router.source-revision,build/hermes-chat-router-release/hermes-chat-router-deployment.yaml,build/hermes-chat-router-release/hermes-chat-router-image-update.patch,build/hermes-chat-router-release/hermes-chat-router-image.json',
|
||||
allowEmptyArchive: false,
|
||||
fingerprint: true
|
||||
)
|
||||
}
|
||||
}
|
||||
stage('Publish Flux release tag') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'harbor-robot',
|
||||
usernameVariable: 'HARBOR_USER',
|
||||
passwordVariable: 'HARBOR_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -eu
|
||||
set +x
|
||||
destination="$(cat build/hermes-chat-router.destination)"
|
||||
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||
python3 ci/scripts/hermes_oci_promote.py \
|
||||
--destination "${destination}" \
|
||||
--digest-file build/hermes-chat-router.digest \
|
||||
--source-revision "${source_revision}" \
|
||||
--build-number "${BUILD_NUMBER}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -192,8 +192,29 @@ spec:
|
||||
testing/tests/test_hermes_voice_language_routing.py \
|
||||
testing/tests/test_hermes_webui_brand.py \
|
||||
testing/tests/test_hermes_webui_release.py \
|
||||
testing/tests/test_hermes_webui_hux_bff.py \
|
||||
testing/tests/test_hermes_webui_hux_context.py \
|
||||
testing/tests/test_hermes_webui_hux_integration.py \
|
||||
testing/tests/test_hermes_webui_hux_backend_e2e.py \
|
||||
testing/tests/test_hermes_hux_ui_runtime_wave_a.py \
|
||||
testing/tests/test_hermes_hux_runtime_autonomy_privacy.py \
|
||||
testing/tests/test_hermes_hux_runtime_stop.py \
|
||||
testing/tests/test_hermes_hux_ui_runtime_wave_b.py \
|
||||
testing/tests/test_hermes_hux_runtime_wave_c.py \
|
||||
testing/tests/test_hermes_hux_runtime_plugin.py \
|
||||
testing/tests/test_hermes_hux_runtime_vendor_parity.py \
|
||||
testing/tests/test_hermes_hux_delivery.py \
|
||||
testing/tests/test_hermes_oci_promote.py \
|
||||
testing/tests/test_hermes_image_automation.py
|
||||
HUX_BACKEND_TESTS="$(find testing/tests -maxdepth 1 -type f \
|
||||
-name 'test_hermes_hux_*.py' \
|
||||
! -name '*_ui_*' \
|
||||
! -name '*runtime*' \
|
||||
! -name '*delivery*' \
|
||||
| sort)"
|
||||
test -n "${HUX_BACKEND_TESTS}"
|
||||
PYTHONPATH=/tmp/hermes-webui-release-test-deps \
|
||||
python3 -m pytest -q ${HUX_BACKEND_TESTS}
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
479
ci/scripts/hermes_chat_router_release.py
Normal file
479
ci/scripts/hermes_chat_router_release.py
Normal file
@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify and render a reviewable Hermes chat-router image release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_IMAGE = "registry.bstein.dev/bstein/hermes-chat-router"
|
||||
HARBOR_API_ORIGIN = "https://registry.bstein.dev/api/v2.0"
|
||||
HARBOR_PROJECT = "bstein"
|
||||
HARBOR_REPOSITORY = "hermes-chat-router"
|
||||
IMMUTABLE_REPOSITORY_PATTERN = "hermes-chat-router"
|
||||
IMMUTABLE_TAG_PATTERN = "git-*-build-*"
|
||||
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
BUILD_PATTERN = re.compile(r"^[1-9][0-9]*$")
|
||||
DESTINATION_PATTERN = re.compile(
|
||||
r"^registry\.bstein\.dev/bstein/hermes-chat-router:"
|
||||
r"git-([0-9a-f]{40})-build-([1-9][0-9]*)$"
|
||||
)
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Never forward registry credentials to a redirect target."""
|
||||
|
||||
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
|
||||
return None
|
||||
|
||||
|
||||
def _validated(value: str, pattern: re.Pattern[str], label: str) -> str:
|
||||
"""Return a normalized value only when it matches the release contract."""
|
||||
normalized = value.strip()
|
||||
if not pattern.fullmatch(normalized):
|
||||
raise ValueError(f"invalid {label}: expected {pattern.pattern}")
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_destination(
|
||||
destination: str, source_revision: str, build_number: str
|
||||
) -> tuple[str, str]:
|
||||
"""Bind the unique build tag to one reviewed source and Jenkins build."""
|
||||
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
|
||||
build = _validated(build_number, BUILD_PATTERN, "build number")
|
||||
match = DESTINATION_PATTERN.fullmatch(destination.strip())
|
||||
if not match or match.groups() != (revision, build):
|
||||
raise ValueError("destination does not match the reviewed revision and build")
|
||||
return revision, build
|
||||
|
||||
|
||||
def validate_kaniko_evidence(
|
||||
*, digest_text: str, image_text: str, destination: str
|
||||
) -> str:
|
||||
"""Cross-check Kaniko's two independent output files."""
|
||||
digests = digest_text.splitlines()
|
||||
images = image_text.splitlines()
|
||||
if len(digests) != 1 or len(images) != 1:
|
||||
raise ValueError("Kaniko evidence must contain exactly one line per file")
|
||||
digest = _validated(digests[0], DIGEST_PATTERN, "image digest")
|
||||
if images[0].strip() != f"{destination}@{digest}":
|
||||
raise ValueError("Kaniko image evidence does not match destination and digest")
|
||||
return digest
|
||||
|
||||
|
||||
def _registry_request(request: urllib.request.Request, timeout: int) -> Any:
|
||||
"""Return same-origin registry responses without following redirects."""
|
||||
opener = urllib.request.build_opener(_NoRedirect())
|
||||
try:
|
||||
return opener.open(request, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc
|
||||
|
||||
|
||||
def _authorization(username: str, password: str) -> str:
|
||||
"""Build a Basic header without placing credentials in a URL."""
|
||||
if not username or not password:
|
||||
raise RuntimeError("Harbor credentials are unavailable")
|
||||
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||
return f"Basic {token}"
|
||||
|
||||
|
||||
def _artifact_response(
|
||||
destination: str,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> tuple[int, bytes]:
|
||||
"""Read one exact Harbor artifact by candidate tag."""
|
||||
if not DESTINATION_PATTERN.fullmatch(destination):
|
||||
raise ValueError("invalid destination")
|
||||
tag = urllib.parse.quote(destination.rsplit(":", 1)[1], safe="")
|
||||
request = urllib.request.Request(
|
||||
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/"
|
||||
f"{HARBOR_REPOSITORY}/artifacts/{tag}?with_immutable_status=true",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": _authorization(username, password),
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
with opener(request, 20) as response:
|
||||
body = response.read(1_048_577)
|
||||
if len(body) > 1_048_576:
|
||||
raise RuntimeError("Harbor artifact response exceeded the size limit")
|
||||
return int(response.status), body
|
||||
|
||||
|
||||
def _rules_response(
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> tuple[int, bytes, dict[str, str]]:
|
||||
"""Read the complete bounded Harbor immutable-rule page."""
|
||||
request = urllib.request.Request(
|
||||
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/immutabletagrules"
|
||||
"?page=1&page_size=100",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": _authorization(username, password),
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
with opener(request, 20) as response:
|
||||
body = response.read(1_048_577)
|
||||
if len(body) > 1_048_576:
|
||||
raise RuntimeError("Harbor immutable rule response exceeded the size limit")
|
||||
return int(response.status), body, dict(response.headers)
|
||||
|
||||
|
||||
def _normalized_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Select only immutable-policy fields used by this lane."""
|
||||
return {
|
||||
"disabled": bool(rule.get("disabled", False)),
|
||||
"action": rule.get("action"),
|
||||
"template": rule.get("template"),
|
||||
"tag_selectors": [
|
||||
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
|
||||
for item in rule.get("tag_selectors") or []
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
"scope_selectors": {
|
||||
"repository": [
|
||||
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
|
||||
for item in (rule.get("scope_selectors") or {}).get("repository", [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def verify_immutable_policy(
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> None:
|
||||
"""Fail closed unless one exact active router immutability rule exists."""
|
||||
status, body, headers = _rules_response(
|
||||
username=username, password=password, opener=opener
|
||||
)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Harbor immutable policy preflight returned HTTP {status}")
|
||||
try:
|
||||
rules = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc
|
||||
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
||||
raise RuntimeError("Harbor immutable rule list has an invalid shape")
|
||||
total = next(
|
||||
(value for key, value in headers.items() if key.lower() == "x-total-count"),
|
||||
None,
|
||||
)
|
||||
if total is None or not str(total).isdecimal() or int(total) != len(rules):
|
||||
raise RuntimeError("Harbor immutable rule page is incomplete")
|
||||
expected = {
|
||||
"disabled": False,
|
||||
"action": "immutable",
|
||||
"template": "immutable_template",
|
||||
"tag_selectors": [
|
||||
{
|
||||
"kind": "doublestar",
|
||||
"decoration": "matches",
|
||||
"pattern": IMMUTABLE_TAG_PATTERN,
|
||||
}
|
||||
],
|
||||
"scope_selectors": {
|
||||
"repository": [
|
||||
{
|
||||
"kind": "doublestar",
|
||||
"decoration": "repoMatches",
|
||||
"pattern": IMMUTABLE_REPOSITORY_PATTERN,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
matches = [
|
||||
value
|
||||
for value in map(_normalized_rule, rules)
|
||||
if value["tag_selectors"] == expected["tag_selectors"]
|
||||
and value["scope_selectors"] == expected["scope_selectors"]
|
||||
]
|
||||
if matches != [expected]:
|
||||
raise RuntimeError("Harbor router immutable build-tag policy is not exact")
|
||||
|
||||
|
||||
def assert_tag_absent(
|
||||
destination: str,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> None:
|
||||
"""Reject replay before Kaniko can target an already-used build tag."""
|
||||
status, _ = _artifact_response(
|
||||
destination, username=username, password=password, opener=opener
|
||||
)
|
||||
if status == 404:
|
||||
return
|
||||
if status == 200:
|
||||
raise RuntimeError("Harbor destination tag already exists")
|
||||
raise RuntimeError(f"Harbor destination preflight returned HTTP {status}")
|
||||
|
||||
|
||||
def verify_registry_digest(
|
||||
destination: str,
|
||||
digest: str,
|
||||
source_revision: str,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||
) -> None:
|
||||
"""Verify Harbor's digest, immutable tag, and persisted source label."""
|
||||
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
|
||||
status, body = _artifact_response(
|
||||
destination, username=username, password=password, opener=opener
|
||||
)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Harbor manifest verification returned HTTP {status}")
|
||||
try:
|
||||
artifact = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("Harbor returned invalid artifact JSON") from exc
|
||||
tag = destination.rsplit(":", 1)[1]
|
||||
matching = [
|
||||
item
|
||||
for item in artifact.get("tags") or []
|
||||
if isinstance(item, dict) and item.get("name") == tag
|
||||
]
|
||||
labels = ((artifact.get("extra_attrs") or {}).get("config") or {}).get("Labels")
|
||||
if artifact.get("digest") != digest:
|
||||
raise RuntimeError("Harbor digest does not match Kaniko evidence")
|
||||
if len(matching) != 1 or matching[0].get("immutable") is not True:
|
||||
raise RuntimeError("Harbor did not enforce the candidate tag as immutable")
|
||||
if not isinstance(labels, dict) or labels.get(
|
||||
"org.opencontainers.image.revision"
|
||||
) != revision:
|
||||
raise RuntimeError("Harbor OCI source-revision label does not match")
|
||||
|
||||
|
||||
def render_workload(source: str, digest: str) -> str:
|
||||
"""Replace the single exact router image while preserving the Flux marker."""
|
||||
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||
identity = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: hermes-chat-router\n"
|
||||
if not source.startswith("# services/hermes/chat-router.yaml\n" + identity):
|
||||
raise ValueError("Flux target identity changed")
|
||||
lines = source.splitlines(keepends=True)
|
||||
matches: list[int] = []
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("image: "):
|
||||
continue
|
||||
value = stripped.removeprefix("image: ").split(" #", 1)[0]
|
||||
image, separator, current_digest = value.rpartition("@")
|
||||
if separator and re.fullmatch(
|
||||
rf"{re.escape(DEFAULT_IMAGE)}(?::[A-Za-z0-9_][A-Za-z0-9_.-]{{0,127}})?",
|
||||
image,
|
||||
):
|
||||
_validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
|
||||
matches.append(index)
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected exactly one router image; found {len(matches)}")
|
||||
index = matches[0]
|
||||
indent = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
||||
comment = ""
|
||||
if " #" in lines[index]:
|
||||
comment = " #" + lines[index].split(" #", 1)[1].rstrip("\n")
|
||||
newline = "\n" if lines[index].endswith("\n") else ""
|
||||
lines[index] = f"{indent}image: {DEFAULT_IMAGE}@{digest}{comment}{newline}"
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def _metadata(
|
||||
digest: str, revision: str, build: str, destination: str
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"build_number": build,
|
||||
"digest": digest,
|
||||
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
|
||||
"flux_targets": ["apps/Deployment/hermes/hermes-chat-router"],
|
||||
"image": DEFAULT_IMAGE,
|
||||
"published_tag": destination,
|
||||
"source_revision": revision,
|
||||
}
|
||||
|
||||
|
||||
def _expected_artifacts(
|
||||
*,
|
||||
digest: str,
|
||||
source_revision: str,
|
||||
build_number: str,
|
||||
destination: str,
|
||||
manifest: Path,
|
||||
) -> dict[str, str]:
|
||||
source = manifest.read_text(encoding="utf-8")
|
||||
rendered = render_workload(source, digest)
|
||||
patch = "".join(
|
||||
difflib.unified_diff(
|
||||
source.splitlines(keepends=True),
|
||||
rendered.splitlines(keepends=True),
|
||||
fromfile="a/services/hermes/chat-router.yaml",
|
||||
tofile="b/services/hermes/chat-router.yaml",
|
||||
)
|
||||
)
|
||||
if not patch:
|
||||
raise ValueError("published digest already matches the Flux target")
|
||||
metadata = _metadata(digest, source_revision, build_number, destination)
|
||||
return {
|
||||
"hermes-chat-router-deployment.yaml": rendered,
|
||||
"hermes-chat-router-image-update.patch": patch,
|
||||
"hermes-chat-router-image.json": json.dumps(
|
||||
metadata, indent=2, sort_keys=True
|
||||
)
|
||||
+ "\n",
|
||||
}
|
||||
|
||||
|
||||
def write_release_artifacts(
|
||||
*,
|
||||
digest: str,
|
||||
source_revision: str,
|
||||
build_number: str,
|
||||
destination: str,
|
||||
manifest: Path,
|
||||
output_dir: Path,
|
||||
) -> None:
|
||||
"""Write deterministic, credential-free Flux handoff evidence."""
|
||||
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||
revision, build = validate_destination(destination, source_revision, build_number)
|
||||
expected = _expected_artifacts(
|
||||
digest=digest,
|
||||
source_revision=revision,
|
||||
build_number=build,
|
||||
destination=destination,
|
||||
manifest=manifest,
|
||||
)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in expected.items():
|
||||
(output_dir / name).write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def validate_release_artifacts(
|
||||
*,
|
||||
digest: str,
|
||||
source_revision: str,
|
||||
build_number: str,
|
||||
destination: str,
|
||||
manifest: Path,
|
||||
output_dir: Path,
|
||||
) -> None:
|
||||
"""Recompute and compare every archived handoff byte."""
|
||||
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||
revision, build = validate_destination(destination, source_revision, build_number)
|
||||
expected = _expected_artifacts(
|
||||
digest=digest,
|
||||
source_revision=revision,
|
||||
build_number=build,
|
||||
destination=destination,
|
||||
manifest=manifest,
|
||||
)
|
||||
entries = list(output_dir.iterdir())
|
||||
if {entry.name for entry in entries} != set(expected) or not all(
|
||||
entry.is_file() and not entry.is_symlink() for entry in entries
|
||||
):
|
||||
raise ValueError("release output must contain exactly three evidence files")
|
||||
for name, content in expected.items():
|
||||
if (output_dir / name).read_text(encoding="utf-8") != content:
|
||||
raise ValueError(f"release evidence is incomplete or mismatched: {name}")
|
||||
|
||||
|
||||
def _credentials() -> tuple[str, str]:
|
||||
username = os.environ.get("HARBOR_USER", "")
|
||||
password = os.environ.get("HARBOR_PASSWORD", "")
|
||||
if not username or not password:
|
||||
raise RuntimeError("Harbor credentials are unavailable")
|
||||
return username, password
|
||||
|
||||
|
||||
def _add_common(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--source-revision", required=True)
|
||||
parser.add_argument("--build-number", required=True)
|
||||
parser.add_argument("--destination", required=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run one fail-closed candidate or evidence operation."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
absent = commands.add_parser("assert-absent")
|
||||
_add_common(absent)
|
||||
for name in ("render", "verify-evidence"):
|
||||
command = commands.add_parser(name)
|
||||
_add_common(command)
|
||||
command.add_argument("--digest-file", required=True, type=Path)
|
||||
command.add_argument("--image-file", required=True, type=Path)
|
||||
command.add_argument("--manifest", required=True, type=Path)
|
||||
command.add_argument("--output-dir", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
validate_destination(args.destination, args.source_revision, args.build_number)
|
||||
if args.command == "verify-evidence":
|
||||
digest = validate_kaniko_evidence(
|
||||
digest_text=args.digest_file.read_text(encoding="utf-8"),
|
||||
image_text=args.image_file.read_text(encoding="utf-8"),
|
||||
destination=args.destination,
|
||||
)
|
||||
validate_release_artifacts(
|
||||
digest=digest,
|
||||
source_revision=args.source_revision,
|
||||
build_number=args.build_number,
|
||||
destination=args.destination,
|
||||
manifest=args.manifest,
|
||||
output_dir=args.output_dir,
|
||||
)
|
||||
return 0
|
||||
username, password = _credentials()
|
||||
if args.command == "assert-absent":
|
||||
verify_immutable_policy(username=username, password=password)
|
||||
assert_tag_absent(
|
||||
args.destination, username=username, password=password
|
||||
)
|
||||
return 0
|
||||
digest = validate_kaniko_evidence(
|
||||
digest_text=args.digest_file.read_text(encoding="utf-8"),
|
||||
image_text=args.image_file.read_text(encoding="utf-8"),
|
||||
destination=args.destination,
|
||||
)
|
||||
verify_registry_digest(
|
||||
args.destination,
|
||||
digest,
|
||||
args.source_revision,
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
write_release_artifacts(
|
||||
digest=digest,
|
||||
source_revision=args.source_revision,
|
||||
build_number=args.build_number,
|
||||
destination=args.destination,
|
||||
manifest=args.manifest,
|
||||
output_dir=args.output_dir,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||
raise SystemExit(main())
|
||||
@ -18,7 +18,7 @@ from typing import Any, Callable
|
||||
REGISTRY_ORIGIN = "https://registry.bstein.dev"
|
||||
DESTINATION_PATTERN = re.compile(
|
||||
r"^registry\.bstein\.dev/bstein/"
|
||||
r"(?P<component>hermes-(?:agent|webui|jetson-(?:stt|tts))):"
|
||||
r"(?P<component>hermes-(?:agent|webui|chat-router|jetson-(?:stt|tts))):"
|
||||
r"git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)$"
|
||||
)
|
||||
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
@ -49,8 +49,9 @@ def render_workload(
|
||||
kind: str,
|
||||
name: str,
|
||||
image: str = DEFAULT_IMAGE,
|
||||
expected_images: int | tuple[int, ...] = 1,
|
||||
) -> str:
|
||||
"""Replace one WebUI image in one exact Flux workload without reformatting."""
|
||||
"""Replace every expected WebUI consumer in one exact Flux workload."""
|
||||
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
||||
identity = re.compile(
|
||||
rf"\A(?:#[^\n]*\n)*apiVersion: apps/v1\nkind: {re.escape(kind)}\n"
|
||||
@ -75,18 +76,55 @@ def render_workload(
|
||||
validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
|
||||
matches.append(index)
|
||||
suffixes[index] = f" #{comment}" if separator else ""
|
||||
if len(matches) != 1:
|
||||
allowed = (expected_images,) if isinstance(expected_images, int) else expected_images
|
||||
if not allowed or any(count < 1 for count in allowed) or len(matches) not in allowed:
|
||||
raise ValueError(
|
||||
f"expected exactly one {image!r} image in {kind}/{name}; "
|
||||
f"expected {allowed} {image!r} image(s) in {kind}/{name}; "
|
||||
f"found {len(matches)}"
|
||||
)
|
||||
index = matches[0]
|
||||
newline = "\n" if lines[index].endswith("\n") else ""
|
||||
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
||||
lines[index] = f"{prefix}image: {image}@{digest}{suffixes[index]}{newline}"
|
||||
for index in matches:
|
||||
newline = "\n" if lines[index].endswith("\n") else ""
|
||||
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
||||
lines[index] = f"{prefix}image: {image}@{digest}{suffixes[index]}{newline}"
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def render_hux_build_metadata(
|
||||
source: str, digest: str, source_revision: str, build_number: str
|
||||
) -> str:
|
||||
"""Bind HUX metadata when the activated sidecar fields are present."""
|
||||
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
||||
revision = validated(source_revision, REVISION_PATTERN, "source revision")
|
||||
build = validated(build_number, BUILD_PATTERN, "build number")
|
||||
replacements = {
|
||||
"HUX_IMAGE_TAG": f"git-{revision}-build-{build}-release",
|
||||
"HUX_IMAGE_DIGEST": digest,
|
||||
}
|
||||
rendered = source
|
||||
present = {
|
||||
name: rendered.count(f"- {{name: {name}, value:") for name in replacements
|
||||
}
|
||||
if set(present.values()) == {0}:
|
||||
return rendered
|
||||
if any(count != 1 for count in present.values()):
|
||||
raise ValueError(f"incomplete HUX build binding fields: {present}")
|
||||
for name, value in replacements.items():
|
||||
pattern = re.compile(
|
||||
rf"^(?P<indent>\s*)- \{{name: {name}, value: '[^'\n]+'\}}(?P<suffix>[^\n]*)$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
rendered, count = pattern.subn(
|
||||
lambda match: (
|
||||
f"{match.group('indent')}- {{name: {name}, value: '{value}'}}"
|
||||
f"{match.group('suffix')}"
|
||||
),
|
||||
rendered,
|
||||
)
|
||||
if count != 1:
|
||||
raise ValueError(f"expected exactly one {name} HUX build binding; found {count}")
|
||||
return rendered
|
||||
|
||||
|
||||
def _targets(chat_manifest: Path, dashboard_manifest: Path):
|
||||
return (
|
||||
(
|
||||
@ -94,12 +132,14 @@ def _targets(chat_manifest: Path, dashboard_manifest: Path):
|
||||
"StatefulSet",
|
||||
"hermes-chat-tenant",
|
||||
"hermes-chat-statefulset.yaml",
|
||||
(1, 2, 3),
|
||||
),
|
||||
(
|
||||
dashboard_manifest,
|
||||
"Deployment",
|
||||
"hermes",
|
||||
"hermes-dashboard-deployment.yaml",
|
||||
1,
|
||||
),
|
||||
)
|
||||
|
||||
@ -122,13 +162,27 @@ def _metadata(
|
||||
|
||||
|
||||
def _rendered_and_patch(
|
||||
digest: str, chat_manifest: Path, dashboard_manifest: Path
|
||||
digest: str,
|
||||
source_revision: str,
|
||||
build_number: str,
|
||||
chat_manifest: Path,
|
||||
dashboard_manifest: Path,
|
||||
) -> tuple[dict[str, str], str]:
|
||||
rendered_targets: dict[str, str] = {}
|
||||
patch_parts: list[str] = []
|
||||
for path, kind, name, artifact_name in _targets(chat_manifest, dashboard_manifest):
|
||||
for path, kind, name, artifact_name, expected_images in _targets(chat_manifest, dashboard_manifest):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
rendered = render_workload(source, digest, kind=kind, name=name)
|
||||
rendered = render_workload(
|
||||
source,
|
||||
digest,
|
||||
kind=kind,
|
||||
name=name,
|
||||
expected_images=expected_images,
|
||||
)
|
||||
if name == "hermes-chat-tenant":
|
||||
rendered = render_hux_build_metadata(
|
||||
rendered, digest, source_revision, build_number
|
||||
)
|
||||
rendered_targets[artifact_name] = rendered
|
||||
patch_parts.append(
|
||||
"".join(
|
||||
@ -159,7 +213,7 @@ def write_release_artifacts(
|
||||
destination, source_revision, build_number
|
||||
)
|
||||
rendered_targets, patch = _rendered_and_patch(
|
||||
digest, chat_manifest, dashboard_manifest
|
||||
digest, source_revision, build_number, chat_manifest, dashboard_manifest
|
||||
)
|
||||
if not patch:
|
||||
raise ValueError("published digest already matches every Flux target")
|
||||
@ -201,7 +255,7 @@ def validate_release_artifacts(
|
||||
):
|
||||
raise ValueError("release output must contain exactly four evidence files")
|
||||
rendered_targets, patch = _rendered_and_patch(
|
||||
digest, chat_manifest, dashboard_manifest
|
||||
digest, source_revision, build_number, chat_manifest, dashboard_manifest
|
||||
)
|
||||
metadata = _metadata(digest, source_revision, build_number, destination)
|
||||
expected = {
|
||||
|
||||
@ -18,6 +18,7 @@ from hermes_webui_flux_release import (
|
||||
DESTINATION_PATTERN,
|
||||
DIGEST_PATTERN,
|
||||
REVISION_PATTERN,
|
||||
render_hux_build_metadata as render_hux_build_metadata,
|
||||
render_workload as render_workload,
|
||||
validate_destination,
|
||||
validate_release_artifacts as validate_flux_release_artifacts,
|
||||
|
||||
@ -26,6 +26,10 @@ spec:
|
||||
kind: Job
|
||||
name: harbor-hermes-webui-immutability-ensure-1
|
||||
namespace: harbor
|
||||
- apiVersion: batch/v1
|
||||
kind: Job
|
||||
name: harbor-hermes-chat-router-immutability-ensure-1
|
||||
namespace: harbor
|
||||
dependsOn:
|
||||
- name: core
|
||||
- name: longhorn
|
||||
|
||||
@ -11,6 +11,10 @@ USER root
|
||||
# Keep WebUI and Hermes pinned together. The WebUI imports Hermes internals,
|
||||
# while the gateway remains the only process that owns an agent conversation.
|
||||
COPY --from=webui /apptoo /opt/hermes-webui
|
||||
COPY dockerfiles/hermes-hux-foundation/hux /opt/hermes-hux/hux
|
||||
COPY dockerfiles/hermes-hux-foundation/hux_producer /opt/hermes-hux/hux_producer
|
||||
COPY services/hermes/contracts/hux /opt/hermes-hux/contracts
|
||||
ENV HUX_CONTRACT_DIR=/opt/hermes-hux/contracts
|
||||
|
||||
# Add the Atlas voice bridge as a narrow integration layer. It activates only
|
||||
# when a tenant's server-side STT capability reports the private Jetson route.
|
||||
@ -23,6 +27,27 @@ COPY dockerfiles/hermes-webui-atlas-voice-worklet.js /opt/hermes-webui/static/at
|
||||
COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
|
||||
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
|
||||
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
|
||||
COPY dockerfiles/hermes-webui-hux-bff-patch.py /tmp/hermes-webui-hux-bff-patch.py
|
||||
COPY dockerfiles/hermes-webui-hux-context.py /tmp/hermes-webui-hux-context.py
|
||||
COPY dockerfiles/hermes-webui-hux-context-patch.py /tmp/hermes-webui-hux-context-patch.py
|
||||
COPY dockerfiles/hermes-webui-hux-patch.py /tmp/hermes-webui-hux-patch.py
|
||||
COPY dockerfiles/hermes-webui-hux/foundation.css /opt/hermes-webui/static/hux/foundation.css
|
||||
COPY dockerfiles/hermes-webui-hux/foundation.js /opt/hermes-webui/static/hux/foundation.js
|
||||
COPY dockerfiles/hermes-webui-hux/shell.js /opt/hermes-webui/static/hux/shell.js
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/autonomy-privacy.css /opt/hermes-webui/static/hux/runtime/autonomy-privacy.css
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/autonomy-privacy.js /opt/hermes-webui/static/hux/runtime/autonomy-privacy.js
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_activity_memory.css /opt/hermes-webui/static/hux/runtime/wave_a_activity_memory.css
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_activity_memory.js /opt/hermes-webui/static/hux/runtime/wave_a_activity_memory.js
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_contract.js /opt/hermes-webui/static/hux/runtime/wave_a_contract.js
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_b.css /opt/hermes-webui/static/hux/runtime/wave_b.css
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_artifacts_research.js /opt/hermes-webui/static/hux/runtime/wave_b_artifacts_research.js
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_contract.js /opt/hermes-webui/static/hux/runtime/wave_b_contract.js
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_projects_modes.js /opt/hermes-webui/static/hux/runtime/wave_b_projects_modes.js
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_runtime.js /opt/hermes-webui/static/hux/runtime/wave_b_runtime.js
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_c_multimodal_onboarding_release.css /opt/hermes-webui/static/hux/runtime/wave_c_multimodal_onboarding_release.css
|
||||
COPY dockerfiles/hermes-webui-hux/runtime/wave_c_multimodal_onboarding_release.js /opt/hermes-webui/static/hux/runtime/wave_c_multimodal_onboarding_release.js
|
||||
COPY dockerfiles/hermes-webui-hux/bootstrap.css /opt/hermes-webui/static/hux/bootstrap.css
|
||||
COPY dockerfiles/hermes-webui-hux/bootstrap.js /opt/hermes-webui/static/hux/bootstrap.js
|
||||
COPY dockerfiles/hermes-webui-brand-patch.py /tmp/hermes-webui-brand-patch.py
|
||||
COPY dockerfiles/hermes-webui-manifest-patch.py /tmp/hermes-webui-manifest-patch.py
|
||||
COPY dockerfiles/hermes-webui-release-patch.py /tmp/hermes-webui-release-patch.py
|
||||
@ -37,12 +62,19 @@ RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-base-patch.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-bff-patch.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-context-patch.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-brand-patch.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-manifest-patch.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-patch.py \
|
||||
&& HERMES_WEBUI_RELEASE_ID="${HERMES_WEBUI_RELEASE_ID}" \
|
||||
/opt/hermes/.venv/bin/python /tmp/hermes-webui-release-patch.py
|
||||
|
||||
RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
||||
&& test -f /opt/hermes-hux/contracts/flags.json \
|
||||
&& test -f /opt/hermes-hux/contracts/release-ledger.schema.json \
|
||||
&& PYTHONPATH=/opt/hermes-hux /opt/hermes/.venv/bin/python -m compileall -q /opt/hermes-hux/hux /opt/hermes-hux/hux_producer \
|
||||
&& PYTHONPATH=/opt/hermes-hux /opt/hermes/.venv/bin/python -c 'from pathlib import Path; from tempfile import TemporaryDirectory; from hux.server import build_router; root = TemporaryDirectory(); router = build_router(Path(root.name), {"HUX_FLAGS": ""}); assert router.routes; root.cleanup()' \
|
||||
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
|
||||
/opt/hermes-webui/api/config.py \
|
||||
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
|
||||
@ -63,6 +95,18 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
||||
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
|
||||
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
|
||||
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
|
||||
&& grep -Fq 'from api.hux_bff import proxy_hux' /opt/hermes-webui/api/routes.py \
|
||||
&& grep -Fq 'from api.hux_context import attach_hux_context' /opt/hermes-webui/api/routes.py \
|
||||
&& grep -Fq '"schema": "hux.webui_context.v1"' /opt/hermes-webui/api/hux_context.py \
|
||||
&& grep -Fq '"project_source": raw_project' /opt/hermes-webui/api/hux_context.py \
|
||||
&& grep -Fq 'static/hux/bootstrap.js' /opt/hermes-webui/static/index.html \
|
||||
&& grep -Fq "'./static/hux/bootstrap.js' + VQ" /opt/hermes-webui/static/sw.js \
|
||||
&& grep -Fq 'value.hux_context' /opt/hermes-webui/static/hux/bootstrap.js \
|
||||
&& grep -Fq "'/hux/v1/capabilities'" /opt/hermes-webui/static/hux/bootstrap.js \
|
||||
&& grep -Fq "'/hux/v1/context/bootstrap'" /opt/hermes-webui/static/hux/bootstrap.js \
|
||||
&& grep -Fq 'HermesHuxBootstrap.mergeTrustedContext(S.session, startData)' /opt/hermes-webui/static/messages.js \
|
||||
&& grep -Fq 'respBody.cancelled===true && respBody.stream_id===streamId' /opt/hermes-webui/static/boot.js \
|
||||
&& grep -Fq 'prefers-reduced-motion: no-preference' /opt/hermes-webui/static/hux/bootstrap.css \
|
||||
&& grep -Fq '"language": detected_language' /opt/hermes/tools/transcription_tools.py \
|
||||
&& grep -Fq "'language': detected" /opt/hermes-webui/api/upload.py \
|
||||
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
|
||||
@ -93,6 +137,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
||||
/opt/hermes-webui/api/routes.py \
|
||||
/opt/hermes-webui/api/upload.py \
|
||||
/opt/hermes-webui/api/gateway_chat.py \
|
||||
/opt/hermes-webui/api/hux_bff.py \
|
||||
/opt/hermes-webui/api/hux_context.py \
|
||||
/opt/hermes/tools/transcription_tools.py
|
||||
|
||||
# Exercise branded responses from the real upstream server process in the
|
||||
@ -138,6 +184,7 @@ ENV HERMES_WEBUI_AGENT_DIR=/opt/hermes \
|
||||
HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
|
||||
HERMES_WEBUI_SKIP_ONBOARDING=1 \
|
||||
HERMES_WEBUI_SECURE=1 \
|
||||
PYTHONPATH=/opt/hermes-hux \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
|
||||
1
dockerfiles/hermes-hux-foundation/hux/__init__.py
Normal file
1
dockerfiles/hermes-hux-foundation/hux/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Hermes User Experience foundation service package."""
|
||||
424
dockerfiles/hermes-hux-foundation/hux/artifacts.py
Normal file
424
dockerfiles/hermes-hux-foundation/hux/artifacts.py
Normal file
@ -0,0 +1,424 @@
|
||||
"""HUX-04 artifact workspace: typed, versioned, diffable outputs.
|
||||
|
||||
Every artifact is one ``hux.artifact.v1`` document under the caller's tenant
|
||||
subtree. Content lives in the content-addressed blob store; the document only
|
||||
carries ``content_ref`` hashes, so a version can never be rewritten once it
|
||||
has been appended (SO-30). Blobs are served inside a JSON envelope with
|
||||
``nosniff`` and never as an executable response type (SO-32). Every
|
||||
referenced id is resolved under the caller's own subtree (SO-33); a foreign
|
||||
or unknown id is ``404`` so ids cannot be probed. Sharing is out of scope for
|
||||
this increment: ``access.mode`` is always ``owner`` (SO-34).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from hux import diffs
|
||||
from hux.contracts import load_all, validate_record
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id, new_id, now_iso
|
||||
|
||||
|
||||
MAX_UPLOAD_BODY = 34 * 1024 * 1024 # 25 MiB of content plus base64 overhead (SO-31, SO-54)
|
||||
|
||||
|
||||
def _project_exists(store, project_id: str) -> bool:
|
||||
"""Ask the organization family when it is present; otherwise only the id shape is known."""
|
||||
try:
|
||||
from hux.organization import project_exists
|
||||
except ModuleNotFoundError: # pragma: no cover - organization is always shipped with artifacts
|
||||
return True
|
||||
return project_exists(store, project_id)
|
||||
|
||||
FAMILY = "artifacts"
|
||||
MAX_VERSION_BYTES = 25 * 1024 * 1024
|
||||
MAX_VERSIONS = 200
|
||||
MAX_ARTIFACTS = 2000
|
||||
PAGE_SIZE = 50
|
||||
ARTIFACT_TYPES = ("markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio")
|
||||
SENSITIVITIES = ("public", "personal", "sensitive", "restricted")
|
||||
_SCHEMAS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _schemas() -> dict[str, dict[str, Any]]:
|
||||
if not _SCHEMAS:
|
||||
_SCHEMAS.update(load_all())
|
||||
return _SCHEMAS
|
||||
|
||||
|
||||
# -- helpers shared with other lanes -------------------------------------------
|
||||
|
||||
def artifact_exists(store: TenantStore, artifact_id: str) -> bool:
|
||||
"""True when ``artifact_id`` is a well-formed id owned by the store's tenant."""
|
||||
try:
|
||||
return store.exists(FAMILY, artifact_id)
|
||||
except Invalid:
|
||||
return False
|
||||
|
||||
|
||||
def artifact_titles(store: TenantStore, ids: list[str]) -> dict[str, str]:
|
||||
"""Map each resolvable artifact id to its title; unknown ids are omitted."""
|
||||
titles: dict[str, str] = {}
|
||||
for artifact_id in ids:
|
||||
if artifact_exists(store, artifact_id):
|
||||
titles[artifact_id] = store.get(FAMILY, artifact_id)["title"]
|
||||
return titles
|
||||
|
||||
|
||||
def emit_event(store: TenantStore, identity: Identity, artifact: dict[str, Any], kind: str, summary: str, detail: dict[str, Any]) -> None:
|
||||
"""Record an activity event through the events lane when it is present.
|
||||
|
||||
Imported lazily so module import order never matters; a missing events
|
||||
module (earlier build) is not an error because the artifact write itself
|
||||
is the source of truth.
|
||||
"""
|
||||
conversation_id = artifact.get("conversation_id")
|
||||
if conversation_id is None:
|
||||
return
|
||||
try:
|
||||
from hux.events import emit
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
version = artifact["current_version"]
|
||||
evidence = [{"kind": "artifact_version", "id": f"{artifact['id']}@{version}", "hash": artifact["versions"][-1]["content_ref"]["hash"]}]
|
||||
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence, sensitivity=artifact["sensitivity"])
|
||||
|
||||
|
||||
def replay(store: TenantStore, family: str, key: str) -> str | None:
|
||||
"""Record id previously created under an Idempotency-Key, if any."""
|
||||
if not key:
|
||||
return None
|
||||
for row in store.read(family, "idempotency"):
|
||||
if row.get("key") == key:
|
||||
return row["id"]
|
||||
return None
|
||||
|
||||
|
||||
def remember(store: TenantStore, family: str, key: str, record_id: str) -> None:
|
||||
"""Persist an Idempotency-Key to record id mapping."""
|
||||
if key:
|
||||
store.append(family, "idempotency", {"key": key, "id": record_id, "at": now_iso()})
|
||||
|
||||
|
||||
# -- record shaping ------------------------------------------------------------
|
||||
|
||||
def _actor(identity: Identity) -> dict[str, str]:
|
||||
if identity.trust == "worker":
|
||||
return {"type": "system", "id": "worker"}
|
||||
return {"type": "user", "id": identity.subject}
|
||||
|
||||
|
||||
def _body(request: Request) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
|
||||
value = body.get(key)
|
||||
if value is None:
|
||||
if required:
|
||||
raise Invalid(f"{key} is required")
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
|
||||
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")
|
||||
if isinstance(text, str) and encoded is None:
|
||||
data = text.encode("utf-8")
|
||||
elif isinstance(encoded, str) and text is None:
|
||||
try:
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, TypeError) as error:
|
||||
raise Invalid("content_base64 is not valid base64") from error
|
||||
else:
|
||||
raise Invalid("exactly one of content (utf-8 text) or content_base64 is required")
|
||||
if len(data) > MAX_VERSION_BYTES:
|
||||
raise TooLarge("version exceeds 25 MiB")
|
||||
mime = _string(body, "mime", 120, required=False) or "application/octet-stream"
|
||||
return data, mime
|
||||
|
||||
|
||||
def _store_content(store: TenantStore, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Hash, verify against any client-supplied hash, store the blob, return ``content_ref``."""
|
||||
data, mime = _content(body)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
claimed = body.get("hash")
|
||||
if claimed is not None and claimed != f"sha256:{digest}":
|
||||
raise Invalid("hash does not match the submitted content")
|
||||
store.put_blob(digest, data)
|
||||
return {"hash": f"sha256:{digest}", "bytes": len(data), "mime": mime}
|
||||
|
||||
|
||||
def _optional_id(body: dict[str, Any], key: str) -> str | None:
|
||||
value = body.get(key)
|
||||
return None if value is None else check_id(value)
|
||||
|
||||
|
||||
def _load_owned(request: Request) -> dict[str, Any]:
|
||||
"""The artifact named in the path; unknown, malformed or foreign is 404."""
|
||||
try:
|
||||
artifact = request.store.get(FAMILY, request.params["id"])
|
||||
except Invalid as error:
|
||||
raise NotFound("artifact not found") from error
|
||||
if artifact.get("owner") != request.identity.subject:
|
||||
raise NotFound("artifact not found")
|
||||
return artifact
|
||||
|
||||
|
||||
def _version(artifact: dict[str, Any], number: Any) -> dict[str, Any]:
|
||||
if not isinstance(number, int) or isinstance(number, bool):
|
||||
raise Invalid("version must be an integer")
|
||||
for entry in artifact["versions"]:
|
||||
if entry["version"] == number:
|
||||
return entry
|
||||
raise NotFound(f"version {number} not found")
|
||||
|
||||
|
||||
def _lineage(request: Request, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Resolve ``lineage`` under the caller's subtree; anything else is 404 (SO-33)."""
|
||||
lineage = body.get("lineage")
|
||||
if lineage is None:
|
||||
return None
|
||||
if not isinstance(lineage, dict):
|
||||
raise Invalid("lineage must be an object")
|
||||
try:
|
||||
parent = request.store.get(FAMILY, check_id(lineage.get("artifact_id")))
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("lineage artifact not found") from error
|
||||
if parent.get("owner") != request.identity.subject:
|
||||
raise NotFound("lineage artifact not found")
|
||||
version = _version(parent, lineage.get("version"))
|
||||
return {"artifact_id": parent["id"], "version": version["version"]}
|
||||
|
||||
|
||||
def append_version(store: TenantStore, artifact: dict[str, Any], entry: dict[str, Any], expected_revision: int | None) -> dict[str, Any]:
|
||||
"""Append an immutable version entry and persist the document.
|
||||
|
||||
The version number must be exactly ``current_version + 1``; any attempt to
|
||||
write an existing number is a Conflict so history is never rewritten.
|
||||
"""
|
||||
if any(v["version"] == entry["version"] for v in artifact["versions"]):
|
||||
raise Conflict(f"version {entry['version']} already exists")
|
||||
if entry["version"] != artifact["current_version"] + 1:
|
||||
raise Conflict("versions are appended in order")
|
||||
if len(artifact["versions"]) >= MAX_VERSIONS:
|
||||
raise TooLarge("artifact has reached 200 versions")
|
||||
updated = {**artifact, "versions": [*artifact["versions"], entry], "current_version": entry["version"], "updated_at": now_iso()}
|
||||
_check(updated)
|
||||
return store.put(FAMILY, updated, expected_revision=expected_revision)
|
||||
|
||||
|
||||
def _check(artifact: dict[str, Any]) -> None:
|
||||
problems = validate_record(artifact, _schemas())
|
||||
if problems:
|
||||
raise Invalid("artifact does not satisfy hux.artifact.v1", problems)
|
||||
|
||||
|
||||
# -- handlers ------------------------------------------------------------------
|
||||
|
||||
def create(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts``: create an artifact with version 1."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None:
|
||||
request.audit("artifacts.create", existing, reason="idempotent_replay")
|
||||
return Response(200, request.store.get(FAMILY, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(FAMILY) >= MAX_ARTIFACTS:
|
||||
raise TooLarge("tenant has reached 2000 artifacts")
|
||||
artifact_type = _string(body, "type", 40)
|
||||
if artifact_type not in ARTIFACT_TYPES:
|
||||
raise Invalid("unknown artifact type")
|
||||
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)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
version[field] = body[field]
|
||||
lineage = _lineage(request, body)
|
||||
if lineage is not None:
|
||||
version["lineage"] = lineage
|
||||
artifact: dict[str, Any] = {
|
||||
"schema": "hux.artifact.v1",
|
||||
"id": new_id("art"),
|
||||
"owner": request.identity.subject,
|
||||
"type": artifact_type,
|
||||
"title": _string(body, "title", 200),
|
||||
"current_version": 1,
|
||||
"versions": [version],
|
||||
"sensitivity": sensitivity,
|
||||
"created_at": stamp,
|
||||
"updated_at": stamp,
|
||||
"revision": 1,
|
||||
"access": {"mode": "owner"},
|
||||
}
|
||||
for field in ("conversation_id", "project_id"):
|
||||
if _optional_id(body, field) is not None:
|
||||
artifact[field] = body[field]
|
||||
if _string(body, "language", 40, required=False) is not None:
|
||||
artifact["language"] = body["language"]
|
||||
_check(artifact)
|
||||
stored = request.store.put(FAMILY, artifact)
|
||||
remember(request.store, FAMILY, key, 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"])})
|
||||
|
||||
|
||||
def list_artifacts(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts?conversation_id=&project_id=&cursor=``: owned artifacts, oldest first."""
|
||||
filters = {k: request.query[k] for k in ("conversation_id", "project_id") if request.query.get(k)}
|
||||
cursor = request.query.get("cursor", "0")
|
||||
if not cursor.isdigit():
|
||||
raise Invalid("cursor must be a non-negative integer")
|
||||
items = [
|
||||
artifact
|
||||
for artifact in request.store.scan(FAMILY)
|
||||
if artifact.get("owner") == request.identity.subject and all(artifact.get(k) == v for k, v in filters.items())
|
||||
]
|
||||
start = int(cursor)
|
||||
window = items[start : start + PAGE_SIZE]
|
||||
request.audit("artifacts.list", "artifacts")
|
||||
return page(window, str(start + PAGE_SIZE) if len(items) > start + PAGE_SIZE else None)
|
||||
|
||||
|
||||
def get(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}``: one artifact document."""
|
||||
artifact = _load_owned(request)
|
||||
request.audit("artifacts.get", artifact["id"])
|
||||
return Response(200, artifact, {"ETag": str(artifact["revision"])})
|
||||
|
||||
|
||||
def add_version(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts/{id}/versions``: append a new immutable version (If-Match required)."""
|
||||
body = _body(request)
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required to append a version")
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
artifact = _load_owned(request)
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None and existing.split("@")[0] == artifact["id"]:
|
||||
request.audit("artifacts.version", existing, reason="idempotent_replay")
|
||||
return Response(200, artifact, {"HUX-Replayed": "true", "ETag": str(artifact["revision"])})
|
||||
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"]
|
||||
for field, limit in (("message_id", 120), ("note", 200)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
entry[field] = body[field]
|
||||
lineage = _lineage(request, body)
|
||||
if lineage is not None:
|
||||
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}", 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"])})
|
||||
|
||||
|
||||
def _version_number(request: Request) -> int:
|
||||
raw = request.params["n"]
|
||||
if not raw.isdigit() or int(raw) < 1:
|
||||
raise Invalid("version must be a positive integer")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def get_version(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}/versions/{n}``: version metadata plus content."""
|
||||
artifact = _load_owned(request)
|
||||
entry = _version(artifact, _version_number(request))
|
||||
data = request.store.get_blob(entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
text = diffs.decode_text(data) if diffs.is_text_type(artifact["type"]) else None
|
||||
body: dict[str, Any] = {"artifact_id": artifact["id"], "type": artifact["type"], "version": entry}
|
||||
if text is None:
|
||||
body["content_base64"] = base64.b64encode(data).decode("ascii")
|
||||
else:
|
||||
body["content"] = text
|
||||
request.audit("artifacts.get_version", f"{artifact['id']}@{entry['version']}")
|
||||
headers = {"X-Content-Type-Options": "nosniff", "Content-Disposition": "attachment", "ETag": str(artifact["revision"])}
|
||||
return Response(200, body, headers)
|
||||
|
||||
|
||||
def diff(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}/versions/{n}/diff?from=``: unified diff for text, sizes and hashes otherwise."""
|
||||
artifact = _load_owned(request)
|
||||
to_entry = _version(artifact, _version_number(request))
|
||||
raw_from = request.query.get("from", "")
|
||||
if raw_from and not raw_from.isdigit():
|
||||
raise Invalid("from must be a version number")
|
||||
from_number = int(raw_from) if raw_from else to_entry.get("diff_from", to_entry["version"])
|
||||
from_entry = _version(artifact, from_number)
|
||||
older = request.store.get_blob(from_entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
newer = request.store.get_blob(to_entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
request.audit("artifacts.diff", f"{artifact['id']}@{from_number}..{to_entry['version']}")
|
||||
return Response(200, diffs.unified(artifact["type"], older, newer, from_number, to_entry["version"]), {"X-Content-Type-Options": "nosniff"})
|
||||
|
||||
|
||||
def promote(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts/{id}/promote``: mark the current version as the project's copy."""
|
||||
body = _body(request)
|
||||
project_id = check_id(body.get("project_id"))
|
||||
if not _project_exists(request.store, project_id):
|
||||
raise NotFound("project not found")
|
||||
expected = request.if_match()
|
||||
with request.store.lock(FAMILY):
|
||||
artifact = _load_owned(request)
|
||||
if expected is not None and expected != artifact["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
||||
version = artifact["current_version"]
|
||||
if body.get("version") is not None:
|
||||
version = _version(artifact, body["version"])["version"]
|
||||
stamp = now_iso()
|
||||
updated = {**artifact, "project_id": project_id, "promotion": {"project_id": project_id, "version": version, "at": stamp}, "updated_at": stamp}
|
||||
_check(updated)
|
||||
stored = request.store.put(FAMILY, updated, expected_revision=artifact["revision"])
|
||||
request.audit("artifacts.promote", f"{stored['id']}@{version}", reason="" if expected is not None else "unconditional_write")
|
||||
emit_event(request.store, request.identity, stored, "artifact.promoted", f"Promoted version {version} to project", {"artifact_id": stored["id"], "version": version, "project_id": project_id})
|
||||
return Response(200, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-04 routes."""
|
||||
card = "HUX-04"
|
||||
router.add("POST", "/hux/v1/artifacts", card, "artifacts.create", create, max_body=MAX_UPLOAD_BODY)
|
||||
router.add("GET", "/hux/v1/artifacts", card, "artifacts.list", list_artifacts)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}", card, "artifacts.get", get)
|
||||
router.add("POST", "/hux/v1/artifacts/{id}/versions", card, "artifacts.version", add_version, max_body=MAX_UPLOAD_BODY)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}", card, "artifacts.get_version", get_version)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}/diff", card, "artifacts.diff", diff)
|
||||
router.add("POST", "/hux/v1/artifacts/{id}/promote", card, "artifacts.promote", promote)
|
||||
40
dockerfiles/hermes-hux-foundation/hux/audit.py
Normal file
40
dockerfiles/hermes-hux-foundation/hux/audit.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""Auditable outcome for every read and mutation.
|
||||
|
||||
One JSONL ledger per tenant per day. The record shape is
|
||||
``common.schema.json#/$defs/audit_outcome``; it never carries request bodies,
|
||||
only the action name, the resource id and the decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, now_iso
|
||||
|
||||
OUTCOMES = ("allow", "deny", "not_found", "conflict", "flag_off")
|
||||
|
||||
|
||||
def record(store: TenantStore, identity: Identity, action: str, resource: str, outcome: str, reason: str = "") -> dict:
|
||||
"""Append an audit outcome and return it."""
|
||||
if outcome not in OUTCOMES:
|
||||
raise ValueError(f"unknown outcome {outcome!r}")
|
||||
entry = {
|
||||
"at": now_iso(),
|
||||
"identity": identity.record(),
|
||||
"action": action,
|
||||
"resource": resource[:200],
|
||||
"outcome": outcome,
|
||||
}
|
||||
if reason:
|
||||
entry["reason"] = reason[:200]
|
||||
store.append("audit", now_iso()[:10], entry)
|
||||
return entry
|
||||
|
||||
|
||||
def recent(store: TenantStore, limit: int = 200) -> list[dict]:
|
||||
"""Newest audit rows across day ledgers, newest last."""
|
||||
rows: list[dict] = []
|
||||
for name in reversed(store.ledgers("audit")):
|
||||
rows = store.read("audit", name) + rows
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return rows[-limit:]
|
||||
258
dockerfiles/hermes-hux-foundation/hux/budgets.py
Normal file
258
dockerfiles/hermes-hux-foundation/hux/budgets.py
Normal file
@ -0,0 +1,258 @@
|
||||
"""HUX-05 run controls: budgets, the pre-side-effect gate and cancellation receipts.
|
||||
|
||||
The agent hook reports spend per run and asks the gate before every side
|
||||
effect. The gate releases only against an approved, unexpired approval for the
|
||||
same run, capability and argument hash; a ``once`` approval is consumed by its
|
||||
first release (SO-36, SO-37). A stop is not done until a receipt says what
|
||||
actually happened to the run and its side effects (SO-41).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hux import policy, rules
|
||||
from hux.errors import Invalid
|
||||
from hux.http import Request, Response, Router
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id
|
||||
|
||||
BUDGETS = "budgets"
|
||||
RECEIPTS = "receipts"
|
||||
SPEND_KEYS = ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")
|
||||
LIMIT_OF = {
|
||||
"tokens": "tokens_per_run", "tool_calls": "tool_calls_per_run", "wall_clock_seconds": "wall_clock_seconds",
|
||||
"delegations": "delegations_per_run", "spend_units": "spend_units", "subagents": "subagents_per_run",
|
||||
}
|
||||
RUN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
|
||||
|
||||
|
||||
def checked_run_id(value: Any) -> str:
|
||||
"""Return a canonical run id accepted by both body and path APIs."""
|
||||
if not isinstance(value, str) or not RUN_ID_RE.fullmatch(value):
|
||||
raise Invalid("run id is malformed or too long")
|
||||
return value
|
||||
|
||||
|
||||
def run_id_from(request: Request) -> str:
|
||||
"""The run id in the path; the router already bounded its alphabet."""
|
||||
return checked_run_id(request.params["id"])
|
||||
|
||||
|
||||
# -- budgets -------------------------------------------------------------------
|
||||
|
||||
def exhausted(spent: dict[str, int], limits: dict[str, Any]) -> list[str]:
|
||||
"""Limit names whose spend has reached them; a zero limit is exhausted immediately."""
|
||||
return [LIMIT_OF[k] for k in SPEND_KEYS if LIMIT_OF[k] in limits and spent.get(k, 0) >= limits[LIMIT_OF[k]]]
|
||||
|
||||
|
||||
def budget_state(store: TenantStore, identity: Identity, run_id: str, conversation_id: str | None = None) -> dict[str, Any]:
|
||||
"""Current state for a run, enforced against its conversation's policy epoch aggregate."""
|
||||
run_id = checked_run_id(run_id)
|
||||
doc_id = f"bud_{policy.run_key(run_id)}"
|
||||
stored = store.get(BUDGETS, doc_id) if store.exists(BUDGETS, doc_id) else {"spent": {}, "_conversation_id": None}
|
||||
known_conversation = stored.get("_conversation_id") or run_conversation(store, run_id)
|
||||
if known_conversation and conversation_id and known_conversation != conversation_id:
|
||||
raise Invalid("run is already bound to another conversation")
|
||||
conversation_id = known_conversation or conversation_id
|
||||
level, scope_id = ("conversation", conversation_id) if conversation_id else ("global", None)
|
||||
effective = policy.effective_policy(store, identity, level, scope_id)
|
||||
limits = {k: v for k, v in effective["budgets"].items() if k != "scope"}
|
||||
epoch = str(effective.get("_budget_epoch") or f"{effective['id']}:{effective['revision']}")
|
||||
same_epoch = stored.get("_budget_epoch") == epoch
|
||||
run_spent = {k: int(stored["spent"].get(k, 0)) if same_epoch else 0 for k in SPEND_KEYS}
|
||||
aggregate = {k: 0 for k in SPEND_KEYS}
|
||||
for record in store.scan(BUDGETS):
|
||||
if record.get("_conversation_id") != conversation_id or record.get("_budget_epoch") != epoch:
|
||||
continue
|
||||
for key in SPEND_KEYS:
|
||||
aggregate[key] += int(record.get("spent", {}).get(key, 0))
|
||||
return policy.checked({
|
||||
"schema": "hux.budget_state.v1", "run_id": run_id[:120], "spent": aggregate, "limits": limits,
|
||||
"exhausted": exhausted(aggregate, limits), "_conversation_id": conversation_id, "_id": doc_id,
|
||||
"_run_spent": run_spent, "_budget_epoch": epoch,
|
||||
})
|
||||
|
||||
|
||||
def get_budget(request: Request) -> Response:
|
||||
"""``GET /hux/v1/runs/{id}/budget``: spend so far and what is exhausted."""
|
||||
state = budget_state(request.store, request.identity, run_id_from(request))
|
||||
request.audit("budgets.read", state["_id"])
|
||||
return Response(200, policy.public(state))
|
||||
|
||||
|
||||
def post_budget(request: Request) -> Response:
|
||||
"""``POST /hux/v1/runs/{id}/budget``: add spend increments; emits budget.exhausted on the crossing."""
|
||||
policy.require_worker(request.identity, "budget reports")
|
||||
body = policy.body_dict(request)
|
||||
run_id = run_id_from(request)
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None:
|
||||
conversation_id = check_id(conversation_id)
|
||||
increments = {}
|
||||
for key in SPEND_KEYS:
|
||||
value = body.get(key, 0)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise Invalid(f"{key} must be a non-negative integer")
|
||||
increments[key] = value
|
||||
with request.store.lock(BUDGETS):
|
||||
before = budget_state(request.store, request.identity, run_id, conversation_id)
|
||||
known = before["exhausted"] if request.store.exists(BUDGETS, before["_id"]) else []
|
||||
spent = {k: before["_run_spent"][k] + increments[k] for k in SPEND_KEYS}
|
||||
doc = {
|
||||
"id": before["_id"], "run_id": run_id, "spent": spent,
|
||||
"_conversation_id": before["_conversation_id"], "_budget_epoch": before["_budget_epoch"],
|
||||
}
|
||||
request.store.put(BUDGETS, doc)
|
||||
after = budget_state(request.store, request.identity, run_id, conversation_id)
|
||||
request.audit("budgets.write", after["_id"])
|
||||
newly = [k for k in after["exhausted"] if k not in known]
|
||||
if newly:
|
||||
policy.emit(request.store, request.identity, after["_conversation_id"], "budget.exhausted",
|
||||
f"Budget exhausted: {', '.join(newly)}", run_id=run_id)
|
||||
return Response(200, policy.public(after))
|
||||
|
||||
|
||||
# -- gate ----------------------------------------------------------------------
|
||||
|
||||
def hashes_of(approval: dict[str, Any]) -> set[str]:
|
||||
"""Argument hashes the approval was requested for (tool_call evidence with a hash)."""
|
||||
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 trusted Worker bound to this run in its budget document (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"]
|
||||
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 conversation the gate settled on.
|
||||
|
||||
Every release uses an approval record created for this exact run. A
|
||||
session/always grant may let a later run create a new approved record,
|
||||
but the old record itself never crosses run ids. External effects also
|
||||
require the same argument hash; ``once`` names exactly one hash (SO-36,
|
||||
SO-37, SO-39).
|
||||
"""
|
||||
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
|
||||
choice = record.get("decision", {}).get("choice")
|
||||
if not same_run:
|
||||
continue
|
||||
if record["status"] != "approved":
|
||||
reason = f"approval {record['id']} is {record['status']}"
|
||||
continue
|
||||
if policy.parse(record["expires_at"]) <= policy.now():
|
||||
reason = f"approval {record['id']} has expired"
|
||||
continue
|
||||
if external and not record["request"]["external"]:
|
||||
reason = f"approval {record['id']} was not requested as external"
|
||||
continue
|
||||
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 or record["conversation_id"]
|
||||
return None, reason, conversation_id
|
||||
|
||||
|
||||
def gate(request: Request) -> Response:
|
||||
"""``POST /hux/v1/runs/{id}/gate``: may this side effect proceed right now?"""
|
||||
policy.require_worker(request.identity, "gate checks")
|
||||
body = policy.body_dict(request)
|
||||
run_id = run_id_from(request)
|
||||
capability = body.get("capability")
|
||||
argument_hash = body.get("argument_hash")
|
||||
if capability not in rules.CAPABILITIES:
|
||||
raise Invalid("unknown capability")
|
||||
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 = run_conversation(request.store, run_id)
|
||||
if policy.private_mode_denies(request.store, conversation_id, capability):
|
||||
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="private_mode")
|
||||
policy.emit(request.store, request.identity, conversation_id, "side_effect.blocked", f"{capability} blocked: private mode", run_id=run_id)
|
||||
return Response(200, {"proceed": False, "reason": "private_mode"})
|
||||
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":
|
||||
request.store.put(policy.APPROVALS, {**record, "_consumed_at": policy.iso(policy.now())})
|
||||
if record is None:
|
||||
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="policy_violation")
|
||||
policy.emit(request.store, request.identity, conversation_id, "side_effect.blocked", f"{capability} blocked: {reason}"[:280], run_id=run_id)
|
||||
return Response(200, {"proceed": False, "reason": reason})
|
||||
request.audit("gate.check", record["id"])
|
||||
policy.emit(request.store, request.identity, conversation_id, "side_effect.released", f"{capability} released by approval {record['id']}",
|
||||
run_id=run_id, evidence=[{"kind": "approval", "id": record["id"]}])
|
||||
return Response(200, {"proceed": True, "approval_id": record["id"], "reason": reason})
|
||||
|
||||
|
||||
# -- stop ----------------------------------------------------------------------
|
||||
|
||||
def stop_outcome(body: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Return the cancellation outcome proven by the gateway's 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"
|
||||
return "cancelled", "cancelled"
|
||||
|
||||
|
||||
def stop(request: Request) -> Response:
|
||||
"""``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).
|
||||
"""
|
||||
policy.require_worker(request.identity, "stop receipts")
|
||||
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(body)
|
||||
with request.store.lock(RECEIPTS):
|
||||
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))
|
||||
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": 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", 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), 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))
|
||||
|
||||
|
||||
def register_routes(router: Router) -> None:
|
||||
"""Attach the run-scoped HUX-05 routes; called by ``hux.policy.register``."""
|
||||
router.add("GET", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.read", get_budget)
|
||||
router.add("POST", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.write", post_budget)
|
||||
router.add("POST", "/hux/v1/runs/{id}/gate", policy.CARD, "gate.check", gate)
|
||||
router.add("POST", "/hux/v1/runs/{id}/stop", policy.CARD, "runs.stop", stop)
|
||||
@ -10,23 +10,30 @@ loudly rather than silently passing.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "hux"
|
||||
CONTRACT_DIR = Path(
|
||||
os.environ.get("HUX_CONTRACT_DIR")
|
||||
or Path(__file__).resolve().parents[3] / "services" / "hermes" / "contracts" / "hux"
|
||||
)
|
||||
SCHEMA_FILES = (
|
||||
"common.schema.json",
|
||||
"identity.schema.json",
|
||||
"event.schema.json",
|
||||
"memory.schema.json",
|
||||
"project.schema.json",
|
||||
"artifact.schema.json",
|
||||
"permission.schema.json",
|
||||
"mode.schema.json",
|
||||
"multimodal.schema.json",
|
||||
"citation.schema.json",
|
||||
"suggestion.schema.json",
|
||||
"privacy.schema.json",
|
||||
"release.schema.json",
|
||||
"release-ledger.schema.json",
|
||||
)
|
||||
SUPPORTED_KEYWORDS = frozenset(
|
||||
{
|
||||
@ -70,7 +77,7 @@ def load_flags(directory: Path = CONTRACT_DIR) -> dict[str, Any]:
|
||||
def _walk(node: Any, path: str, problems: list[str]) -> None:
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if path.endswith("/properties") or path.endswith("/$defs"):
|
||||
if path.endswith(("/properties", "/$defs")):
|
||||
_walk(value, f"{path}/{key}", problems)
|
||||
continue
|
||||
if key not in SUPPORTED_KEYWORDS:
|
||||
50
dockerfiles/hermes-hux-foundation/hux/diffs.py
Normal file
50
dockerfiles/hermes-hux-foundation/hux/diffs.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Version-to-version comparison for artifacts (HUX-04).
|
||||
|
||||
Text artifacts get a unified diff from ``difflib``; anything that is not
|
||||
valid UTF-8, or whose artifact type is binary, is described by sizes and
|
||||
hashes only so the UI never tries to render bytes as text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
TEXT_TYPES = frozenset({"markdown", "code", "html", "svg", "json", "csv"})
|
||||
|
||||
|
||||
def is_text_type(artifact_type: str) -> bool:
|
||||
"""True for artifact types whose content is served as UTF-8 text."""
|
||||
return artifact_type in TEXT_TYPES
|
||||
|
||||
|
||||
def decode_text(data: bytes) -> str | None:
|
||||
"""UTF-8 decode, or None when the bytes are not text."""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def unified(artifact_type: str, older: bytes, newer: bytes, from_n: int, to_n: int) -> dict[str, Any]:
|
||||
"""Diff record ``{"from", "to", "unified"|"binary"}`` for two versions."""
|
||||
record: dict[str, Any] = {"from": from_n, "to": to_n}
|
||||
old_text = decode_text(older) if is_text_type(artifact_type) else None
|
||||
new_text = decode_text(newer) if is_text_type(artifact_type) else None
|
||||
if old_text is None or new_text is None:
|
||||
record["binary"] = {
|
||||
"from_bytes": len(older),
|
||||
"to_bytes": len(newer),
|
||||
"from_hash": "sha256:" + hashlib.sha256(older).hexdigest(),
|
||||
"to_hash": "sha256:" + hashlib.sha256(newer).hexdigest(),
|
||||
}
|
||||
return record
|
||||
lines = difflib.unified_diff(
|
||||
old_text.splitlines(keepends=True),
|
||||
new_text.splitlines(keepends=True),
|
||||
fromfile=f"v{from_n}",
|
||||
tofile=f"v{to_n}",
|
||||
)
|
||||
record["unified"] = "".join(lines)
|
||||
return record
|
||||
92
dockerfiles/hermes-hux-foundation/hux/errors.py
Normal file
92
dockerfiles/hermes-hux-foundation/hux/errors.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Error types that map one-to-one onto ``hux.error.v1`` records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class HuxError(Exception):
|
||||
"""Base class; ``status`` and ``code`` follow identity.schema.json#/$defs/error."""
|
||||
|
||||
status = 500
|
||||
code = "invalid"
|
||||
|
||||
def __init__(self, message: str, details: list[str] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = list(details or [])
|
||||
|
||||
def record(self) -> dict:
|
||||
"""Serialise as a ``hux.error.v1`` record."""
|
||||
body = {"schema": "hux.error.v1", "status": self.status, "code": self.code, "message": self.message[:280]}
|
||||
if self.details:
|
||||
body["details"] = [d[:280] for d in self.details[:32]]
|
||||
return body
|
||||
|
||||
|
||||
class Unauthorized(HuxError):
|
||||
"""Identity headers missing, malformed or not trusted."""
|
||||
|
||||
status, code = 401, "unauthorized"
|
||||
|
||||
|
||||
class Forbidden(HuxError):
|
||||
"""Identity is valid but does not own the resource."""
|
||||
|
||||
status, code = 403, "forbidden"
|
||||
|
||||
|
||||
class NotFound(HuxError):
|
||||
"""Resource does not exist for this tenant."""
|
||||
|
||||
status, code = 404, "not_found"
|
||||
|
||||
|
||||
class FlagOff(HuxError):
|
||||
"""Card (or one of its dependencies) is disabled; indistinguishable from not found on purpose."""
|
||||
|
||||
status, code = 404, "flag_off"
|
||||
|
||||
|
||||
class Conflict(HuxError):
|
||||
"""If-Match revision mismatch or duplicate create."""
|
||||
|
||||
status, code = 409, "conflict"
|
||||
|
||||
|
||||
class Invalid(HuxError):
|
||||
"""Body failed contract validation."""
|
||||
|
||||
status, code = 400, "invalid"
|
||||
|
||||
|
||||
class Unprocessable(HuxError):
|
||||
"""Well-formed body that violates a rule (hash mismatch, policy violation)."""
|
||||
|
||||
status, code = 422, "unprocessable"
|
||||
|
||||
|
||||
class TooLarge(HuxError):
|
||||
"""Body, record or family exceeds its bound."""
|
||||
|
||||
status, code = 413, "too_large"
|
||||
|
||||
|
||||
class RateLimited(HuxError):
|
||||
"""The caller exceeded the bounded per-subject request rate."""
|
||||
|
||||
status, code = 429, "rate_limited"
|
||||
|
||||
def __init__(self, retry_after: int) -> None:
|
||||
super().__init__("request rate limit exceeded")
|
||||
self.retry_after = max(1, int(retry_after))
|
||||
|
||||
|
||||
class ApprovalRequired(HuxError):
|
||||
"""An action needs an approval record before it may proceed."""
|
||||
|
||||
status, code = 403, "approval_required"
|
||||
|
||||
|
||||
class BudgetExhausted(HuxError):
|
||||
"""A run budget has been spent."""
|
||||
|
||||
status, code = 429, "budget_exhausted"
|
||||
315
dockerfiles/hermes-hux-foundation/hux/events.py
Normal file
315
dockerfiles/hermes-hux-foundation/hux/events.py
Normal file
@ -0,0 +1,315 @@
|
||||
"""HUX-01 activity events: the per-conversation append-only log and its readers.
|
||||
|
||||
``emit`` is the one write path every family uses. It runs the redaction
|
||||
pipeline, allocates ``seq`` under the conversation lock, honours idempotency
|
||||
keys and keeps a checkpoint document per conversation. Readers page by
|
||||
``after_seq`` or follow an SSE stream whose ``id:`` is the seq, and every
|
||||
served record passes serve-time redaction for the caller's surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from collections.abc import Iterator
|
||||
|
||||
from hux import contracts, redaction
|
||||
from hux.errors import Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, new_id, now_iso
|
||||
|
||||
FAMILY = "events"
|
||||
SEQ_FAMILY = "events_seq"
|
||||
IDEM_FAMILY = "events_idem"
|
||||
PAGE_MAX = 200
|
||||
PAGE_DEFAULT = 100
|
||||
STREAM_MAX_POLLS = 900
|
||||
STREAM_POLL_SECONDS = 1.0
|
||||
SCHEMAS = contracts.load_all()
|
||||
KINDS = frozenset(contracts.load_schema("event.schema.json")["properties"]["kind"]["enum"])
|
||||
USER_KINDS = frozenset({"message.user", "approval.resolved", "run.cancelled", "memory.forgotten", "suggestion.dismissed"})
|
||||
_streams: dict[str, int] = {}
|
||||
_streams_guard = threading.Lock()
|
||||
|
||||
|
||||
def _seq_id(conversation_id: str) -> str:
|
||||
return f"seq_{conversation_id}"
|
||||
|
||||
|
||||
def is_private(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""True when the conversation document (HUX-03) says the mode is private (SO-28)."""
|
||||
try:
|
||||
return store.get("conversations", conversation_id).get("mode") == "private"
|
||||
except NotFound:
|
||||
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 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}
|
||||
if identity.trust == "worker":
|
||||
return {"type": "system", "id": "hux-worker"}
|
||||
return {"type": "assistant", "id": "hermes"}
|
||||
|
||||
|
||||
def _find_replay(store: TenantStore, conversation_id: str, key: str) -> dict[str, Any] | None:
|
||||
for row in store.read(IDEM_FAMILY, conversation_id):
|
||||
if row.get("idempotency_key") == key:
|
||||
for event in store.read(FAMILY, conversation_id):
|
||||
if event.get("id") == row.get("event_id"):
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def build(identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None, evidence: list | None,
|
||||
sensitivity: str, run_id: str | None, turn: int | None, correlation_id: str | None, idempotency_key: str | None) -> dict[str, Any]:
|
||||
"""Run the redaction pipeline and shape an unsequenced ``hux.event.v1`` record."""
|
||||
if kind not in KINDS:
|
||||
raise Invalid(f"unknown event kind {kind!r}")
|
||||
if sensitivity not in ("public", "personal", "sensitive", "restricted"):
|
||||
raise Invalid("unknown sensitivity")
|
||||
hits: list[str] = []
|
||||
summary_clean = redaction.scrub_value(str(summary or "").strip(), hits)[: redaction.SUMMARY_MAX] or "[empty]"
|
||||
detail_clean = redaction.scrub_value(redaction.filter_detail(kind, detail), hits)
|
||||
detail_clean, truncated = redaction.cap_detail(detail_clean)
|
||||
evidence_clean = redaction.scrub_value(redaction.filter_evidence(evidence), hits)
|
||||
stamp = now_iso()
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.event.v1",
|
||||
"id": new_id("evt"),
|
||||
"seq": 0,
|
||||
"ts": stamp,
|
||||
"conversation_id": conversation_id,
|
||||
"kind": kind,
|
||||
"summary": summary_clean,
|
||||
"provenance": {"surface": identity.surface, "actor": _actor(identity, kind), "recorded_at": stamp, "conversation_id": conversation_id},
|
||||
"sensitivity": sensitivity,
|
||||
"redaction": redaction.derive_level(sensitivity, hits, truncated),
|
||||
"turn": max(0, int(turn or 0)),
|
||||
"identity": identity.record(),
|
||||
}
|
||||
if detail_clean:
|
||||
record["detail"] = detail_clean
|
||||
if evidence_clean:
|
||||
record["evidence"] = evidence_clean
|
||||
if run_id:
|
||||
record["run_id"] = str(run_id)[:120]
|
||||
record["provenance"]["run_id"] = record["run_id"]
|
||||
if correlation_id:
|
||||
record["correlation_id"] = str(correlation_id)[:120]
|
||||
if idempotency_key:
|
||||
record["idempotency_key"] = idempotency_key
|
||||
return record
|
||||
|
||||
|
||||
def emit_with_status(store: TenantStore, identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None = None,
|
||||
evidence: list | None = None, sensitivity: str = "personal", run_id: str | None = None, turn: int | None = None,
|
||||
correlation_id: str | None = None, idempotency_key: str | None = None) -> tuple[dict[str, Any] | None, bool]:
|
||||
"""Like ``emit`` but also says whether the record was an idempotent replay. None means private mode (nothing written)."""
|
||||
if is_private(store, conversation_id):
|
||||
return None, False
|
||||
record = build(identity, conversation_id, kind, summary, detail, evidence, sensitivity, run_id, turn, correlation_id, idempotency_key)
|
||||
with store.lock(f"{FAMILY}:{conversation_id}"):
|
||||
if idempotency_key:
|
||||
existing = _find_replay(store, conversation_id, idempotency_key)
|
||||
if existing is not None:
|
||||
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"] = 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)
|
||||
line = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
|
||||
if len(line) > redaction.LINE_CAP_BYTES:
|
||||
raise TooLarge("event line exceeds 64 KiB")
|
||||
store.append(FAMILY, conversation_id, record)
|
||||
store.put(SEQ_FAMILY, {**checkpoint, "next_seq": record["seq"] + 1, "last_event_id": record["id"], "checkpointed_at": now_iso()})
|
||||
if idempotency_key:
|
||||
store.append(IDEM_FAMILY, conversation_id, {"idempotency_key": idempotency_key, "event_id": record["id"], "seq": record["seq"], "at": record["ts"]})
|
||||
return record, False
|
||||
|
||||
|
||||
def emit(store: TenantStore, identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None = None,
|
||||
evidence: list | 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:
|
||||
"""Append one event and return the stored record (None when the conversation is private)."""
|
||||
record, _ = emit_with_status(store, identity, conversation_id, kind, summary, detail, evidence, sensitivity, run_id, turn, correlation_id, idempotency_key)
|
||||
return record
|
||||
|
||||
|
||||
def read_after(store: TenantStore, conversation_id: str, after_seq: int, limit: int) -> list[dict[str, Any]]:
|
||||
"""Stored events with ``seq > after_seq``, oldest first, at most ``limit``."""
|
||||
rows = [row for row in store.read(FAMILY, conversation_id) if int(row.get("seq", 0)) > after_seq]
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def rewrite_full(store: TenantStore, conversation_id: str, summary: str, reason: str, match=None) -> int:
|
||||
"""Rewrite matching events of one conversation to ``redaction.level: full`` (SO-24, forget, decay). Returns the count."""
|
||||
with store.lock(f"{FAMILY}:{conversation_id}"):
|
||||
rows = store.read(FAMILY, conversation_id)
|
||||
changed = 0
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if row.get("redaction", {}).get("level") != "full" and (match is None or match(row)):
|
||||
row = redaction.full_redaction(row, summary, reason)
|
||||
changed += 1
|
||||
out.append(row)
|
||||
if changed:
|
||||
store.rewrite(FAMILY, conversation_id, out)
|
||||
return changed
|
||||
|
||||
|
||||
def redact_memory_references(store: TenantStore, memory_id: str) -> int:
|
||||
"""Fully redact every event, in any conversation, that names a forgotten memory id (SO-24)."""
|
||||
|
||||
def references(row: dict[str, Any]) -> bool:
|
||||
if row.get("detail", {}).get("memory_id") == memory_id:
|
||||
return True
|
||||
return any(ref.get("kind") == "memory" and ref.get("id") == memory_id for ref in row.get("evidence", []))
|
||||
|
||||
return sum(rewrite_full(store, name, "[forgotten memory]", "memory forgotten", references) for name in store.ledgers(FAMILY))
|
||||
|
||||
|
||||
# -- routes ------------------------------------------------------------------
|
||||
|
||||
def _int_query(request: Request, name: str, default: int, floor: int = 0) -> int:
|
||||
raw = request.query.get(name, "")
|
||||
if raw == "":
|
||||
return default
|
||||
if not raw.lstrip("-").isdigit():
|
||||
raise Invalid(f"{name} must be an integer")
|
||||
return max(floor, int(raw))
|
||||
|
||||
|
||||
def _require_conversation(request: Request) -> str:
|
||||
conversation_id = request.params["id"]
|
||||
if not conversation_known(request.store, conversation_id):
|
||||
raise NotFound("conversation not found")
|
||||
return conversation_id
|
||||
|
||||
|
||||
def list_events(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}/events?after_seq=&limit=``: one page, ``next`` is the last seq served."""
|
||||
conversation_id = _require_conversation(request)
|
||||
after_seq = _int_query(request, "after_seq", 0)
|
||||
limit = min(_int_query(request, "limit", PAGE_DEFAULT, 1), PAGE_MAX)
|
||||
rows = read_after(request.store, conversation_id, after_seq, limit)
|
||||
items = [redaction.redact_record(row, request.identity.surface) for row in rows]
|
||||
request.audit("events.list", conversation_id)
|
||||
return page(items, items[-1]["seq"] if len(items) == limit else None)
|
||||
|
||||
|
||||
def append_event(request: Request) -> Response:
|
||||
"""``POST /hux/v1/conversations/{id}/events``: append from the trusted hop; ids and seq are server-assigned (SO-15)."""
|
||||
conversation_id = _require_conversation(request)
|
||||
body = request.body if isinstance(request.body, dict) else None
|
||||
if body is None:
|
||||
raise Invalid("body must be an object")
|
||||
if "id" in body or "seq" in body:
|
||||
raise Invalid("id and seq are server-assigned")
|
||||
if not isinstance(body.get("kind"), str) or not isinstance(body.get("summary"), str):
|
||||
raise Invalid("kind and summary are required")
|
||||
detail = body.get("detail") if isinstance(body.get("detail"), dict) else None
|
||||
evidence = body.get("evidence") if isinstance(body.get("evidence"), list) else None
|
||||
turn = body.get("turn") if isinstance(body.get("turn"), int) else None
|
||||
record, replayed = emit_with_status(
|
||||
request.store, request.identity, conversation_id, body["kind"], body["summary"], detail, evidence,
|
||||
body.get("sensitivity", "personal"), body.get("run_id"), turn, body.get("correlation_id"), request.idempotency_key() or None,
|
||||
)
|
||||
if record is None:
|
||||
request.audit("events.append", conversation_id, "allow", "private_mode")
|
||||
return Response(204)
|
||||
request.audit("events.append", f"{conversation_id}/{record['id']}", "allow", "replayed" if replayed else "")
|
||||
served = redaction.redact_record(record, request.identity.surface)
|
||||
return Response(200 if replayed else 201, served, {"HUX-Replayed": "true"} if replayed else {})
|
||||
|
||||
|
||||
def _sse(record: dict[str, Any]) -> bytes:
|
||||
return f"id: {record['seq']}\nevent: {record['kind']}\ndata: {json.dumps(record, sort_keys=True)}\n\n".encode()
|
||||
|
||||
|
||||
def stream_events(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}/events/stream``: SSE replay from ``Last-Event-ID`` (or ``after_seq``), then a bounded live poll.
|
||||
|
||||
``max_polls`` and ``poll_ms`` (query) bound the live phase so a client, or a
|
||||
test, decides how long to wait; the server caps them at the 15 minute idle
|
||||
limit (SO-17). A second stream for the same (subject, conversation) closes
|
||||
the first.
|
||||
"""
|
||||
conversation_id = _require_conversation(request)
|
||||
last_id = request.header("Last-Event-ID")
|
||||
cursor = int(last_id) if last_id.isdigit() else _int_query(request, "after_seq", 0)
|
||||
max_polls = min(_int_query(request, "max_polls", STREAM_MAX_POLLS), STREAM_MAX_POLLS)
|
||||
poll_seconds = min(_int_query(request, "poll_ms", int(STREAM_POLL_SECONDS * 1000)), 60000) / 1000
|
||||
store, surface = request.store, request.identity.surface
|
||||
key = f"{request.identity.subject}:{conversation_id}"
|
||||
with _streams_guard:
|
||||
token = _streams[key] = _streams.get(key, 0) + 1
|
||||
request.audit("events.stream", conversation_id)
|
||||
|
||||
def generate() -> Iterator[bytes]:
|
||||
position = cursor
|
||||
yield b"retry: 2000\n\n"
|
||||
polls = 0
|
||||
while True:
|
||||
for record in read_after(store, conversation_id, position, PAGE_MAX):
|
||||
position = record["seq"]
|
||||
yield _sse(redaction.redact_record(record, surface))
|
||||
if polls >= max_polls or _streams.get(key) != token:
|
||||
break
|
||||
polls += 1
|
||||
yield b": keepalive\n\n"
|
||||
time.sleep(poll_seconds)
|
||||
|
||||
return Response(200, stream=generate)
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-01 routes."""
|
||||
router.add("GET", "/hux/v1/conversations/{id}/events", "HUX-01", "events.list", list_events)
|
||||
router.add("POST", "/hux/v1/conversations/{id}/events", "HUX-01", "events.append", append_event)
|
||||
router.add("GET", "/hux/v1/conversations/{id}/events/stream", "HUX-01", "events.stream", stream_events)
|
||||
150
dockerfiles/hermes-hux-foundation/hux/flags.py
Normal file
150
dockerfiles/hermes-hux-foundation/hux/flags.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Per-card feature flags and the capabilities record clients negotiate with.
|
||||
|
||||
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, and the
|
||||
worker allowlist (SO-08) says which of those a ``trust: worker`` caller may
|
||||
reach at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from hux.errors import FlagOff
|
||||
from hux.identity import Identity
|
||||
from hux.rules import flag_enabled, flag_registry
|
||||
|
||||
CONTRACT_VERSION = "1.1.0"
|
||||
RELEASE_TAG = re.compile(r"^git-([0-9a-f]{40})-build-[1-9][0-9]*-release$")
|
||||
CARD_ROUTES: dict[str, list[str]] = {
|
||||
"HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest", "/hux/v1/context/bootstrap"],
|
||||
"HUX-01": ["/hux/v1/conversations/{id}/events", "/hux/v1/conversations/{id}/events/stream"],
|
||||
"HUX-02": ["/hux/v1/memory", "/hux/v1/memory/{id}", "/hux/v1/memory/{id}/{action}", "/hux/v1/memory/export"],
|
||||
"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/projects/{project_id}/conversations/{id}/mode"],
|
||||
"HUX-07": [
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items",
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}",
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections",
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/capture-intents",
|
||||
],
|
||||
"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/projects/{project_id}/conversations/{id}/suggestions/evaluate",
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions",
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states",
|
||||
],
|
||||
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit", "/hux/v1/conversations/{id}/privacy"],
|
||||
"HUX-12": [
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/releases",
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}",
|
||||
"/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions",
|
||||
],
|
||||
}
|
||||
|
||||
# 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"),
|
||||
("POST", "/hux/v1/context/bootstrap"),
|
||||
("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/conversations/{id}/privacy"),
|
||||
("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"),
|
||||
("GET", "/hux/v1/modes"),
|
||||
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode"),
|
||||
("POST", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items"),
|
||||
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"),
|
||||
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"),
|
||||
})
|
||||
|
||||
|
||||
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:
|
||||
"""Snapshot of which cards are on for this process."""
|
||||
|
||||
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
|
||||
self._environ = dict(os.environ if environ is None else environ)
|
||||
self._registry = flag_registry()
|
||||
self._route_cards = frozenset(card for card, routes in CARD_ROUTES.items() if routes)
|
||||
|
||||
def bind_routes(self, routes: Mapping[str, set[str]]) -> None:
|
||||
"""Bind capability flags to the route templates actually registered by this process."""
|
||||
unknown = set(routes) - set(self._registry)
|
||||
if unknown:
|
||||
raise ValueError(f"routes registered for unknown cards: {sorted(unknown)}")
|
||||
for card, actual in routes.items():
|
||||
undeclared = actual - set(CARD_ROUTES.get(card, []))
|
||||
if undeclared:
|
||||
raise ValueError(f"undeclared routes for {card}: {sorted(undeclared)}")
|
||||
self._route_cards = frozenset(
|
||||
card for card, declared in CARD_ROUTES.items() if declared and set(declared) == routes.get(card, set())
|
||||
)
|
||||
|
||||
def enabled(self, card: str) -> bool:
|
||||
"""True only for a route-backed card whose configured flag chain is on."""
|
||||
entry = self._registry.get(card)
|
||||
enabled = bool(entry) and card in self._route_cards and flag_enabled(entry["flag"], self._environ)
|
||||
if enabled and card == "HUX-12":
|
||||
from hux.release_security import configured
|
||||
|
||||
return configured(self._environ)
|
||||
return enabled
|
||||
|
||||
def require(self, card: str) -> None:
|
||||
"""Raise FlagOff unless the card is enabled."""
|
||||
if not self.enabled(card):
|
||||
raise FlagOff(f"{card} is not enabled")
|
||||
|
||||
@property
|
||||
def environ(self) -> Mapping[str, str]:
|
||||
"""Read-only process configuration for route-family policy checks."""
|
||||
return self._environ
|
||||
|
||||
def capabilities(self, identity: Identity, build: Mapping[str, str] | None = None) -> dict:
|
||||
"""Serialise ``hux.capabilities.v1`` for one caller."""
|
||||
cards = [
|
||||
{"card": card, "flag": entry["flag"], "enabled": self.enabled(card), "routes": CARD_ROUTES.get(card, [])}
|
||||
for card, entry in sorted(self._registry.items())
|
||||
]
|
||||
server = {k: v for k, v in (build or {}).items() if k in {"commit", "image_digest"} and v}
|
||||
return {
|
||||
"schema": "hux.capabilities.v1",
|
||||
"contract_version": CONTRACT_VERSION,
|
||||
"identity": identity.record(),
|
||||
"cards": cards,
|
||||
"server": server,
|
||||
}
|
||||
|
||||
|
||||
def build_from_environ(environ: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
"""Commit and image digest the pod was started with, when the operator set them."""
|
||||
environ = os.environ if environ is None else environ
|
||||
tag = environ.get("HUX_IMAGE_TAG", "")
|
||||
commit = environ.get("HUX_BUILD_COMMIT", "")
|
||||
if tag:
|
||||
match = RELEASE_TAG.fullmatch(tag)
|
||||
if not match:
|
||||
raise ValueError("HUX_IMAGE_TAG is not an immutable WebUI release tag")
|
||||
if commit and commit != match.group(1):
|
||||
raise ValueError("HUX build commit conflicts with the immutable image tag")
|
||||
commit = match.group(1)
|
||||
return {"commit": commit, "image_digest": environ.get("HUX_IMAGE_DIGEST", "")}
|
||||
185
dockerfiles/hermes-hux-foundation/hux/foundation.py
Normal file
185
dockerfiles/hermes-hux-foundation/hux/foundation.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""HUX-11 routes: capabilities, manifest, and trusted context bootstrap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts, organization
|
||||
from hux.errors import Conflict, Forbidden, Invalid
|
||||
from hux.flags import CONTRACT_VERSION
|
||||
from hux.http import Request, Response, Router
|
||||
from hux.store import check_id, now_iso
|
||||
|
||||
CONTEXT_FAMILY = "context_bindings"
|
||||
CONTEXT_SCHEMA = "hux.context_bootstrap.v1"
|
||||
CONTEXT_KEY_BYTES = 32
|
||||
CONTEXT_MESSAGE = b"hux.context.id.v1"
|
||||
RAW_RE = re.compile(r"^[A-Za-z0-9._:/@+\-]{1,240}$")
|
||||
CONTEXT_ID_RE = re.compile(r"^(ses|conv|prj)_[0-9a-f]{32}$")
|
||||
SCHEMAS = contracts.load_all()
|
||||
|
||||
|
||||
def _context_key(environ: dict[str, str]) -> bytes:
|
||||
"""Read the exact 0600 regular key file; inline keys and links fail closed."""
|
||||
raw_path = environ.get("HUX_CONTEXT_KEY_FILE", "")
|
||||
if not raw_path:
|
||||
raise Forbidden("context identity key is unavailable")
|
||||
path = Path(raw_path)
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as error:
|
||||
raise Forbidden("context identity key is unavailable") from error
|
||||
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_uid != os.geteuid():
|
||||
raise Forbidden("context identity key is unsafe")
|
||||
try:
|
||||
value = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise Forbidden("context identity key is unavailable") from error
|
||||
if len(value) != CONTEXT_KEY_BYTES:
|
||||
raise Forbidden("context identity key is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _source(body: dict[str, Any], name: str) -> str:
|
||||
value = body.get(name)
|
||||
if not isinstance(value, str) or not RAW_RE.fullmatch(value):
|
||||
raise Invalid(f"{name} is malformed")
|
||||
return value
|
||||
|
||||
|
||||
def derive_context_id(key: bytes, purpose: str, identity: Any, raw: str) -> str:
|
||||
"""Derive one frozen HMAC-bound context id."""
|
||||
prefix = {"session": "ses", "conversation": "conv", "project": "prj"}[purpose]
|
||||
message = b"\0".join(
|
||||
(CONTEXT_MESSAGE, purpose.encode(), identity.tenant_slot.encode(), identity.subject.encode(), raw.encode())
|
||||
)
|
||||
return f"{prefix}_{hmac.new(key, message, hashlib.sha256).hexdigest()[:32]}"
|
||||
|
||||
|
||||
def _exact_body(request: Request) -> tuple[dict[str, Any], str, str]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
expected = {"raw_session_id", "project_source", "session_id", "conversation_id", "project_id"}
|
||||
if set(request.body) != expected:
|
||||
raise Invalid("context bootstrap fields are not exact")
|
||||
raw_session = _source(request.body, "raw_session_id")
|
||||
project_source = _source(request.body, "project_source")
|
||||
for name, prefix in (("session_id", "ses"), ("conversation_id", "conv"), ("project_id", "prj")):
|
||||
value = request.body.get(name)
|
||||
if not isinstance(value, str) or not CONTEXT_ID_RE.fullmatch(value) or not value.startswith(f"{prefix}_"):
|
||||
raise Invalid(f"{name} is malformed")
|
||||
check_id(value)
|
||||
return request.body, raw_session, project_source
|
||||
|
||||
|
||||
def _verify_record(record: dict[str, Any], schema: str, owner: str, project_id: str | None = None) -> None:
|
||||
if record.get("schema") != schema or record.get("owner") != owner:
|
||||
raise Conflict("context identity does not match existing record")
|
||||
if project_id is not None and record.get("project_id") != project_id:
|
||||
raise Conflict("context linkage does not match existing record")
|
||||
if contracts.validate_record(record, SCHEMAS):
|
||||
raise Conflict("existing context record is invalid")
|
||||
|
||||
|
||||
def _response(request: Request, project: dict[str, Any], conversation: dict[str, Any], created: dict[str, bool]) -> Response:
|
||||
body = {
|
||||
"schema": CONTEXT_SCHEMA,
|
||||
"contract_version": CONTRACT_VERSION,
|
||||
"identity": request.identity.record(),
|
||||
"session_id": request.body["session_id"],
|
||||
"conversation_id": conversation["id"],
|
||||
"project_id": project["id"],
|
||||
"created": created,
|
||||
"revisions": {"project": project["revision"], "conversation": conversation["revision"]},
|
||||
}
|
||||
return Response(201 if any(created.values()) else 200, body)
|
||||
|
||||
|
||||
def bootstrap_context(request: Request) -> Response:
|
||||
"""Create deterministic server-owned project and conversation records for trusted runtimes."""
|
||||
if request.identity.trust not in {"relay", "worker"}:
|
||||
raise Forbidden("context bootstrap requires relay or worker trust")
|
||||
body, raw_session, project_source = _exact_body(request)
|
||||
idem = request.idempotency_key()
|
||||
if not idem:
|
||||
raise Invalid("Idempotency-Key is required")
|
||||
key = _context_key(request.flags._environ)
|
||||
expected = {
|
||||
"session_id": derive_context_id(key, "session", request.identity, raw_session),
|
||||
"conversation_id": derive_context_id(key, "conversation", request.identity, raw_session),
|
||||
"project_id": derive_context_id(key, "project", request.identity, project_source),
|
||||
}
|
||||
if any(not hmac.compare_digest(body[name], value) for name, value in expected.items()):
|
||||
raise Invalid("context identifiers do not match authenticated inputs")
|
||||
with request.store.lock(CONTEXT_FAMILY):
|
||||
binding = request.store.get(CONTEXT_FAMILY, body["session_id"]) if request.store.exists(CONTEXT_FAMILY, body["session_id"]) else None
|
||||
if binding:
|
||||
valid_binding = (
|
||||
binding.get("schema") == "hux.context_binding.v1"
|
||||
and binding.get("id") == body["session_id"]
|
||||
and binding.get("owner") == request.identity.subject
|
||||
and all(binding.get(name) == body[name] for name in expected)
|
||||
)
|
||||
if not valid_binding:
|
||||
raise Conflict("context binding does not match existing linkage")
|
||||
for row in request.store.read(CONTEXT_FAMILY, "idempotency"):
|
||||
if row.get("key") == idem and any(row.get(name) != body[name] for name in expected):
|
||||
raise Conflict("Idempotency-Key was used for another context")
|
||||
project = request.store.get(organization.PROJECTS, body["project_id"]) if request.store.exists(organization.PROJECTS, body["project_id"]) else None
|
||||
conversation = request.store.get(organization.CONVERSATIONS, body["conversation_id"]) if request.store.exists(organization.CONVERSATIONS, body["conversation_id"]) else None
|
||||
if project:
|
||||
_verify_record(project, "hux.project.v1", request.identity.subject)
|
||||
if conversation:
|
||||
_verify_record(conversation, "hux.conversation.v1", request.identity.subject, body["project_id"])
|
||||
if project is None and request.store.count(organization.PROJECTS) >= organization.MAX_PROJECTS:
|
||||
raise Conflict("projects cap reached")
|
||||
if conversation is None and request.store.count(organization.CONVERSATIONS) >= organization.MAX_CONVERSATIONS:
|
||||
raise Conflict("conversations cap reached")
|
||||
stamp = now_iso()
|
||||
created = {"project": project is None, "conversation": conversation is None}
|
||||
project = project or request.store.put(organization.PROJECTS, organization.checked({
|
||||
"schema": "hux.project.v1", "id": body["project_id"], "owner": request.identity.subject,
|
||||
"name": "Hermes", "tags": [], "pinned": False, "archived": False, "default_mode": "fast",
|
||||
"created_at": stamp, "updated_at": stamp, "revision": 1,
|
||||
}))
|
||||
conversation = conversation or request.store.put(organization.CONVERSATIONS, organization.checked({
|
||||
"schema": "hux.conversation.v1", "id": body["conversation_id"], "owner": request.identity.subject,
|
||||
"project_id": body["project_id"], "title": "Hermes conversation", "tags": [], "pinned": False,
|
||||
"archived": False, "mode": "fast", "artifact_ids": [], "created_at": stamp, "updated_at": stamp,
|
||||
"revision": 1,
|
||||
}))
|
||||
if binding is None:
|
||||
request.store.put(CONTEXT_FAMILY, {
|
||||
"schema": "hux.context_binding.v1", "id": body["session_id"], "owner": request.identity.subject,
|
||||
"session_id": body["session_id"], "conversation_id": body["conversation_id"],
|
||||
"project_id": body["project_id"], "created_at": stamp,
|
||||
})
|
||||
if not any(row.get("key") == idem for row in request.store.read(CONTEXT_FAMILY, "idempotency")):
|
||||
request.store.append(CONTEXT_FAMILY, "idempotency", {"key": idem, **expected})
|
||||
request.audit("foundation.bootstrap", body["session_id"], reason="created" if any(created.values()) else "replayed")
|
||||
return _response(request, project, conversation, created)
|
||||
|
||||
|
||||
def capabilities(request: Request) -> Response:
|
||||
"""``GET /hux/v1/capabilities``: what this tenant may call."""
|
||||
request.audit("foundation.capabilities", "capabilities")
|
||||
return Response(200, request.flags.capabilities(request.identity, request.flags_build))
|
||||
|
||||
|
||||
def manifest(request: Request) -> Response:
|
||||
"""``GET /hux/v1/manifest``: the on-disk layout version for rollback readers."""
|
||||
request.audit("foundation.manifest", "manifest")
|
||||
return Response(200, request.store.manifest(CONTRACT_VERSION))
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-11 routes."""
|
||||
router.add("GET", "/hux/v1/capabilities", "HUX-11", "foundation.capabilities", capabilities)
|
||||
router.add("GET", "/hux/v1/manifest", "HUX-11", "foundation.manifest", manifest)
|
||||
router.add("POST", "/hux/v1/context/bootstrap", "HUX-11", "foundation.bootstrap", bootstrap_context, 4096)
|
||||
359
dockerfiles/hermes-hux-foundation/hux/http.py
Normal file
359
dockerfiles/hermes-hux-foundation/hux/http.py
Normal file
@ -0,0 +1,359 @@
|
||||
"""Minimal HTTP layer for the per-tenant HUX service.
|
||||
|
||||
Stdlib only. A ``Router`` maps method + path template to a handler; family
|
||||
modules register their routes with it. Every request resolves identity from
|
||||
trusted headers, checks the owning card's flag, and writes an audit outcome.
|
||||
Errors always leave as ``hux.error.v1``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from hux import audit
|
||||
from hux.errors import Forbidden, HuxError, Invalid, NotFound, RateLimited, 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
|
||||
|
||||
MAX_BODY_BYTES = 1024 * 1024
|
||||
MAX_QUERY_FIELDS = 64
|
||||
DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0
|
||||
DEFAULT_READS_PER_MINUTE = 300
|
||||
DEFAULT_WRITES_PER_MINUTE = 30
|
||||
MAX_RATE_BUCKETS = 4096
|
||||
Handler = Callable[["Request"], "Response"]
|
||||
|
||||
|
||||
def _bounded_number(environ: Mapping[str, str], name: str, default: float, low: float, high: float) -> float:
|
||||
"""Read one numeric setting, using its safe default when malformed or outside bounds."""
|
||||
try:
|
||||
value = float(environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return value if math.isfinite(value) and low <= value <= high else default
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Small fixed-window limiter bounded by active subjects in the current minute."""
|
||||
|
||||
def __init__(self, reads: int, writes: int, clock: Callable[[], float] = time.monotonic) -> None:
|
||||
self.limits = {"read": reads, "write": writes}
|
||||
self.clock = clock
|
||||
self._windows: dict[tuple[str, str], tuple[float, int]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def check(self, subject: str, method: str) -> int | None:
|
||||
"""Consume one request and return Retry-After seconds when its bucket is full."""
|
||||
now = self.clock()
|
||||
bucket = "read" if method in {"GET", "HEAD", "OPTIONS"} else "write"
|
||||
key = (subject, bucket)
|
||||
with self._lock:
|
||||
# Each accepted identity can leave at most two current entries;
|
||||
# expired identities disappear whenever any request arrives.
|
||||
self._windows = {item: value for item, value in self._windows.items() if value[0] > now}
|
||||
reset, used = self._windows.get(key, (now + 60.0, 0))
|
||||
if key not in self._windows and len(self._windows) >= MAX_RATE_BUCKETS:
|
||||
return 60
|
||||
if used >= self.limits[bucket]:
|
||||
return max(1, math.ceil(reset - now))
|
||||
self._windows[key] = (reset, used + 1)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Request:
|
||||
"""Everything a handler needs; no raw socket access."""
|
||||
|
||||
method: str
|
||||
path: str
|
||||
params: dict[str, str]
|
||||
query: dict[str, str]
|
||||
headers: Mapping[str, str]
|
||||
body: Any
|
||||
identity: Identity
|
||||
store: TenantStore
|
||||
flags: Flags
|
||||
flags_build: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def if_match(self) -> int | None:
|
||||
"""Parsed If-Match revision, or None when absent."""
|
||||
raw = self.header("If-Match")
|
||||
if raw == "":
|
||||
return None
|
||||
if not raw.isdigit():
|
||||
raise Invalid("If-Match must be a revision integer")
|
||||
return int(raw)
|
||||
|
||||
def idempotency_key(self) -> str:
|
||||
"""Client idempotency key, validated against the contract pattern."""
|
||||
raw = self.header("Idempotency-Key")
|
||||
if raw and not re.match(r"^[A-Za-z0-9._:-]{8,120}$", raw):
|
||||
raise Invalid("malformed Idempotency-Key")
|
||||
return raw
|
||||
|
||||
def header(self, name: str) -> str:
|
||||
"""Case-insensitive header lookup."""
|
||||
for key, value in self.headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
def audit(self, action: str, resource: str, outcome: str = "allow", reason: str = "") -> None:
|
||||
"""Write an audit outcome for this request."""
|
||||
audit.record(self.store, self.identity, action, resource, outcome, reason)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
"""JSON (or SSE) response."""
|
||||
|
||||
status: int = 200
|
||||
body: Any = None
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
stream: Callable[[], Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Route:
|
||||
"""One registered handler."""
|
||||
|
||||
method: str
|
||||
template: str
|
||||
card: str
|
||||
action: str
|
||||
handler: Handler
|
||||
max_body: int = MAX_BODY_BYTES
|
||||
pattern: re.Pattern = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
regex = re.sub(r"\{(\w+)\}", r"(?P<\1>[A-Za-z0-9._:-]+)", self.template)
|
||||
self.pattern = re.compile(f"^{regex}$")
|
||||
|
||||
|
||||
class Router:
|
||||
"""Route table plus the request pipeline."""
|
||||
|
||||
def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None:
|
||||
self.data_root = Path(data_root)
|
||||
self.environ = dict(os.environ if environ is None else environ)
|
||||
self.flags = Flags(self.environ)
|
||||
self.build = build_from_environ(self.environ)
|
||||
self.routes: list[Route] = []
|
||||
reads = int(_bounded_number(self.environ, "HUX_READS_PER_MINUTE", DEFAULT_READS_PER_MINUTE, 1, 10_000))
|
||||
writes = int(_bounded_number(self.environ, "HUX_WRITES_PER_MINUTE", DEFAULT_WRITES_PER_MINUTE, 1, 10_000))
|
||||
self.request_timeout = _bounded_number(
|
||||
self.environ, "HUX_REQUEST_TIMEOUT_SECONDS", DEFAULT_REQUEST_TIMEOUT_SECONDS, 0.1, 60.0
|
||||
)
|
||||
self.rate_limiter = RateLimiter(reads, writes)
|
||||
|
||||
def add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None:
|
||||
"""Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap."""
|
||||
self.routes.append(Route(method, template, card, action, handler, max_body))
|
||||
|
||||
def bind_capability_routes(self) -> None:
|
||||
"""Make feature negotiation depend on the route templates this process really registered."""
|
||||
routes: dict[str, set[str]] = {}
|
||||
for route in self.routes:
|
||||
routes.setdefault(route.card, set()).add(route.template)
|
||||
self.flags.bind_routes(routes)
|
||||
|
||||
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
|
||||
"""Return (route, params, path_known)."""
|
||||
known = False
|
||||
for route in self.routes:
|
||||
found = route.pattern.match(path)
|
||||
if found:
|
||||
known = True
|
||||
if route.method == method:
|
||||
return route, found.groupdict(), True
|
||||
return None, {}, known
|
||||
|
||||
def body_limit(self, method: str, raw_path: str) -> int:
|
||||
"""Maximum declared body for a matching route, before a socket read allocates it."""
|
||||
route, _, _ = self.match(method, urlsplit(raw_path).path)
|
||||
return route.max_body if route is not None else MAX_BODY_BYTES
|
||||
|
||||
def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response:
|
||||
"""Run the full pipeline and never raise."""
|
||||
parts = urlsplit(raw_path)
|
||||
route: Route | None = None
|
||||
try:
|
||||
identity = resolve(headers, self.environ)
|
||||
store = TenantStore(self.data_root, identity)
|
||||
except HuxError as error:
|
||||
return Response(error.status, error.record())
|
||||
try:
|
||||
retry_after = self.rate_limiter.check(identity.subject, method)
|
||||
if retry_after is not None:
|
||||
raise RateLimited(retry_after)
|
||||
route, params, known = self.match(method, parts.path)
|
||||
if route is None:
|
||||
error = Invalid("method not allowed") if known else NotFound("no such route")
|
||||
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
|
||||
return Response(405 if known else 404, error.record())
|
||||
# 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")
|
||||
if identity.trust == "evidence":
|
||||
allowed = {
|
||||
("GET", "/hux/v1/capabilities"),
|
||||
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"),
|
||||
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"),
|
||||
("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions"),
|
||||
}
|
||||
if (method, route.template) not in allowed:
|
||||
raise Forbidden("route is not available to evidence trust")
|
||||
self.flags.require(route.card)
|
||||
try:
|
||||
parsed_query = parse_qs(parts.query, max_num_fields=MAX_QUERY_FIELDS)
|
||||
except ValueError as error:
|
||||
raise Invalid("query has too many fields") from error
|
||||
query = {key: values[-1] for key, values in parsed_query.items()}
|
||||
payload = self._decode(body, route.max_body)
|
||||
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
|
||||
response = route.handler(request)
|
||||
except HuxError as error:
|
||||
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
|
||||
action = route.action if route is not None else "http.route"
|
||||
audit.record(store, identity, action, parts.path, outcome, error.message)
|
||||
headers = {"Retry-After": str(error.retry_after)} if isinstance(error, RateLimited) else {}
|
||||
return Response(error.status, error.record(), headers)
|
||||
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
|
||||
error = HuxError("internal error")
|
||||
action = route.action if route is not None else "http.route"
|
||||
audit.record(store, identity, action, parts.path, "deny", error.message)
|
||||
return Response(error.status, error.record())
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _decode(body: bytes, max_body: int = MAX_BODY_BYTES) -> Any:
|
||||
if not body:
|
||||
return None
|
||||
if len(body) > max_body:
|
||||
raise TooLarge(f"body exceeds {max_body} bytes")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as error:
|
||||
raise Invalid(f"body is not JSON: {error.msg}") from error
|
||||
|
||||
|
||||
def page(items: list[Any], next_cursor: Any = None) -> Response:
|
||||
"""Standard list envelope."""
|
||||
return Response(200, {"items": items, "next": next_cursor})
|
||||
|
||||
|
||||
def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
|
||||
"""Bind a Router to a BaseHTTPRequestHandler subclass."""
|
||||
|
||||
class HuxHandler(BaseHTTPRequestHandler):
|
||||
server_version = "hux-foundation/1.0"
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None:
|
||||
return
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
if self.headers.get("Transfer-Encoding"):
|
||||
raise Invalid("Transfer-Encoding is not supported")
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if len(lengths) > 1:
|
||||
raise Invalid("duplicate Content-Length is not supported")
|
||||
raw_length = lengths[0] if lengths else "0"
|
||||
if not raw_length.isascii() or not raw_length.isdigit():
|
||||
raise Invalid("Content-Length must be a non-negative integer")
|
||||
if len(raw_length) > 10:
|
||||
raise TooLarge("declared body length is too large")
|
||||
length = int(raw_length)
|
||||
limit = 0 if self.path == "/healthz" else router.body_limit(self.command, self.path)
|
||||
if length > limit:
|
||||
raise TooLarge(f"body exceeds {limit} bytes")
|
||||
body = self.rfile.read(length) if length else b""
|
||||
if len(body) != length:
|
||||
raise Invalid("request body is incomplete or timed out")
|
||||
if self.path == "/healthz":
|
||||
body = {"status": "ok", "contract_version": CONTRACT_VERSION}
|
||||
scheduler = getattr(router, "retention_scheduler", None)
|
||||
if scheduler is not None:
|
||||
body["retention"] = scheduler.health()
|
||||
self._send(Response(200, body))
|
||||
return
|
||||
self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body))
|
||||
except HuxError as error:
|
||||
self._send(Response(error.status, error.record()))
|
||||
except (OSError, TimeoutError):
|
||||
error = Invalid("request body is incomplete or timed out")
|
||||
self._send(Response(error.status, error.record()))
|
||||
|
||||
def _send(self, response: Response) -> None:
|
||||
if response.stream is not None:
|
||||
self.send_response(response.status)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
for key, value in response.headers.items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
for chunk in response.stream():
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
return
|
||||
data = json.dumps(response.body, sort_keys=True).encode()
|
||||
self.send_response(response.status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
for key, value in response.headers.items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
|
||||
|
||||
return HuxHandler
|
||||
|
||||
|
||||
class BoundedHTTPServer(ThreadingHTTPServer):
|
||||
"""Threading server that bounds header and body socket reads from accept onward."""
|
||||
|
||||
def __init__(self, address: tuple[str, int], handler: type[BaseHTTPRequestHandler], timeout: float, scheduler: Any = None) -> None:
|
||||
self.request_timeout = timeout
|
||||
self.scheduler = scheduler
|
||||
super().__init__(address, handler)
|
||||
|
||||
def get_request(self) -> tuple[Any, Any]:
|
||||
"""Accept one connection and apply its per-request socket deadline."""
|
||||
request, address = super().get_request()
|
||||
request.settimeout(self.request_timeout)
|
||||
return request, address
|
||||
|
||||
def server_close(self) -> None:
|
||||
"""Stop background work before closing the listening socket."""
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.stop()
|
||||
super().server_close()
|
||||
|
||||
|
||||
def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer:
|
||||
"""Create (but do not start) the server; callers call serve_forever()."""
|
||||
from hux.retention_scheduler import RetentionScheduler
|
||||
|
||||
scheduler = RetentionScheduler(router)
|
||||
router.retention_scheduler = scheduler
|
||||
server = BoundedHTTPServer((host, port), make_handler(router), router.request_timeout, scheduler)
|
||||
server.daemon_threads = True
|
||||
scheduler.start()
|
||||
return server
|
||||
196
dockerfiles/hermes-hux-foundation/hux/identity.py
Normal file
196
dockerfiles/hermes-hux-foundation/hux/identity.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""Caller identity resolved from the trusted hop's headers.
|
||||
|
||||
The chat router, the Telegram relay and the Worker are the only callers. Each
|
||||
asserts identity in headers; the request body is never trusted for identity.
|
||||
Router, relay, and worker callers must each present their distinct shared key,
|
||||
compared in constant time against an inline or projected secret value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from hux.errors import Unauthorized
|
||||
|
||||
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"
|
||||
|
||||
SLOT_RE = re.compile(r"^slot-[0-9]{1,3}$")
|
||||
SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$")
|
||||
SURFACES = ("chat", "worker", "telegram", "voice", "api")
|
||||
TRUSTS = ("router", "relay", "worker", "evidence")
|
||||
KEY_ENV = {
|
||||
"router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY",
|
||||
"worker": "HUX_WORKER_KEY", "evidence": "HUX_RELEASE_EVIDENCE_KEY",
|
||||
}
|
||||
MAX_KEY_BYTES = 4096
|
||||
MAX_SUBJECT_BYTES = 128
|
||||
SUBJECT_BINDING_ENV = "HUX_SUBJECT_BINDING_FILE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
"""Who is calling, on which surface, asserted by which trusted hop."""
|
||||
|
||||
tenant_slot: str
|
||||
subject: str
|
||||
surface: str
|
||||
trust: str
|
||||
|
||||
def record(self) -> dict[str, str]:
|
||||
"""Serialise as ``common.identity``."""
|
||||
return {"tenant_slot": self.tenant_slot, "subject": self.subject, "surface": self.surface, "trust": self.trust}
|
||||
|
||||
|
||||
def _header(headers: Mapping[str, str], name: str) -> str:
|
||||
for key, value in headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _expected_key(environ: Mapping[str, str], trust: str) -> str:
|
||||
"""Read a caller key, preferring a bounded Kubernetes secret file."""
|
||||
if trust == "evidence":
|
||||
from hux.release_security import evidence_key
|
||||
|
||||
try:
|
||||
return evidence_key(environ)
|
||||
except Exception: # noqa: BLE001 - authentication fails closed without revealing configuration detail
|
||||
return ""
|
||||
name = KEY_ENV[trust]
|
||||
key_file = environ.get(f"{name}_FILE", "")
|
||||
if key_file:
|
||||
try:
|
||||
path = Path(key_file)
|
||||
mode = path.stat().st_mode
|
||||
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400:
|
||||
return ""
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return ""
|
||||
if len(data) > MAX_KEY_BYTES:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8", errors="strict").strip()
|
||||
except UnicodeDecodeError:
|
||||
return ""
|
||||
return environ.get(name, "")
|
||||
|
||||
|
||||
def _read_subject_binding(path: Path) -> str | None:
|
||||
"""Read a complete, permission-constrained binding without following links."""
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise Unauthorized("subject binding is unavailable") from error
|
||||
try:
|
||||
mode = os.fstat(descriptor).st_mode
|
||||
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}:
|
||||
raise Unauthorized("subject binding is invalid")
|
||||
with os.fdopen(descriptor, "rb", closefd=False) as binding:
|
||||
data = binding.read(MAX_SUBJECT_BYTES + 1)
|
||||
except OSError as error:
|
||||
raise Unauthorized("subject binding is unavailable") from error
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if not data or len(data) > MAX_SUBJECT_BYTES:
|
||||
raise Unauthorized("subject binding is invalid")
|
||||
try:
|
||||
value = data.decode("utf-8", errors="strict").strip()
|
||||
except UnicodeDecodeError as error:
|
||||
raise Unauthorized("subject binding is invalid") from error
|
||||
if not SUBJECT_RE.fullmatch(value):
|
||||
raise Unauthorized("subject binding is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _publish_subject_binding(path: Path, subject: str) -> None:
|
||||
"""Publish an immutable first-writer binding; concurrent writers never see partial data."""
|
||||
descriptor = -1
|
||||
temporary = ""
|
||||
try:
|
||||
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
os.fchmod(descriptor, 0o440)
|
||||
with os.fdopen(descriptor, "wb", closefd=False) as binding:
|
||||
binding.write((subject + "\n").encode("utf-8"))
|
||||
binding.flush()
|
||||
os.fsync(descriptor)
|
||||
os.close(descriptor)
|
||||
descriptor = -1
|
||||
try:
|
||||
os.link(temporary, path, follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except OSError as error:
|
||||
raise Unauthorized("subject binding is unavailable") from error
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
if temporary:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
# The binding decision is still verified by a fresh read below.
|
||||
pass
|
||||
|
||||
|
||||
def _enforce_subject_binding(environ: Mapping[str, str], trust: str, subject: str) -> None:
|
||||
"""Bind from an authenticated edge hop, then require every caller to match."""
|
||||
raw_path = environ.get(SUBJECT_BINDING_ENV, "")
|
||||
if not raw_path:
|
||||
return
|
||||
path = Path(raw_path)
|
||||
bound = _read_subject_binding(path)
|
||||
if bound is None:
|
||||
if trust == "worker":
|
||||
raise Unauthorized("worker subject is not bound")
|
||||
_publish_subject_binding(path, subject)
|
||||
bound = _read_subject_binding(path)
|
||||
if bound is None or not hmac.compare_digest(bound, subject):
|
||||
raise Unauthorized("subject does not match trusted binding")
|
||||
def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity:
|
||||
"""Build an Identity from request headers or raise Unauthorized.
|
||||
|
||||
``trust`` defaults to ``router``; every trusted hop requires its distinct
|
||||
shared key to be configured and presented.
|
||||
"""
|
||||
environ = os.environ if environ is None else environ
|
||||
slot = _header(headers, HEADER_SLOT)
|
||||
subject = _header(headers, HEADER_SUBJECT)
|
||||
surface = _header(headers, HEADER_SURFACE) or "chat"
|
||||
trust = _header(headers, HEADER_TRUST) or "router"
|
||||
if not SLOT_RE.match(slot):
|
||||
raise Unauthorized("missing or malformed tenant slot")
|
||||
own_slot = environ.get("HUX_TENANT_SLOT", "")
|
||||
if own_slot and slot != own_slot:
|
||||
raise Unauthorized("tenant slot does not belong to this pod")
|
||||
if not SUBJECT_RE.match(subject):
|
||||
raise Unauthorized("missing or malformed subject")
|
||||
if surface not in SURFACES or trust not in TRUSTS:
|
||||
raise Unauthorized("unknown surface or trust")
|
||||
expected = _expected_key(environ, trust)
|
||||
presented = _header(headers, HEADER_KEY)
|
||||
if not expected or not presented or not hmac.compare_digest(expected, presented):
|
||||
raise Unauthorized(f"{trust} key missing or wrong")
|
||||
if trust == "router" and surface == "worker":
|
||||
raise Unauthorized("worker surface needs worker trust")
|
||||
if trust == "evidence" and surface != "api":
|
||||
raise Unauthorized("evidence trust needs api surface")
|
||||
_enforce_subject_binding(environ, trust, subject)
|
||||
return Identity(slot, subject, surface, trust)
|
||||
459
dockerfiles/hermes-hux-foundation/hux/memory.py
Normal file
459
dockerfiles/hermes-hux-foundation/hux/memory.py
Normal file
@ -0,0 +1,459 @@
|
||||
"""HUX-02 memory control: an append-only ledger with explicit user consent.
|
||||
|
||||
Every entry is a ``hux.memory.v1`` snapshot. The newest snapshot per id is
|
||||
kept as a revisioned document (so If-Match works) and every snapshot is also
|
||||
appended to ``ledger.jsonl``. Status moves only along
|
||||
``rules.MEMORY_TRANSITIONS``; ``no_store`` and ``forgotten`` write a
|
||||
content-free line plus a tombstone before anything is returned (SO-22), and
|
||||
``retrieve`` consults the tombstones before the documents (SO-23).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts, redaction, rules
|
||||
from hux.errors import Conflict, Forbidden, Invalid, NotFound
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, new_id, now_iso
|
||||
|
||||
FAMILY = "memory"
|
||||
IDEM_FAMILY = "memory_idem"
|
||||
LEDGER = "ledger"
|
||||
TOMBSTONES = "tombstones"
|
||||
DEFAULT_CONVERSATION = "conv_memory"
|
||||
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
|
||||
ACTIONS = ("approve", "reject", "forget", "edit", "remove_retrieval", "restore_retrieval")
|
||||
KINDS = ("preference", "fact", "instruction", "context")
|
||||
DEFAULT_DECAY_DAYS = 180
|
||||
SCHEMAS = contracts.load_all()
|
||||
|
||||
|
||||
def _parse(stamp: str) -> datetime:
|
||||
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _emit(store: TenantStore, identity: Identity, record: dict[str, Any], kind: str, summary: str) -> None:
|
||||
from hux import events
|
||||
|
||||
detail = {"memory_id": record["id"], "kind": record["kind"], "sensitivity": record["sensitivity"], "topic": record.get("topic", "general")}
|
||||
conversation_id = record["provenance"].get("conversation_id", DEFAULT_CONVERSATION)
|
||||
events.emit(store, identity, conversation_id, kind, summary, detail, [{"kind": "memory", "id": record["id"]}], sensitivity=record["sensitivity"])
|
||||
|
||||
|
||||
def tombstoned(store: TenantStore) -> set[str]:
|
||||
"""Ids that must never surface again, whatever the documents say."""
|
||||
return {row["memory_id"] for row in store.read(FAMILY, TOMBSTONES)}
|
||||
|
||||
|
||||
def _tombstone(store: TenantStore, memory_id: str, reason: str) -> None:
|
||||
store.append(FAMILY, TOMBSTONES, {"memory_id": memory_id, "at": now_iso(), "reason": reason, "purged": False})
|
||||
|
||||
|
||||
def effective_expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""When the entry stops being active: ``expires_at``, or decay from the last approval (or creation)."""
|
||||
ttl = record["ttl"]
|
||||
if ttl["policy"] == "expires_at":
|
||||
return _parse(ttl["expires_at"])
|
||||
if ttl["policy"] == "decay":
|
||||
approvals = [row["at"] for row in record["audit"] if row["action"] == "approved"]
|
||||
return _parse(approvals[-1] if approvals else record["created_at"]) + timedelta(days=ttl["decay_days"])
|
||||
return None
|
||||
|
||||
|
||||
def _persist(store: TenantStore, record: dict[str, Any], expected: int | None) -> dict[str, Any]:
|
||||
"""Validate against contract and rules, then write document + ledger line under the family lock."""
|
||||
problems = rules.memory_policy_violations(record) + contracts.validate_record({**record, "revision": record.get("revision") or 1}, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("memory entry violates policy", problems)
|
||||
with store.lock(FAMILY):
|
||||
stored = store.put(FAMILY, record, expected)
|
||||
store.append(FAMILY, LEDGER, stored)
|
||||
return stored
|
||||
|
||||
|
||||
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]:
|
||||
"""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)
|
||||
if record["status"] == "active":
|
||||
expiry = effective_expiry(record)
|
||||
if expiry is not None and expiry <= (now or datetime.now(timezone.utc)):
|
||||
record = _transition(store, None, record, "expired", "expired", {"type": "system", "id": "retention"}, expected=record["revision"])
|
||||
return record
|
||||
|
||||
|
||||
def expire_due(store: TenantStore, now: datetime) -> int:
|
||||
"""Retention pass: move every active entry past its expiry to expired. Returns the count."""
|
||||
before = {row["id"]: row["status"] for row in store.scan(FAMILY)}
|
||||
return sum(1 for memory_id, status in before.items() if status == "active" and load(store, memory_id, now)["status"] == "expired")
|
||||
|
||||
|
||||
def purge_forgotten(store: TenantStore) -> int:
|
||||
"""Retention pass (SO-47): blank ``content`` on every ledger snapshot of tombstoned ids, then mark them purged."""
|
||||
with store.lock(FAMILY):
|
||||
stones = store.read(FAMILY, TOMBSTONES)
|
||||
pending = {row["memory_id"] for row in stones if not row.get("purged")}
|
||||
if not pending:
|
||||
return 0
|
||||
rows = store.read(FAMILY, LEDGER)
|
||||
store.rewrite(FAMILY, LEDGER, [{**row, "content": ""} if row.get("id") in pending else row for row in rows])
|
||||
store.rewrite(FAMILY, TOMBSTONES, [{**row, "purged": True} for row in stones])
|
||||
return len(pending)
|
||||
|
||||
|
||||
def forget_from_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> list[str]:
|
||||
"""Forget every entry sourced from ``conversation_id`` (used by the privacy forget); returns the ids."""
|
||||
from hux import events
|
||||
|
||||
forgotten: list[str] = []
|
||||
for row in list(store.scan(FAMILY)):
|
||||
if row["provenance"].get("conversation_id") != conversation_id:
|
||||
continue
|
||||
if row["status"] in {"proposed"}:
|
||||
row = _transition(store, identity, row, "rejected", "rejected", {"type": "system", "id": "forget"}, "conversation forgotten")
|
||||
if row["status"] in {"active", "expired"}:
|
||||
row = _transition(store, identity, row, "forgotten", "forgotten", {"type": "system", "id": "forget"}, "conversation forgotten")
|
||||
if row["status"] not in {"forgotten", "no_store"}:
|
||||
_tombstone(store, row["id"], "conversation forgotten")
|
||||
forgotten.append(row["id"])
|
||||
events.redact_memory_references(store, row["id"])
|
||||
return forgotten
|
||||
|
||||
|
||||
def retrieve(store: TenantStore, query_terms: list[str], scope: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
"""Agent read hook (SO-23): tombstones first, then only active + retrievable entries in scope; never no_store, forgotten or expired."""
|
||||
from hux import privacy
|
||||
|
||||
stones = tombstoned(store)
|
||||
blocked = privacy.blocked_conversations(store)
|
||||
if scope and scope.get("level") == "conversation" and scope.get("scope_id") in blocked:
|
||||
return []
|
||||
terms = [term.lower() for term in query_terms if term]
|
||||
hits: list[dict[str, Any]] = []
|
||||
for row in store.scan(FAMILY):
|
||||
if row["id"] in stones or row["provenance"].get("conversation_id") in blocked:
|
||||
continue
|
||||
record = load(store, row["id"])
|
||||
if record["status"] != "active" or not record["retrievable"]:
|
||||
continue
|
||||
if scope and record["scope"]["level"] != "global" and record["scope"] != scope:
|
||||
continue
|
||||
text = record["content"].lower()
|
||||
if terms and not any(term in text for term in terms):
|
||||
continue
|
||||
hits.append(record)
|
||||
return sorted(hits, key=lambda r: r["updated_at"], reverse=True)
|
||||
|
||||
|
||||
# -- proposals ------------------------------------------------------------------
|
||||
|
||||
def _human(identity: Identity) -> bool:
|
||||
return identity.surface in HUMAN_SURFACES and identity.trust != "worker"
|
||||
|
||||
|
||||
def _actor(request: Request) -> dict[str, str]:
|
||||
if _human(request.identity) and request.body.get("proposed_by", "assistant") == "user":
|
||||
return {"type": "user", "id": request.identity.subject}
|
||||
return {"type": "assistant", "id": "hermes"}
|
||||
|
||||
|
||||
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
|
||||
|
||||
hits: list[str] = []
|
||||
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")
|
||||
sensitivity = body.get("sensitivity", "personal")
|
||||
if sensitivity not in privacy.SENSITIVITY_RANK:
|
||||
raise Invalid("unknown sensitivity")
|
||||
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
|
||||
if ttl is None or (sensitivity == "sensitive" and ttl.get("policy") == "never"):
|
||||
days = rules.PRIVACY_TOPICS[topic]["decay_days"] if topic in rules.PRIVACY_TOPICS else DEFAULT_DECAY_DAYS
|
||||
ttl = {"policy": "decay", "decay_days": days}
|
||||
actor = _actor(request)
|
||||
stamp = now_iso()
|
||||
provenance = {"surface": request.identity.surface, "actor": actor, "recorded_at": stamp}
|
||||
if conversation_id:
|
||||
provenance["conversation_id"] = conversation_id
|
||||
if isinstance(body.get("run_id"), 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": 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],
|
||||
"retrievable": False, "identity": request.identity.record(),
|
||||
}
|
||||
if isinstance(body.get("supersedes"), str):
|
||||
entry["supersedes"] = body["supersedes"]
|
||||
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")
|
||||
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:
|
||||
entry.update(status="active", approval_mode="automatic", retrievable=True)
|
||||
entry["audit"].append({"at": stamp, "action": "approved", "actor": actor, "note": "automatic"})
|
||||
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).
|
||||
|
||||
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()
|
||||
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})")
|
||||
request.audit("memory.propose", stored["id"], "allow", "no_store")
|
||||
return Response(202, stored)
|
||||
kind = "memory.committed" if stored["status"] == "active" else "memory.proposed"
|
||||
_emit(request.store, request.identity, stored, kind, "Remembered" if stored["status"] == "active" else "Proposed to remember")
|
||||
request.audit("memory.propose", stored["id"])
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
def list_memory(request: Request) -> Response:
|
||||
"""``GET /hux/v1/memory?status=&scope=``: owner-scoped list; ``scope`` is ``level`` or ``level:scope_id``."""
|
||||
status, scope = request.query.get("status"), request.query.get("scope")
|
||||
level, _, scope_id = (scope or "").partition(":")
|
||||
items = []
|
||||
for row in list(request.store.scan(FAMILY)):
|
||||
record = load(request.store, row["id"])
|
||||
if status and record["status"] != status:
|
||||
continue
|
||||
if level and (record["scope"]["level"] != level or (scope_id and record["scope"].get("scope_id") != scope_id)):
|
||||
continue
|
||||
items.append(record)
|
||||
request.audit("memory.list", "memory")
|
||||
return page(sorted(items, key=lambda r: r["updated_at"], reverse=True), None)
|
||||
|
||||
|
||||
def get_memory(request: Request) -> Response:
|
||||
"""``GET /hux/v1/memory/{id}``."""
|
||||
memory_id = request.params["id"]
|
||||
try:
|
||||
record = load(request.store, memory_id)
|
||||
except (NotFound, Invalid) as error:
|
||||
raise NotFound("memory entry not found") from error
|
||||
request.audit("memory.get", memory_id)
|
||||
return Response(200, record, {"ETag": str(record["revision"])})
|
||||
|
||||
|
||||
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() 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"])
|
||||
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)
|
||||
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"]
|
||||
if action not in ACTIONS:
|
||||
raise NotFound("unknown memory action")
|
||||
if not _human(request.identity):
|
||||
raise Forbidden("memory decisions come from a human surface")
|
||||
try:
|
||||
record = load(request.store, memory_id)
|
||||
except (NotFound, Invalid) as error:
|
||||
raise NotFound("memory entry not found") from error
|
||||
expected = request.if_match()
|
||||
if expected is not None and expected != record["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {record['revision']}", [str(record["revision"])])
|
||||
actor = {"type": "user", "id": request.identity.subject}
|
||||
store, identity = request.store, request.identity
|
||||
if action == "approve":
|
||||
stored = _transition(store, identity, record, "active", "approved", actor, expected=expected)
|
||||
_emit(store, identity, stored, "memory.committed", "Memory approved")
|
||||
elif action == "reject":
|
||||
stored = _transition(store, identity, record, "rejected", "rejected", actor, expected=expected)
|
||||
_emit(store, identity, stored, "memory.suppressed", "Memory rejected")
|
||||
elif action == "forget":
|
||||
stored = _transition(store, identity, record, "forgotten", "forgotten", actor, expected=expected)
|
||||
from hux import events
|
||||
|
||||
events.redact_memory_references(store, stored["id"])
|
||||
_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:
|
||||
retrievable = action == "restore_retrieval"
|
||||
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"])})
|
||||
|
||||
|
||||
def export_memory(request: Request) -> Response:
|
||||
"""``GET /hux/v1/memory/export``: active, retrievable entries only, each stamped ``exported`` (SO-26)."""
|
||||
stones = tombstoned(request.store)
|
||||
items = []
|
||||
for row in list(request.store.scan(FAMILY)):
|
||||
record = load(request.store, row["id"])
|
||||
if record["id"] in stones or record["status"] != "active" or not record["retrievable"]:
|
||||
continue
|
||||
stamp = now_iso()
|
||||
items.append(_persist(request.store, {**record, "audit": [*record["audit"], {"at": stamp, "action": "exported", "actor": {"type": "user", "id": request.identity.subject}}]}, record["revision"]))
|
||||
request.store.append(FAMILY, "exports", {"at": now_iso(), "ids": [item["id"] for item in items]})
|
||||
request.audit("memory.export", "memory")
|
||||
response = page(items, None)
|
||||
response.headers["Content-Disposition"] = 'attachment; filename="hux-memory-export.json"'
|
||||
return response
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-02 routes."""
|
||||
router.add("GET", "/hux/v1/memory/export", "HUX-02", "memory.export", export_memory)
|
||||
router.add("POST", "/hux/v1/memory", "HUX-02", "memory.propose", post_memory)
|
||||
router.add("GET", "/hux/v1/memory", "HUX-02", "memory.list", list_memory)
|
||||
router.add("GET", "/hux/v1/memory/{id}", "HUX-02", "memory.get", get_memory)
|
||||
router.add("POST", "/hux/v1/memory/{id}/{action}", "HUX-02", "memory.act", act_memory)
|
||||
169
dockerfiles/hermes-hux-foundation/hux/modes.py
Normal file
169
dockerfiles/hermes-hux-foundation/hux/modes.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""HUX-06 provider-neutral friendly modes and scoped route selection.
|
||||
|
||||
Automatic modes express intent to Switchyard and never name a provider. An
|
||||
exact route can only be selected through the explicit advanced path and must
|
||||
exist in the operator-provided route catalog. Private mode is local-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts, rules
|
||||
from hux.errors import Conflict, Invalid, NotFound
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.store import now_iso
|
||||
|
||||
CARD = "HUX-06"
|
||||
FAMILY = "mode_selections"
|
||||
MODE_NAMES = ("fast", "thoughtful", "research", "create", "private")
|
||||
MANUAL_ROUTE = re.compile(r"^atlas/manual/(codex|claude|local)/[a-z0-9][a-z0-9/-]{0,100}$")
|
||||
MAX_CATALOG_ROUTES = 256
|
||||
SCHEMAS = contracts.load_all()
|
||||
|
||||
|
||||
def _body(request: Request) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
extra = set(request.body) - {"project_id", "mode", "advanced", "override_route_id"}
|
||||
if extra:
|
||||
raise Invalid("unexpected mode fields", sorted(extra))
|
||||
return request.body
|
||||
|
||||
|
||||
def _scope(request: Request, project_id: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Resolve a conversation and its mandatory owning project."""
|
||||
from hux import organization
|
||||
|
||||
try:
|
||||
conversation = request.store.get(organization.CONVERSATIONS, request.params["id"])
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("conversation not found") from error
|
||||
path_project = request.params.get("project_id")
|
||||
actual = conversation.get("project_id")
|
||||
if not isinstance(project_id, str) or project_id != path_project or not actual or project_id != actual:
|
||||
raise NotFound("project or conversation not found")
|
||||
try:
|
||||
project = request.store.get(organization.PROJECTS, project_id)
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("project or conversation not found") from error
|
||||
return project, conversation
|
||||
|
||||
|
||||
def _record_id(conversation_id: str) -> str:
|
||||
digest = hashlib.sha256(conversation_id.encode()).hexdigest()[:24]
|
||||
return f"mode_{digest}"
|
||||
|
||||
|
||||
def _catalog() -> set[str]:
|
||||
raw = os.environ.get("HUX_SWITCHYARD_ROUTE_CATALOG", "")
|
||||
items = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
if len(items) > MAX_CATALOG_ROUTES:
|
||||
return set()
|
||||
return {item for item in items if MANUAL_ROUTE.fullmatch(item)}
|
||||
|
||||
|
||||
def _contract(mode: str, advanced: bool, override: Any) -> dict[str, Any]:
|
||||
if not isinstance(mode, str) or mode not in MODE_NAMES:
|
||||
raise Invalid("unknown friendly mode")
|
||||
if override is not None:
|
||||
if not advanced or not isinstance(override, str) or not MANUAL_ROUTE.fullmatch(override):
|
||||
raise Invalid("an exact route requires a valid advanced manual route")
|
||||
if override not in _catalog():
|
||||
raise Invalid("advanced route is not in the Switchyard catalog")
|
||||
if mode == "private" and not override.startswith("atlas/manual/local/"):
|
||||
raise Invalid("private mode is local-only")
|
||||
elif advanced:
|
||||
raise Invalid("advanced selection requires override_route_id")
|
||||
try:
|
||||
result = rules.mode_contract(mode, override)
|
||||
except ValueError as error:
|
||||
raise Invalid(str(error)) from error
|
||||
problems = contracts.validate("mode.schema.json", result, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("mode failed contract validation", problems)
|
||||
if mode != "private" and result["switchyard"]["route_id"].startswith("atlas/manual/"):
|
||||
raise Invalid("automatic modes may not pin a provider")
|
||||
return result
|
||||
|
||||
|
||||
def _fingerprint(body: dict[str, Any]) -> str:
|
||||
allowed = {key: body.get(key) for key in ("project_id", "mode", "advanced", "override_route_id")}
|
||||
return hashlib.sha256(json.dumps(allowed, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
|
||||
for row in request.store.read(FAMILY, "idempotency"):
|
||||
if row.get("key") != key:
|
||||
continue
|
||||
if row.get("fingerprint") != fingerprint:
|
||||
raise Conflict("Idempotency-Key was already used for a different selection")
|
||||
return request.store.get(FAMILY, row["id"])
|
||||
return None
|
||||
|
||||
|
||||
def list_modes(request: Request) -> Response:
|
||||
"""Return all provider-neutral mode contracts."""
|
||||
items = [_contract(name, False, None) for name in MODE_NAMES]
|
||||
request.audit("modes.list", "modes")
|
||||
return page(items)
|
||||
|
||||
|
||||
def get_selection(request: Request) -> Response:
|
||||
"""Return the selected mode for one bound conversation."""
|
||||
project_id = request.params["project_id"]
|
||||
_, conversation = _scope(request, project_id)
|
||||
record = request.store.get(FAMILY, _record_id(conversation["id"]))
|
||||
request.audit("modes.read", record["id"])
|
||||
return Response(200, record, {"ETag": str(record["revision"]), "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def put_selection(request: Request) -> Response:
|
||||
"""Select a mode using If-Match and Idempotency-Key."""
|
||||
body = _body(request)
|
||||
_, conversation = _scope(request, body.get("project_id"))
|
||||
key = request.idempotency_key()
|
||||
if not key:
|
||||
raise Invalid("Idempotency-Key is required")
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required; use 0 for the first selection")
|
||||
fingerprint = _fingerprint(body)
|
||||
mode = _contract(body.get("mode"), body.get("advanced") is True, body.get("override_route_id"))
|
||||
record_id = _record_id(conversation["id"])
|
||||
with request.store.lock(FAMILY):
|
||||
replayed = _replay(request, key, fingerprint)
|
||||
if replayed is not None:
|
||||
request.audit("modes.select", replayed["id"], reason="idempotent_replay")
|
||||
return Response(200, replayed, {"ETag": str(replayed["revision"]), "HUX-Replayed": "true", "Cache-Control": "no-store"})
|
||||
exists = request.store.exists(FAMILY, record_id)
|
||||
if expected != (request.store.get(FAMILY, record_id)["revision"] if exists else 0):
|
||||
raise Conflict("mode selection revision does not match If-Match")
|
||||
stamp = now_iso()
|
||||
record = {
|
||||
"id": record_id,
|
||||
"schema": "hux.mode_selection.v1",
|
||||
"owner": request.identity.subject,
|
||||
"project_id": body["project_id"],
|
||||
"conversation_id": conversation["id"],
|
||||
"mode": mode,
|
||||
"updated_at": stamp,
|
||||
}
|
||||
stored = request.store.put(FAMILY, record, expected_revision=expected)
|
||||
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "id": record_id, "at": stamp})
|
||||
with request.store.lock("conversations"):
|
||||
current = request.store.get("conversations", conversation["id"])
|
||||
request.store.put("conversations", {**current, "mode": mode["mode"], "updated_at": stamp}, current["revision"])
|
||||
request.audit("modes.select", record_id)
|
||||
return Response(200, stored, {"ETag": str(stored["revision"]), "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-06 routes."""
|
||||
router.add("GET", "/hux/v1/modes", CARD, "modes.list", list_modes)
|
||||
router.add("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode", CARD, "modes.read", get_selection)
|
||||
router.add("PUT", "/hux/v1/projects/{project_id}/conversations/{id}/mode", CARD, "modes.select", put_selection)
|
||||
276
dockerfiles/hermes-hux-foundation/hux/multimodal.py
Normal file
276
dockerfiles/hermes-hux-foundation/hux/multimodal.py
Normal file
@ -0,0 +1,276 @@
|
||||
"""HUX-07 scoped multimodal metadata, lineage and transcript corrections.
|
||||
|
||||
This service never accepts media bytes and has no capture endpoint. It only
|
||||
records metadata after an approved autonomy action, plus inert camera/screen
|
||||
intents that a human-facing client may send through the HUX-05 approval lane.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts, redaction
|
||||
from hux.errors import Conflict, Forbidden, Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.store import check_id, new_id, now_iso
|
||||
|
||||
CARD = "HUX-07"
|
||||
ITEMS = "multimodal_items"
|
||||
CORRECTIONS = "transcript_corrections"
|
||||
INTENTS = "capture_intents"
|
||||
MAX_ITEMS = 2000
|
||||
MAX_CORRECTIONS = 10000
|
||||
MAX_INTENTS = 1000
|
||||
EXECUTABLE_MIMES = frozenset({"text/html", "application/xhtml+xml", "image/svg+xml", "application/xml", "text/xml"})
|
||||
SAFE_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp", ".wav", ".webm", ".ogg", ".mp4", ".pdf", ".txt")
|
||||
KIND_MIMES = {
|
||||
"image": frozenset({"image/jpeg", "image/png", "image/webp"}),
|
||||
"audio": frozenset({"audio/wav", "audio/webm", "audio/ogg"}),
|
||||
"video": frozenset({"video/webm", "video/mp4"}),
|
||||
"document": frozenset({"application/pdf", "text/plain"}),
|
||||
}
|
||||
ITEM_FIELDS = frozenset({"project_id", "kind", "source", "filename", "mime", "bytes", "hash", "approval_id", "lineage"})
|
||||
SCHEMAS = contracts.load_all()
|
||||
SCHEMAS["multimodal.schema.json"] = contracts.load_schema("multimodal.schema.json")
|
||||
|
||||
|
||||
def _body(request: Request, allowed: frozenset[str]) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
extra = set(request.body) - allowed
|
||||
if extra:
|
||||
raise Invalid("unexpected multimodal fields", sorted(extra))
|
||||
return request.body
|
||||
|
||||
|
||||
def _scope(request: Request, project_id: Any) -> dict[str, Any]:
|
||||
from hux import organization
|
||||
|
||||
try:
|
||||
conversation = request.store.get(organization.CONVERSATIONS, request.params["id"])
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("project or conversation not found") from error
|
||||
path_project = request.params.get("project_id")
|
||||
actual = conversation.get("project_id")
|
||||
if not isinstance(project_id, str) or project_id != path_project or not actual or project_id != actual:
|
||||
raise NotFound("project or conversation not found")
|
||||
if not organization.project_exists(request.store, project_id):
|
||||
raise NotFound("project or conversation not found")
|
||||
return conversation
|
||||
|
||||
|
||||
def _if_match(request: Request, current: int) -> None:
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required")
|
||||
if expected != current:
|
||||
raise Conflict("revision does not match If-Match")
|
||||
|
||||
|
||||
def _key(request: Request) -> str:
|
||||
key = request.idempotency_key()
|
||||
if not key:
|
||||
raise Invalid("Idempotency-Key is required")
|
||||
return key
|
||||
|
||||
|
||||
def _digest(body: dict[str, Any]) -> str:
|
||||
return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _replay(request: Request, family: str, key: str, digest: str) -> dict[str, Any] | None:
|
||||
for row in request.store.read(family, "idempotency"):
|
||||
if row.get("key") != key:
|
||||
continue
|
||||
if row.get("digest") != digest:
|
||||
raise Conflict("Idempotency-Key was already used with different metadata")
|
||||
return request.store.get(family, row["id"])
|
||||
return None
|
||||
|
||||
|
||||
def _remember(request: Request, family: str, key: str, digest: str, record_id: str) -> None:
|
||||
request.store.append(family, "idempotency", {"key": key, "digest": digest, "id": record_id, "at": now_iso()})
|
||||
|
||||
|
||||
def _validate(record: dict[str, Any], pointer: str) -> None:
|
||||
problems = contracts.validate("multimodal.schema.json", record, SCHEMAS, pointer)
|
||||
if problems:
|
||||
raise Invalid("multimodal record failed contract validation", problems)
|
||||
|
||||
|
||||
def _approval(request: Request, approval_id: Any, source: Any) -> str:
|
||||
from hux import policy
|
||||
|
||||
try:
|
||||
approval = policy.load_approval(request.store, check_id(approval_id))
|
||||
except (Invalid, NotFound) as error:
|
||||
raise Forbidden("approved autonomy action is required") from error
|
||||
capability = "external_side_effect" if source in {"camera", "screen"} else "artifact_write"
|
||||
if approval.get("status") != "approved" or approval.get("conversation_id") != request.params["id"]:
|
||||
raise Forbidden("approved autonomy action is required")
|
||||
if approval.get("capability") != capability:
|
||||
raise Forbidden("approval does not cover this multimodal action")
|
||||
return approval["id"]
|
||||
|
||||
|
||||
def _lineage(request: Request, body: Any, project_id: str) -> dict[str, Any] | None:
|
||||
if body is None:
|
||||
return None
|
||||
if not isinstance(body, dict) or set(body) - {"parent_item_id", "artifact_id", "artifact_version"}:
|
||||
raise Invalid("lineage must contain only a parent item or artifact version")
|
||||
if "parent_item_id" in body:
|
||||
if len(body) != 1:
|
||||
raise Invalid("parent-item lineage cannot also name an artifact")
|
||||
parent = request.store.get(ITEMS, check_id(body["parent_item_id"]))
|
||||
if parent["conversation_id"] != request.params["id"] or parent["project_id"] != project_id:
|
||||
raise NotFound("lineage item not found")
|
||||
return {"parent_item_id": parent["id"]}
|
||||
if set(body) != {"artifact_id", "artifact_version"} or not isinstance(body["artifact_version"], int):
|
||||
raise Invalid("artifact lineage needs artifact_id and artifact_version")
|
||||
from hux import artifacts
|
||||
|
||||
artifact = request.store.get(artifacts.FAMILY, check_id(body["artifact_id"]))
|
||||
if artifact.get("project_id") != project_id or artifact.get("conversation_id") != request.params["id"]:
|
||||
raise NotFound("lineage artifact not found")
|
||||
version = next((row for row in artifact["versions"] if row["version"] == body["artifact_version"]), None)
|
||||
if version is None:
|
||||
raise NotFound("lineage artifact version not found")
|
||||
if version["content_ref"]["mime"].lower() in EXECUTABLE_MIMES or artifact.get("type") in {"html", "svg"}:
|
||||
raise Invalid("executable HTML and SVG lineage is not accepted")
|
||||
return {"artifact_id": artifact["id"], "artifact_version": version["version"]}
|
||||
|
||||
|
||||
def create_item(request: Request) -> Response:
|
||||
"""Register metadata for approved media; bytes are never accepted here."""
|
||||
body = _body(request, ITEM_FIELDS)
|
||||
conversation = _scope(request, body.get("project_id"))
|
||||
key, digest = _key(request), _digest(body)
|
||||
with request.store.lock(ITEMS):
|
||||
replayed = _replay(request, ITEMS, key, digest)
|
||||
if replayed is not None:
|
||||
return Response(200, replayed, {"ETag": str(replayed["revision"]), "HUX-Replayed": "true", "Cache-Control": "no-store"})
|
||||
_if_match(request, 0)
|
||||
if request.store.count(ITEMS) >= MAX_ITEMS:
|
||||
raise TooLarge("multimodal item limit reached")
|
||||
filename, mime = body.get("filename"), body.get("mime")
|
||||
if not isinstance(filename, str) or not filename.lower().endswith(SAFE_SUFFIXES):
|
||||
raise Invalid("filename is missing or executable")
|
||||
if not isinstance(mime, str) or mime.lower() in EXECUTABLE_MIMES:
|
||||
raise Invalid("executable HTML, XML and SVG are not accepted")
|
||||
if mime.lower() not in KIND_MIMES.get(body.get("kind"), frozenset()):
|
||||
raise Invalid("kind and MIME type do not agree")
|
||||
approval_id = _approval(request, body.get("approval_id"), body.get("source"))
|
||||
lineage = _lineage(request, body.get("lineage"), body["project_id"])
|
||||
record = {
|
||||
"schema": "hux.multimodal_item.v1", "id": new_id("mmi"), "owner": request.identity.subject,
|
||||
"project_id": body["project_id"], "conversation_id": conversation["id"], "kind": body.get("kind"),
|
||||
"source": body.get("source"), "filename": filename, "mime": mime.lower(), "bytes": body.get("bytes"),
|
||||
"hash": body.get("hash"), "approval_id": approval_id, "status": "metadata_only", "created_at": now_iso(),
|
||||
}
|
||||
if lineage:
|
||||
record["lineage"] = lineage
|
||||
_validate({**record, "revision": 1}, "/$defs/item")
|
||||
stored = request.store.put(ITEMS, record, expected_revision=0)
|
||||
_remember(request, ITEMS, key, digest, stored["id"])
|
||||
request.audit("multimodal.create", stored["id"])
|
||||
return Response(201, stored, {"ETag": "1", "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def list_items(request: Request) -> Response:
|
||||
"""List metadata in one project/conversation scope."""
|
||||
project_id = request.params["project_id"]
|
||||
_scope(request, project_id)
|
||||
items = [row for row in request.store.scan(ITEMS) if row["project_id"] == project_id and row["conversation_id"] == request.params["id"]]
|
||||
request.audit("multimodal.list", request.params["id"])
|
||||
response = page(items)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def get_item(request: Request) -> Response:
|
||||
"""Read one metadata record only within its path scope."""
|
||||
project_id = request.params["project_id"]
|
||||
_scope(request, project_id)
|
||||
try:
|
||||
item = request.store.get(ITEMS, check_id(request.params["item_id"]))
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("multimodal item not found") from error
|
||||
if item["project_id"] != project_id or item["conversation_id"] != request.params["id"]:
|
||||
raise NotFound("multimodal item not found")
|
||||
request.audit("multimodal.read", item["id"])
|
||||
return Response(200, item, {"ETag": str(item["revision"]), "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def correct_transcript(request: Request) -> Response:
|
||||
"""Append an immutable transcript correction and point the media head to it."""
|
||||
body = _body(request, frozenset({"project_id", "replacement_text"}))
|
||||
_scope(request, body.get("project_id"))
|
||||
key, digest = _key(request), _digest({**body, "item_id": request.params["item_id"]})
|
||||
with request.store.lock(CORRECTIONS):
|
||||
replayed = _replay(request, CORRECTIONS, key, digest)
|
||||
if replayed is not None:
|
||||
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
|
||||
item = request.store.get(ITEMS, check_id(request.params["item_id"]))
|
||||
if item["project_id"] != body["project_id"] or item["conversation_id"] != request.params["id"]:
|
||||
raise NotFound("multimodal item not found")
|
||||
if item["kind"] not in {"audio", "video"}:
|
||||
raise Invalid("only audio or video transcripts can be corrected")
|
||||
_if_match(request, item["revision"])
|
||||
text = body.get("replacement_text")
|
||||
if not isinstance(text, str) or not text.strip() or len(text) > 10000:
|
||||
raise Invalid("replacement_text must contain 1 to 10000 characters")
|
||||
if request.store.count(CORRECTIONS) >= MAX_CORRECTIONS:
|
||||
raise TooLarge("transcript correction limit reached")
|
||||
record = {
|
||||
"schema": "hux.transcript_correction.v1", "id": new_id("trc"), "owner": request.identity.subject,
|
||||
"project_id": body["project_id"], "conversation_id": request.params["id"], "item_id": item["id"],
|
||||
"replacement_text": text, "created_at": now_iso(),
|
||||
}
|
||||
_validate({**record, "revision": 1}, "/$defs/correction")
|
||||
stored = request.store.put(CORRECTIONS, record, expected_revision=0)
|
||||
with request.store.lock(ITEMS):
|
||||
updated = request.store.put(ITEMS, {**item, "latest_correction_id": stored["id"]}, item["revision"])
|
||||
_remember(request, CORRECTIONS, key, digest, stored["id"])
|
||||
request.audit("multimodal.correct", stored["id"])
|
||||
return Response(201, stored, {"ETag": str(updated["revision"]), "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def create_capture_intent(request: Request) -> Response:
|
||||
"""Record an inert camera/screen proposal; it never grants execution."""
|
||||
body = _body(request, frozenset({"project_id", "source", "purpose"}))
|
||||
_scope(request, body.get("project_id"))
|
||||
key, digest = _key(request), _digest(body)
|
||||
with request.store.lock(INTENTS):
|
||||
replayed = _replay(request, INTENTS, key, digest)
|
||||
if replayed is not None:
|
||||
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
|
||||
_if_match(request, 0)
|
||||
if request.store.count(INTENTS) >= MAX_INTENTS:
|
||||
raise TooLarge("capture intent limit reached")
|
||||
purpose = body.get("purpose")
|
||||
if body.get("source") not in {"camera", "screen"} or not isinstance(purpose, str) or not purpose.strip():
|
||||
raise Invalid("source and purpose are required")
|
||||
purpose = redaction.scrub_text(purpose[:280])[0]
|
||||
record = {
|
||||
"schema": "hux.capture_intent.v1", "id": new_id("cap"), "owner": request.identity.subject,
|
||||
"project_id": body["project_id"], "conversation_id": request.params["id"], "source": body["source"],
|
||||
"purpose": purpose, "status": "proposed", "requires_approval": True, "execution_allowed": False,
|
||||
"created_at": now_iso(),
|
||||
}
|
||||
_validate({**record, "revision": 1}, "/$defs/capture_intent")
|
||||
stored = request.store.put(INTENTS, record, expected_revision=0)
|
||||
_remember(request, INTENTS, key, digest, stored["id"])
|
||||
request.audit("multimodal.intent", stored["id"])
|
||||
return Response(201, stored, {"ETag": "1", "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach metadata-only HUX-07 routes."""
|
||||
base = "/hux/v1/projects/{project_id}/conversations/{id}"
|
||||
router.add("POST", base + "/multimodal/items", CARD, "multimodal.create", create_item)
|
||||
router.add("GET", base + "/multimodal/items", CARD, "multimodal.list", list_items)
|
||||
router.add("GET", base + "/multimodal/items/{item_id}", CARD, "multimodal.read", get_item)
|
||||
router.add("POST", base + "/multimodal/items/{item_id}/transcript-corrections", CARD, "multimodal.correct", correct_transcript)
|
||||
router.add("POST", base + "/capture-intents", CARD, "multimodal.intent", create_capture_intent)
|
||||
384
dockerfiles/hermes-hux-foundation/hux/organization.py
Normal file
384
dockerfiles/hermes-hux-foundation/hux/organization.py
Normal file
@ -0,0 +1,384 @@
|
||||
"""HUX-03 organisation: projects, conversations, branch lineage and search.
|
||||
|
||||
Projects own conversations; conversations carry tags, pins, a mode and an
|
||||
optional branch pointer to the conversation they forked from. Search covers
|
||||
only the ``project.schema.json#/$defs/search_index`` fields this increment
|
||||
indexes (title, tags, project_name, and artifact titles when the artifacts
|
||||
lane is present); message text is not indexed here and the response says so.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
CARD = "HUX-03"
|
||||
PROJECTS = "projects"
|
||||
CONVERSATIONS = "conversations"
|
||||
MAX_PROJECTS = 200
|
||||
MAX_CONVERSATIONS = 2000
|
||||
PROJECT_FIELDS = ("name", "description", "tags", "pinned", "archived", "default_mode")
|
||||
CONVERSATION_FIELDS = ("title", "tags", "pinned", "archived", "mode", "project_id")
|
||||
INDEXED = ("title", "tags", "project_name", "artifact_titles")
|
||||
NOT_INDEXED = ("message_text",)
|
||||
MESSAGE_KINDS = ("message.user", "message.assistant")
|
||||
MESSAGE_SCAN_CONVERSATIONS = 100
|
||||
MESSAGE_SCAN_EVENTS = 300
|
||||
SEARCH_PAGE = 50
|
||||
SCHEMAS = contracts.load_all()
|
||||
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
def project_exists(store: TenantStore, project_id: str) -> bool:
|
||||
"""True when this tenant owns a project with that id (helper for other lanes)."""
|
||||
return isinstance(project_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", project_id)) and store.exists(PROJECTS, project_id)
|
||||
|
||||
|
||||
def conversation_exists(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""True when this tenant owns a conversation with that id (helper for other lanes)."""
|
||||
return isinstance(conversation_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", conversation_id)) and store.exists(CONVERSATIONS, conversation_id)
|
||||
|
||||
|
||||
def project_of(store: TenantStore, conversation_id: str | None) -> str | None:
|
||||
"""The project a conversation belongs to, or None when unknown or unfiled."""
|
||||
if not conversation_id or not conversation_exists(store, conversation_id):
|
||||
return None
|
||||
return store.get(CONVERSATIONS, conversation_id).get("project_id")
|
||||
|
||||
|
||||
def checked(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Raise Invalid unless ``record`` satisfies its contract."""
|
||||
problems = contracts.validate_record(record, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("record fails contract", problems)
|
||||
return record
|
||||
|
||||
|
||||
def body_dict(request: Request) -> dict[str, Any]:
|
||||
"""The JSON object body or Invalid."""
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
def pick(body: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""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:
|
||||
"""The record an Idempotency-Key already created in this family, if any."""
|
||||
for row in store.read(family, "idempotency"):
|
||||
if row["key"] == key:
|
||||
return store.get(family, row["id"])
|
||||
return None
|
||||
|
||||
|
||||
def create(request: Request, family: str, record: dict[str, Any], key: str, cap: int) -> tuple[dict[str, Any], int]:
|
||||
"""Create under the family lock honouring Idempotency-Key and the family count cap."""
|
||||
with request.store.lock(family):
|
||||
if key:
|
||||
existing = replay(request.store, family, key)
|
||||
if existing is not None:
|
||||
return existing, 200
|
||||
if request.store.count(family) >= cap:
|
||||
raise Conflict(f"{family} cap of {cap} reached")
|
||||
stored = request.store.put(family, checked({**record, "revision": 1}))
|
||||
if key:
|
||||
request.store.append(family, "idempotency", {"key": key, "id": stored["id"]})
|
||||
return stored, 201
|
||||
|
||||
|
||||
def update(request: Request, family: str, fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""PATCH under If-Match; a missing If-Match is accepted but audited as unconditional (SO-44)."""
|
||||
changes = pick(body_dict(request), fields)
|
||||
expected = request.if_match()
|
||||
with request.store.lock(family):
|
||||
current = request.store.get(family, check_id(request.params["id"]))
|
||||
if "project_id" in changes and changes["project_id"] is not None and not project_exists(request.store, changes["project_id"]):
|
||||
raise NotFound("project not found")
|
||||
record = {**current, **{k: v for k, v in changes.items() if v is not None}, "updated_at": now_iso()}
|
||||
if changes.get("project_id", "") is None:
|
||||
record.pop("project_id", None)
|
||||
stored = request.store.put(family, checked(record), expected_revision=expected)
|
||||
request.audit(f"{family}.update", stored["id"], reason="" if expected is not None else "unconditional_write")
|
||||
return stored
|
||||
|
||||
|
||||
def etag(record: dict[str, Any]) -> dict[str, str]:
|
||||
"""Revision as ETag so clients can send it back in If-Match."""
|
||||
return {"ETag": str(record["revision"])}
|
||||
|
||||
|
||||
# -- projects ------------------------------------------------------------------
|
||||
|
||||
def create_project(request: Request) -> Response:
|
||||
"""``POST /hux/v1/projects``."""
|
||||
body = pick(body_dict(request), PROJECT_FIELDS)
|
||||
stamp = now_iso()
|
||||
record = {
|
||||
"schema": "hux.project.v1", "id": new_id("prj"), "owner": request.identity.subject,
|
||||
"tags": [], "pinned": False, "archived": False, **body, "created_at": stamp, "updated_at": stamp,
|
||||
}
|
||||
stored, status = create(request, PROJECTS, record, request.idempotency_key(), MAX_PROJECTS)
|
||||
request.audit("projects.create", stored["id"], reason="replayed" if status == 200 else "")
|
||||
return Response(status, stored, etag(stored))
|
||||
|
||||
|
||||
def list_projects(request: Request) -> Response:
|
||||
"""``GET /hux/v1/projects?archived=``: pinned first, then most recently updated."""
|
||||
archived = request.query.get("archived")
|
||||
items = [p for p in request.store.scan(PROJECTS) if archived is None or p["archived"] == (archived == "true")]
|
||||
items.sort(key=lambda p: p["updated_at"], reverse=True)
|
||||
items.sort(key=lambda p: not p["pinned"])
|
||||
request.audit("projects.list", "projects")
|
||||
return page(items)
|
||||
|
||||
|
||||
def get_project(request: Request) -> Response:
|
||||
"""``GET /hux/v1/projects/{id}``."""
|
||||
record = request.store.get(PROJECTS, check_id(request.params["id"]))
|
||||
request.audit("projects.read", record["id"])
|
||||
return Response(200, record, etag(record))
|
||||
|
||||
|
||||
def patch_project(request: Request) -> Response:
|
||||
"""``PATCH /hux/v1/projects/{id}`` with If-Match."""
|
||||
stored = update(request, PROJECTS, PROJECT_FIELDS)
|
||||
return Response(200, stored, etag(stored))
|
||||
|
||||
|
||||
# -- conversations -------------------------------------------------------------
|
||||
|
||||
def new_conversation(request: Request, body: dict[str, Any], extra: dict[str, Any] | None = None) -> Response:
|
||||
"""Build, validate and store a conversation from client fields plus server-set extras."""
|
||||
if body.get("project_id") is not None and not project_exists(request.store, body["project_id"]):
|
||||
raise NotFound("project not found")
|
||||
stamp = now_iso()
|
||||
record = {
|
||||
"schema": "hux.conversation.v1", "id": new_id("conv"), "owner": request.identity.subject,
|
||||
"tags": [], "pinned": False, "archived": False, **{k: v for k, v in body.items() if v is not None},
|
||||
**(extra or {}), "artifact_ids": [], "created_at": stamp, "updated_at": stamp,
|
||||
}
|
||||
stored, status = create(request, CONVERSATIONS, record, request.idempotency_key(), MAX_CONVERSATIONS)
|
||||
request.audit("conversations.create", stored["id"], reason="replayed" if status == 200 else "")
|
||||
return Response(status, stored, etag(stored))
|
||||
|
||||
|
||||
def create_conversation(request: Request) -> Response:
|
||||
"""``POST /hux/v1/conversations``."""
|
||||
return new_conversation(request, pick(body_dict(request), CONVERSATION_FIELDS))
|
||||
|
||||
|
||||
def list_conversations(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations?project_id=&tag=&pinned=&archived=``: newest activity first."""
|
||||
q = request.query
|
||||
flags = {k: q[k] == "true" for k in ("pinned", "archived") if k in q}
|
||||
items = []
|
||||
for index, record in enumerate(request.store.scan(CONVERSATIONS)):
|
||||
if "project_id" in q and record.get("project_id") != q["project_id"]:
|
||||
continue
|
||||
if "tag" in q and q["tag"] not in record["tags"]:
|
||||
continue
|
||||
if any(record[k] != v for k, v in flags.items()):
|
||||
continue
|
||||
items.append((record.get("last_message_at", record["updated_at"]), index, record))
|
||||
items.sort(key=lambda item: item[:2], reverse=True)
|
||||
request.audit("conversations.list", "conversations")
|
||||
return page([record for _, _, record in items])
|
||||
|
||||
|
||||
def get_conversation(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}``."""
|
||||
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
|
||||
request.audit("conversations.read", record["id"])
|
||||
return Response(200, record, etag(record))
|
||||
|
||||
|
||||
def patch_conversation(request: Request) -> Response:
|
||||
"""``PATCH /hux/v1/conversations/{id}`` with If-Match."""
|
||||
stored = update(request, CONVERSATIONS, CONVERSATION_FIELDS)
|
||||
return Response(200, stored, etag(stored))
|
||||
|
||||
|
||||
def branch_conversation(request: Request) -> Response:
|
||||
"""``POST /hux/v1/conversations/{id}/branch``: fork at a message, keeping project, tags and mode."""
|
||||
body = body_dict(request)
|
||||
point = body.get("branch_point_message_id")
|
||||
if not isinstance(point, str) or not 1 <= len(point) <= 120:
|
||||
raise Invalid("branch_point_message_id required")
|
||||
parent = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
|
||||
fields = {"title": body.get("title") or f"{parent['title']} (branch)"[:200], "tags": list(parent["tags"]),
|
||||
"project_id": parent.get("project_id"), "mode": parent.get("mode")}
|
||||
return new_conversation(request, fields, {"branch": {"parent_conversation_id": parent["id"], "branch_point_message_id": point}})
|
||||
|
||||
|
||||
def lineage(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}/lineage``: ancestors root-first plus direct children."""
|
||||
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
|
||||
ancestors: list[dict[str, Any]] = []
|
||||
cursor, seen = record, {record["id"]}
|
||||
while "branch" in cursor and len(ancestors) < 64:
|
||||
parent_id = cursor["branch"]["parent_conversation_id"]
|
||||
if parent_id in seen or not request.store.exists(CONVERSATIONS, parent_id):
|
||||
break
|
||||
cursor = request.store.get(CONVERSATIONS, parent_id)
|
||||
seen.add(parent_id)
|
||||
ancestors.insert(0, cursor)
|
||||
children = [c for c in request.store.scan(CONVERSATIONS) if c.get("branch", {}).get("parent_conversation_id") == record["id"]]
|
||||
request.audit("conversations.lineage", record["id"])
|
||||
return Response(200, {"conversation": record, "ancestors": ancestors, "children": children})
|
||||
|
||||
|
||||
# -- search --------------------------------------------------------------------
|
||||
|
||||
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 {}
|
||||
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]:
|
||||
"""Lowercased alphanumeric terms."""
|
||||
return TOKEN_RE.findall(text.lower())
|
||||
|
||||
|
||||
def score(record: dict[str, Any], terms: list[str], project_name: str, titles: list[str]) -> int:
|
||||
"""Title hit beats tag hit beats project/artifact hit; every term must match somewhere."""
|
||||
fields = {"title": tokens(record["title"]), "tags": [t for tag in record["tags"] for t in tokens(tag)],
|
||||
"project_name": tokens(project_name), "artifact_titles": [t for title in titles for t in tokens(title)]}
|
||||
weight = {"title": 4, "tags": 3, "project_name": 2, "artifact_titles": 1}
|
||||
total = 0
|
||||
for term in terms:
|
||||
hit = sum(weight[f] * words.count(term) for f, words in fields.items())
|
||||
if not hit:
|
||||
return 0
|
||||
total += hit
|
||||
return total
|
||||
|
||||
|
||||
def message_text_score(store: TenantStore, conversation: dict[str, Any], terms: list[str]) -> int:
|
||||
"""Bounded match over this conversation's stored message events.
|
||||
|
||||
Privacy wins: forgotten conversations were already excluded, private-mode
|
||||
conversations are never scanned, and restricted or fully redacted events
|
||||
stay invisible to search exactly as they are on the timeline.
|
||||
"""
|
||||
from hux import events
|
||||
|
||||
if events.is_private(store, conversation["id"]):
|
||||
return 0
|
||||
rows = store.read(events.FAMILY, conversation["id"])[-MESSAGE_SCAN_EVENTS:]
|
||||
total = 0
|
||||
for row in rows:
|
||||
if row.get("kind") not in MESSAGE_KINDS or row.get("sensitivity") == "restricted":
|
||||
continue
|
||||
if (row.get("redaction") or {}).get("level") == "full":
|
||||
continue
|
||||
words = tokens(str(row.get("summary", "")))
|
||||
total += sum(words.count(term) for term in terms)
|
||||
return total
|
||||
|
||||
|
||||
def search(request: Request) -> Response:
|
||||
"""``GET /hux/v1/search?q=&project_id=&include=&cursor=``: best match first.
|
||||
|
||||
The default scope is the indexed fields only. ``include=message_text``
|
||||
additionally scans the stored message events of the most recently active
|
||||
``MESSAGE_SCAN_CONVERSATIONS`` candidates (bounded, privacy-enforced) and
|
||||
pages deterministically: rank order is (score desc, updated_at desc, id),
|
||||
the cursor is the offset into that total order.
|
||||
"""
|
||||
terms = tokens(request.query.get("q", "")[:200])
|
||||
if not terms:
|
||||
raise Invalid("q is required")
|
||||
include = request.query.get("include")
|
||||
if include not in (None, "message_text"):
|
||||
raise Invalid("include supports only message_text")
|
||||
cursor = request.query.get("cursor", "0")
|
||||
if not cursor.isdigit():
|
||||
raise Invalid("cursor must be a non-negative integer")
|
||||
project_filter = request.query.get("project_id")
|
||||
names = {p["id"]: p["name"] for p in request.store.scan(PROJECTS)}
|
||||
index = artifact_index(request.store)
|
||||
candidates = [
|
||||
record for record in request.store.scan(CONVERSATIONS)
|
||||
if not project_filter or record.get("project_id") == project_filter
|
||||
]
|
||||
scanned = set()
|
||||
if include:
|
||||
recent = sorted(candidates, key=lambda r: (r["updated_at"], r["id"]), reverse=True)
|
||||
scanned = {record["id"] for record in recent[:MESSAGE_SCAN_CONVERSATIONS]}
|
||||
ranked = []
|
||||
for record in candidates:
|
||||
points = score(record, terms, names.get(record.get("project_id", ""), ""), artifact_titles(request.store, record, index))
|
||||
if record["id"] in scanned and not record.get("archived"):
|
||||
points += message_text_score(request.store, record, terms)
|
||||
if points:
|
||||
ranked.append((points, record))
|
||||
if include:
|
||||
# Paginated mode needs one total order; ids break updated_at ties.
|
||||
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"], pair[1]["id"]))
|
||||
else:
|
||||
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"]))
|
||||
request.audit("search.query", "conversations")
|
||||
if not include:
|
||||
return Response(200, {"items": [r for _, r in ranked], "next": None, "scores": {r["id"]: s for s, r in ranked},
|
||||
"indexed": list(INDEXED), "not_indexed": list(NOT_INDEXED)})
|
||||
start = int(cursor)
|
||||
window = ranked[start : start + SEARCH_PAGE]
|
||||
return Response(200, {
|
||||
"items": [r for _, r in window], "scores": {r["id"]: s for s, r in window},
|
||||
"next": str(start + SEARCH_PAGE) if len(ranked) > start + SEARCH_PAGE else None,
|
||||
"indexed": list(INDEXED) + ["message_text"], "not_indexed": [],
|
||||
"message_scan_limit": MESSAGE_SCAN_CONVERSATIONS,
|
||||
})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-03 routes."""
|
||||
router.add("POST", "/hux/v1/projects", CARD, "projects.create", create_project)
|
||||
router.add("GET", "/hux/v1/projects", CARD, "projects.list", list_projects)
|
||||
router.add("GET", "/hux/v1/projects/{id}", CARD, "projects.read", get_project)
|
||||
router.add("PATCH", "/hux/v1/projects/{id}", CARD, "projects.update", patch_project)
|
||||
router.add("POST", "/hux/v1/conversations", CARD, "conversations.create", create_conversation)
|
||||
router.add("GET", "/hux/v1/conversations", CARD, "conversations.list", list_conversations)
|
||||
router.add("GET", "/hux/v1/conversations/{id}", CARD, "conversations.read", get_conversation)
|
||||
router.add("PATCH", "/hux/v1/conversations/{id}", CARD, "conversations.update", patch_conversation)
|
||||
router.add("POST", "/hux/v1/conversations/{id}/branch", CARD, "conversations.branch", branch_conversation)
|
||||
router.add("GET", "/hux/v1/conversations/{id}/lineage", CARD, "conversations.lineage", lineage)
|
||||
router.add("GET", "/hux/v1/search", CARD, "search.query", search)
|
||||
428
dockerfiles/hermes-hux-foundation/hux/policy.py
Normal file
428
dockerfiles/hermes-hux-foundation/hux/policy.py
Normal file
@ -0,0 +1,428 @@
|
||||
"""HUX-05 autonomy: policy documents per scope and the approval queue.
|
||||
|
||||
A policy fixes the autonomy level, explicit grants and budgets for a scope
|
||||
(global, project or conversation). ``rules.effective_decision`` is the only
|
||||
resolver: an approval request resolves to allow, ask or deny against the most
|
||||
specific policy, and every external side effect asks regardless. Budgets,
|
||||
the pre-side-effect gate and cancellation receipts live in ``hux.budgets``;
|
||||
this module registers their routes so the family stays one card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts, rules
|
||||
from hux.errors import BudgetExhausted, Conflict, Forbidden, Invalid
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id, new_id
|
||||
|
||||
CARD = "HUX-05"
|
||||
FAMILY = "policy"
|
||||
APPROVALS = "approvals"
|
||||
SCOPES = ("global", "project", "conversation")
|
||||
APPROVAL_TTL = timedelta(hours=24)
|
||||
ALWAYS_TTL = timedelta(days=30)
|
||||
SESSION_TTL = timedelta(hours=24)
|
||||
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
|
||||
HUMAN_TRUSTS = frozenset({"router", "relay"})
|
||||
DEFAULT_BUDGETS = {
|
||||
"tokens_per_run": 200000, "tool_calls_per_run": 40, "wall_clock_seconds": 900,
|
||||
"delegations_per_run": 4, "spend_units": 50, "subagents_per_run": 2,
|
||||
}
|
||||
SCHEMAS = contracts.load_all()
|
||||
|
||||
|
||||
def clock() -> datetime:
|
||||
"""Current UTC time; tests replace this to move approvals past expiry."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
"""Indirection so monkeypatching ``clock`` reaches every module."""
|
||||
return clock()
|
||||
|
||||
|
||||
def iso(when: datetime) -> str:
|
||||
"""RFC 3339 second-precision UTC string."""
|
||||
return when.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def parse(stamp: str) -> datetime:
|
||||
"""Inverse of ``iso``."""
|
||||
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def run_key(run_id: str) -> str:
|
||||
"""Stable hex handle for a free-form run id so it can name a document."""
|
||||
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 require_worker(identity: Identity, what: str) -> None:
|
||||
"""Forbidden unless the separately keyed Worker gateway is making the call."""
|
||||
if identity.trust != "worker" or identity.surface != "worker":
|
||||
raise Forbidden(f"{what} only from worker trust")
|
||||
|
||||
|
||||
def actor_for(identity: Identity) -> dict[str, str]:
|
||||
"""The actor a record attributes to this caller: humans are users, hops are system."""
|
||||
if is_human(identity):
|
||||
return {"type": "user", "id": identity.subject}
|
||||
return {"type": "system", "id": identity.surface}
|
||||
|
||||
|
||||
def provenance(identity: Identity) -> dict[str, Any]:
|
||||
"""Server-set provenance; nothing here comes from the body."""
|
||||
return {"surface": identity.surface, "actor": actor_for(identity), "recorded_at": iso(now())}
|
||||
|
||||
|
||||
def public(record: dict[str, Any], revisioned: bool = False) -> dict[str, Any]:
|
||||
"""Strip store-internal fields before a record leaves the service."""
|
||||
return {k: v for k, v in record.items() if not k.startswith("_") and (revisioned or k != "revision")}
|
||||
|
||||
|
||||
def emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, **extra: Any) -> None:
|
||||
"""Record an activity event through the events lane when it is present."""
|
||||
if not conversation_id:
|
||||
return
|
||||
try:
|
||||
from hux import events
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
events.emit(store, identity, conversation_id, kind, summary, **extra)
|
||||
|
||||
|
||||
def checked(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Raise Invalid unless ``record`` satisfies its contract; internal fields are ignored."""
|
||||
if record["schema"] == "hux.policy.v1":
|
||||
candidate = public({"revision": 1, **record}, revisioned=True)
|
||||
else:
|
||||
candidate = public(record)
|
||||
problems = contracts.validate_record(candidate, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("record fails contract", problems)
|
||||
return record
|
||||
|
||||
|
||||
def body_dict(request: Request) -> dict[str, Any]:
|
||||
"""The JSON object body or Invalid."""
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
# -- policy documents --------------------------------------------------------
|
||||
|
||||
def policy_id(level: str, scope_id: str | None) -> str:
|
||||
"""Document id for a scope; global has exactly one."""
|
||||
if level not in SCOPES:
|
||||
raise Invalid("scope must be global, project or conversation")
|
||||
if level == "global":
|
||||
return "pol_global"
|
||||
if scope_id is None or len(scope_id) > 60:
|
||||
raise Invalid("scope_id required and at most 60 characters for project and conversation scopes")
|
||||
return f"pol_{level}.{check_id(scope_id)}"
|
||||
|
||||
|
||||
def default_policy(identity: Identity) -> dict[str, Any]:
|
||||
"""The ``safe`` policy every tenant starts with."""
|
||||
return {
|
||||
"schema": "hux.policy.v1", "id": "pol_global", "owner": identity.subject, "scope": {"level": "global"},
|
||||
"autonomy": "safe", "grants": [], "budgets": dict(DEFAULT_BUDGETS),
|
||||
"provenance": provenance(identity), "updated_at": iso(now()), "_budget_epoch": new_id("bep"),
|
||||
}
|
||||
|
||||
|
||||
def global_policy(store: TenantStore, identity: Identity) -> dict[str, Any]:
|
||||
"""Read the global policy, creating the default on first touch."""
|
||||
with store.lock(FAMILY):
|
||||
if store.exists(FAMILY, "pol_global"):
|
||||
return store.get(FAMILY, "pol_global")
|
||||
return store.put(FAMILY, checked({**default_policy(identity), "revision": 1}))
|
||||
|
||||
|
||||
def effective_policy(store: TenantStore, identity: Identity, level: str, scope_id: str | None) -> dict[str, Any]:
|
||||
"""Most specific policy along conversation -> project -> global."""
|
||||
chain: list[tuple[str, str | None]] = [(level, scope_id)]
|
||||
if level == "conversation":
|
||||
from hux import organization # lazy: organization never imports policy, but keep import order free
|
||||
parent = organization.project_of(store, scope_id)
|
||||
if parent:
|
||||
chain.append(("project", parent))
|
||||
for lvl, sid in chain:
|
||||
if lvl != "global" and store.exists(FAMILY, policy_id(lvl, sid)):
|
||||
return store.get(FAMILY, policy_id(lvl, sid))
|
||||
return global_policy(store, identity)
|
||||
|
||||
|
||||
def normalise_grants(grants: Any, identity: Identity) -> list[dict[str, Any]]:
|
||||
"""Validate grants and pin every one to a server-set expiry of at most 30 days (SO-38)."""
|
||||
if not isinstance(grants, list):
|
||||
raise Invalid("grants must be a list")
|
||||
ceiling = now() + ALWAYS_TTL
|
||||
out = []
|
||||
for grant in grants:
|
||||
if not isinstance(grant, dict) or grant.get("capability") not in rules.CAPABILITIES:
|
||||
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)
|
||||
out.append({"capability": grant["capability"], "decision": grant["decision"], "expires_at": expires, "granted_by": actor_for(identity)})
|
||||
return out
|
||||
|
||||
|
||||
def get_policy(request: Request) -> Response:
|
||||
"""``GET /hux/v1/policy?scope=&scope_id=``: the effective policy for a scope."""
|
||||
level = request.query.get("scope", "global")
|
||||
scope_id = request.query.get("scope_id")
|
||||
policy_id(level, scope_id)
|
||||
record = effective_policy(request.store, request.identity, level, scope_id)
|
||||
request.audit("policy.read", record["id"])
|
||||
return Response(200, public(record, revisioned=True), {"ETag": str(record["revision"])})
|
||||
|
||||
|
||||
def put_policy(request: Request) -> Response:
|
||||
"""``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):
|
||||
raise Invalid("scope must be an object")
|
||||
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,
|
||||
"autonomy": body["autonomy"], "grants": normalise_grants(body.get("grants", []), request.identity),
|
||||
"budgets": budgets, "provenance": provenance(request.identity), "updated_at": iso(now()),
|
||||
"_budget_epoch": new_id("bep"),
|
||||
}
|
||||
expected = request.if_match()
|
||||
with request.store.lock(FAMILY):
|
||||
exists = request.store.exists(FAMILY, record_id)
|
||||
stored = request.store.put(FAMILY, checked(record), expected_revision=expected)
|
||||
reason = "unconditional_write" if exists and expected is None else ""
|
||||
request.audit("policy.write", record_id, reason=reason)
|
||||
return Response(200, public(stored, revisioned=True), {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
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; 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):
|
||||
record = store.get(FAMILY, record_id)
|
||||
else:
|
||||
scope = {"level": level, **({"scope_id": scope_id} if scope_id else {})}
|
||||
record = {**global_policy(store, identity), "id": record_id, "scope": scope}
|
||||
record.pop("revision")
|
||||
expected = record.pop("revision", None)
|
||||
budget_epoch = record.get("_budget_epoch") or f"{record['id']}:{expected or 1}"
|
||||
grants = [g for g in record["grants"] if g["capability"] != capability]
|
||||
grants.append({"capability": capability, "decision": "allow", "expires_at": iso(now() + min(ttl, ALWAYS_TTL)), "granted_by": actor_for(identity)})
|
||||
record = {
|
||||
**record, "grants": grants[-64:], "provenance": provenance(identity),
|
||||
"updated_at": iso(now()), "_budget_epoch": budget_epoch,
|
||||
}
|
||||
store.put(FAMILY, checked(record), expected_revision=expected)
|
||||
|
||||
|
||||
# -- approvals -----------------------------------------------------------------
|
||||
|
||||
def refresh(store: TenantStore, record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Expire a pending approval whose window has closed (SO-42); terminal records never change."""
|
||||
if record["status"] == "pending" and parse(record["expires_at"]) <= now():
|
||||
with store.lock(APPROVALS):
|
||||
record = store.put(APPROVALS, {**record, "status": "expired"})
|
||||
return record
|
||||
|
||||
|
||||
def load_approval(store: TenantStore, approval_id: str) -> dict[str, Any]:
|
||||
"""Read one approval for this tenant; unknown ids are 404 whoever owns them."""
|
||||
return refresh(store, store.get(APPROVALS, check_id(approval_id)))
|
||||
|
||||
|
||||
def request_fingerprint(body: dict[str, Any]) -> str:
|
||||
"""Canonical digest that binds an Idempotency-Key to exactly one approval request."""
|
||||
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
return "sha256:" + hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
def replay(store: TenantStore, key: str, fingerprint: str) -> dict[str, Any] | None:
|
||||
"""The approval an Idempotency-Key already created, rejecting a changed request body."""
|
||||
for row in store.read(APPROVALS, "idempotency"):
|
||||
if row["key"] == key:
|
||||
record = load_approval(store, row["id"])
|
||||
if record.get("_request_fingerprint") != fingerprint:
|
||||
raise Conflict("Idempotency-Key was already used for a different approval request")
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def resolve_request(policy: dict[str, Any], capability: str, external: bool) -> str:
|
||||
"""Effective decision for a request; external side effects never auto-allow (SO-39)."""
|
||||
decision = rules.effective_decision(policy, capability, now())
|
||||
if external and decision == "allow":
|
||||
return "ask"
|
||||
return decision
|
||||
|
||||
|
||||
PRIVATE_DENIED = frozenset({"network", "web_search", "send_message", "shell", "delegate"})
|
||||
|
||||
|
||||
def private_mode_denies(store: TenantStore, conversation_id: str | None, capability: str) -> bool:
|
||||
"""Private conversations never release web, shell, messaging or delegation.
|
||||
|
||||
The mode catalog says so (``rules.MODE_CATALOG['private']['tools']``);
|
||||
memory writes are already refused by the privacy state. The conversation
|
||||
record's ``mode`` field is written only by the HUX-06 selection route.
|
||||
"""
|
||||
if capability not in PRIVATE_DENIED or not conversation_id:
|
||||
return False
|
||||
try:
|
||||
record = store.get("conversations", conversation_id)
|
||||
except Exception:
|
||||
return False
|
||||
return record.get("mode") == "private"
|
||||
|
||||
|
||||
def create_approval(request: Request) -> Response:
|
||||
"""``POST /hux/v1/approvals``: the agent hook asks before a gated action."""
|
||||
require_worker(request.identity, "approval requests")
|
||||
body = body_dict(request)
|
||||
key = request.idempotency_key()
|
||||
conversation_id = check_id(body.get("conversation_id"))
|
||||
capability = body.get("capability")
|
||||
if capability not in rules.CAPABILITIES:
|
||||
raise Invalid("unknown capability")
|
||||
from hux import budgets # lazy: budgets imports this module
|
||||
run_id = budgets.checked_run_id(body.get("run_id"))
|
||||
bound_conversation = budgets.run_conversation(request.store, run_id)
|
||||
if bound_conversation != conversation_id:
|
||||
raise Invalid("run is not authoritatively bound to this conversation")
|
||||
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)")
|
||||
fingerprint = request_fingerprint(body)
|
||||
if key:
|
||||
with request.store.lock(APPROVALS):
|
||||
existing = replay(request.store, key, fingerprint)
|
||||
if existing is not None:
|
||||
request.audit("approvals.create", existing["id"], reason="replayed")
|
||||
return Response(200, public(existing))
|
||||
state = budgets.budget_state(request.store, request.identity, run_id, conversation_id)
|
||||
if state["exhausted"]:
|
||||
emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"])
|
||||
raise BudgetExhausted("run budget exhausted", state["exhausted"])
|
||||
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
|
||||
decision = resolve_request(policy, capability, external)
|
||||
if private_mode_denies(request.store, conversation_id, capability):
|
||||
decision = "deny"
|
||||
stamp = now()
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": run_id, "conversation_id": conversation_id,
|
||||
"capability": capability, "request": {**req, "external": external},
|
||||
"status": {"allow": "approved", "deny": "denied", "ask": "pending"}[decision],
|
||||
"requested_at": iso(stamp), "expires_at": iso(stamp + APPROVAL_TTL),
|
||||
}
|
||||
if decision != "ask":
|
||||
record["decision"] = {"choice": "once" if decision == "allow" else "deny", "by": {"type": "system", "id": "policy"}, "at": iso(stamp)}
|
||||
if key:
|
||||
record["idempotency_key"] = key
|
||||
record["_request_fingerprint"] = fingerprint
|
||||
with request.store.lock(APPROVALS):
|
||||
if key:
|
||||
existing = replay(request.store, key, fingerprint)
|
||||
if existing is not None:
|
||||
request.audit("approvals.create", existing["id"], reason="replayed")
|
||||
return Response(200, public(existing))
|
||||
stored = request.store.put(APPROVALS, checked(record))
|
||||
if key:
|
||||
request.store.append(APPROVALS, "idempotency", {"key": key, "id": stored["id"]})
|
||||
request.audit("approvals.create", stored["id"], reason=f"policy_{decision}")
|
||||
kind = "approval.requested" if decision == "ask" else "approval.resolved"
|
||||
emit(request.store, request.identity, conversation_id, kind, f"{capability}: {record['request'].get('summary', '')}"[:280],
|
||||
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
|
||||
return Response(201, public(stored))
|
||||
|
||||
|
||||
def list_approvals(request: Request) -> Response:
|
||||
"""``GET /hux/v1/approvals?status=``: the queue, oldest request first."""
|
||||
wanted = request.query.get("status")
|
||||
if wanted and wanted not in rules.APPROVAL_TRANSITIONS:
|
||||
raise Invalid("unknown status")
|
||||
items = [refresh(request.store, r) for r in request.store.scan(APPROVALS)]
|
||||
items = [public(r) for r in items if not wanted or r["status"] == wanted]
|
||||
items.sort(key=lambda r: (r["requested_at"], r["id"]))
|
||||
request.audit("approvals.list", wanted or "all")
|
||||
return page(items)
|
||||
|
||||
|
||||
def decide_approval(request: Request) -> Response:
|
||||
"""``POST /hux/v1/approvals/{id}``: a human answers once, session, always or deny (SO-35)."""
|
||||
identity = request.identity
|
||||
if identity.trust not in HUMAN_TRUSTS or identity.surface not in HUMAN_SURFACES:
|
||||
raise Forbidden("approvals are decided only from a human surface")
|
||||
choice = body_dict(request).get("choice")
|
||||
if choice not in ("once", "session", "always", "deny"):
|
||||
raise Invalid("choice must be once, session, always or deny")
|
||||
target = "denied" if choice == "deny" else "approved"
|
||||
with request.store.lock(APPROVALS):
|
||||
record = load_approval(request.store, request.params["id"])
|
||||
if not rules.transition_allowed(rules.APPROVAL_TRANSITIONS, record["status"], target):
|
||||
raise Conflict(f"approval is {record['status']}; only pending approvals can be decided")
|
||||
record = {**record, "status": target, "decision": {"choice": choice, "by": actor_for(identity), "at": iso(now())}}
|
||||
stored = request.store.put(APPROVALS, checked(record))
|
||||
if choice == "session":
|
||||
add_grant(request.store, identity, "conversation", record["conversation_id"], record["capability"], SESSION_TTL)
|
||||
elif choice == "always":
|
||||
add_grant(request.store, identity, "global", None, record["capability"], ALWAYS_TTL)
|
||||
request.audit("approvals.decide", stored["id"], reason=choice)
|
||||
emit(request.store, identity, record["conversation_id"], "approval.resolved", f"{record['capability']} {target} ({choice})",
|
||||
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
|
||||
return Response(200, public(stored))
|
||||
|
||||
|
||||
def get_approval(request: Request) -> Response:
|
||||
"""``GET /hux/v1/approvals/{id}``: one record."""
|
||||
record = load_approval(request.store, request.params["id"])
|
||||
request.audit("approvals.read", record["id"])
|
||||
return Response(200, public(record))
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-05 routes, including the budget, gate and stop routes from ``hux.budgets``."""
|
||||
from hux import budgets
|
||||
|
||||
router.add("GET", "/hux/v1/policy", CARD, "policy.read", get_policy)
|
||||
router.add("PUT", "/hux/v1/policy", CARD, "policy.write", put_policy)
|
||||
router.add("POST", "/hux/v1/approvals", CARD, "approvals.create", create_approval)
|
||||
router.add("GET", "/hux/v1/approvals", CARD, "approvals.list", list_approvals)
|
||||
router.add("GET", "/hux/v1/approvals/{id}", CARD, "approvals.read", get_approval)
|
||||
router.add("POST", "/hux/v1/approvals/{id}", CARD, "approvals.decide", decide_approval)
|
||||
budgets.register_routes(router)
|
||||
299
dockerfiles/hermes-hux-foundation/hux/privacy.py
Normal file
299
dockerfiles/hermes-hux-foundation/hux/privacy.py
Normal file
@ -0,0 +1,299 @@
|
||||
"""HUX-10 privacy behaviour: topic detection, notices, conversation forget and the retention job.
|
||||
|
||||
The policy itself lives in ``rules.privacy_policy``; this module records what
|
||||
was shown, scopes detected topics to the conversation they appeared in, gates
|
||||
memory writes for other families (``memory_write_allowed``) and runs the
|
||||
daily retention audit that expires memory, decays topic context and purges
|
||||
forgotten content while never touching the audit ledgers (SO-47).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts, rules
|
||||
from hux.errors import Invalid, NotFound
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id, new_id, now_iso
|
||||
|
||||
FAMILY = "privacy"
|
||||
RETENTION_FAMILY = "retention"
|
||||
TOPICS_DOC = "topics_conversations"
|
||||
TOPICS_SCHEMA = "hux.conversation_topics.v1"
|
||||
CONTROLS = ("forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss")
|
||||
SENSITIVITY_RANK = {"public": 0, "personal": 1, "sensitive": 2, "restricted": 3}
|
||||
AUDIT_STALE_HOURS = 48
|
||||
SCHEMAS = contracts.load_all()
|
||||
|
||||
# Deliberately narrow: a false positive only asks the user before remembering,
|
||||
# a false negative would store something it should not. Detection is a hint,
|
||||
# never proof, so callers treat "uncertain" as sensitive (SO-21).
|
||||
TOPIC_PATTERNS: dict[str, re.Pattern[str]] = {
|
||||
"credentials": re.compile(r"(?i)\b(password|passphrase|api[ _-]?key|secret key|private key|token|2fa|otp|ssh key|login credentials?)\b"),
|
||||
"biometric": re.compile(r"(?i)\b(fingerprint|face ?id|retina|iris scan|voice ?print|dna|genome|biometric)\b"),
|
||||
"minors": re.compile(r"(?i)\b(my (son|daughter|kid|kids|child|children|toddler|baby)|(?:\d{1,2}|[a-z]+)[- ]year[- ]old (?:son|daughter|boy|girl|child)|school ?(?:pickup|run)|daycare|minor child)\b"),
|
||||
"location": re.compile(r"(?i)\b(home address|my address|lives? at \d|gps|coordinates|\d{1,5} [A-Z][a-z]+ (?:street|st|avenue|ave|road|rd|lane|ln|drive|dr)\b|latitude|longitude|track(?:ing)? (?:my|his|her) (?:phone|location))")
|
||||
,
|
||||
"health": re.compile(r"(?i)\b(diagnos(?:is|ed)|prescription|medication|therapist|therapy|symptoms?|doctor|clinic|hospital|surgery|chronic|depression|anxiety|cancer|diabetes|pregnan(?:t|cy)|mental health|blood (?:pressure|test))\b"),
|
||||
"finance": re.compile(r"(?i)\b(salary|income|mortgage|bank account|credit card|iban|routing number|debt|loan|tax return|net worth|investment|401k|savings account|bankrupt(?:cy)?)\b"),
|
||||
"legal": re.compile(r"(?i)\b(lawsuit|attorney|lawyer|court (?:date|case|order)|subpoena|arrest(?:ed)?|custody|divorce filing|criminal (?:record|charge)|restraining order|litigation)\b"),
|
||||
"relationships": re.compile(r"(?i)\b(my (?:wife|husband|partner|girlfriend|boyfriend|ex)|breakup|broke up|divorce|affair|dating|marriage counsel(?:l)?ing)\b"),
|
||||
}
|
||||
|
||||
|
||||
def detect_topics(text: str) -> list[str]:
|
||||
"""Sensitive topics a piece of text looks like it touches, most restrictive first. Heuristic and uncertain by design."""
|
||||
found = [topic for topic, pattern in TOPIC_PATTERNS.items() if pattern.search(text or "")]
|
||||
return sorted(found, key=lambda topic: (-SENSITIVITY_RANK[rules.PRIVACY_TOPICS[topic]["sensitivity"]], topic))
|
||||
|
||||
|
||||
def topic_sensitivity(topics: list[str]) -> str:
|
||||
"""Highest sensitivity among detected topics; ``personal`` when nothing matched."""
|
||||
ranked = sorted((rules.PRIVACY_TOPICS[t]["sensitivity"] for t in topics), key=SENSITIVITY_RANK.get, reverse=True)
|
||||
return ranked[0] if ranked else "personal"
|
||||
|
||||
|
||||
def _parse(stamp: str) -> datetime:
|
||||
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _iso(when: datetime) -> str:
|
||||
return when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def load_topics(store: TenantStore) -> dict[str, Any]:
|
||||
"""The ``conversation_topics`` document: conversation id -> scoping state."""
|
||||
if store.exists(FAMILY, TOPICS_DOC):
|
||||
return store.get(FAMILY, TOPICS_DOC)
|
||||
return {"id": TOPICS_DOC, "schema": TOPICS_SCHEMA, "items": {}}
|
||||
|
||||
|
||||
def _save_topics(store: TenantStore, doc: dict[str, Any]) -> None:
|
||||
store.put(FAMILY, doc)
|
||||
|
||||
|
||||
def conversation_state(store: TenantStore, conversation_id: str) -> dict[str, Any]:
|
||||
"""Scoping row for one conversation (empty when nothing was ever detected)."""
|
||||
return load_topics(store)["items"].get(conversation_id, {})
|
||||
|
||||
|
||||
def mark_topic(store: TenantStore, conversation_id: str, topic: str, now: str | None = None) -> dict[str, Any]:
|
||||
"""Scope ``topic`` to ``conversation_id``; the first sighting fixes ``decay_at``."""
|
||||
now = now or now_iso()
|
||||
with store.lock(FAMILY):
|
||||
doc = load_topics(store)
|
||||
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now, "memory_disabled": False, "forgotten": False})
|
||||
if topic not in row["topics"]:
|
||||
row["topics"].append(topic)
|
||||
decay = _parse(row["first_seen"]) + timedelta(days=min(rules.PRIVACY_TOPICS[t]["decay_days"] for t in row["topics"]))
|
||||
row["decay_at"] = _iso(decay)
|
||||
_save_topics(store, doc)
|
||||
return row
|
||||
|
||||
|
||||
def set_flag(store: TenantStore, conversation_id: str, flag: str, value: bool) -> None:
|
||||
"""Set ``memory_disabled`` or ``forgotten`` for a conversation."""
|
||||
with store.lock(FAMILY):
|
||||
doc = load_topics(store)
|
||||
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now_iso(), "memory_disabled": False, "forgotten": False})
|
||||
row[flag] = value
|
||||
_save_topics(store, doc)
|
||||
|
||||
|
||||
def memory_write_allowed(store: TenantStore, conversation_id: str | None, entry: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""Gate every memory write (SO-21, SO-23, SO-28). Returns (allowed, reason)."""
|
||||
from hux import events
|
||||
|
||||
if conversation_id:
|
||||
if events.is_private(store, conversation_id):
|
||||
return False, "private_mode"
|
||||
state = conversation_state(store, conversation_id)
|
||||
if state.get("forgotten"):
|
||||
return False, "conversation_forgotten"
|
||||
if state.get("memory_disabled"):
|
||||
return False, "memory_disabled"
|
||||
if entry.get("sensitivity") == "restricted":
|
||||
return False, "restricted"
|
||||
topic = entry.get("topic", "general")
|
||||
if topic in rules.PRIVACY_TOPICS and rules.PRIVACY_TOPICS[topic]["memory_write"] == "deny":
|
||||
return False, f"topic_{topic}"
|
||||
return True, "allowed"
|
||||
|
||||
|
||||
def blocked_conversations(store: TenantStore) -> set[str]:
|
||||
"""Conversations that contribute nothing to retrieval: forgotten or memory disabled (SO-23)."""
|
||||
return {cid for cid, row in load_topics(store)["items"].items() if row.get("forgotten") or row.get("memory_disabled")}
|
||||
|
||||
|
||||
def latest_audit(store: TenantStore) -> dict[str, Any] | None:
|
||||
"""Newest retention audit record, or None when the job never ran."""
|
||||
rows = sorted(store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"])
|
||||
return rows[-1] if rows else None
|
||||
|
||||
|
||||
def audit_stale(store: TenantStore, now: datetime | None = None) -> bool:
|
||||
"""SO-27: no retention audit in the last 48 hours."""
|
||||
last = latest_audit(store)
|
||||
now = now or datetime.now(timezone.utc)
|
||||
return last is None or now - _parse(last["ran_at"]) > timedelta(hours=AUDIT_STALE_HOURS)
|
||||
|
||||
|
||||
# -- retention ----------------------------------------------------------------
|
||||
|
||||
def run_retention(store: TenantStore, now: datetime | None = None, identity: Identity | None = None) -> dict[str, Any]:
|
||||
"""Expire memory, decay topic context, purge forgotten content, report; write and return ``hux.retention_audit.v1``."""
|
||||
from hux import events, memory
|
||||
|
||||
now = now or datetime.now(timezone.utc)
|
||||
expired = memory.expire_due(store, now)
|
||||
decayed = 0
|
||||
with store.lock(FAMILY):
|
||||
doc = load_topics(store)
|
||||
for conversation_id, row in doc["items"].items():
|
||||
if row.get("decayed") or not row.get("topics") or _parse(row.get("decay_at", "9999-01-01T00:00:00Z")) > now:
|
||||
continue
|
||||
notice = rules.privacy_policy()["topics"]
|
||||
text = next(t["notice"] for t in notice if t["topic"] == row["topics"][0])
|
||||
decayed += events.rewrite_full(store, conversation_id, text, "topic context decayed", lambda r: r.get("sensitivity") in {"sensitive", "restricted"})
|
||||
row["decayed"] = True
|
||||
_save_topics(store, doc)
|
||||
purged = memory.purge_forgotten(store)
|
||||
record = {
|
||||
"schema": "hux.retention_audit.v1",
|
||||
"id": new_id("aud"),
|
||||
"ran_at": _iso(now),
|
||||
"policy_version": rules.privacy_policy()["version"],
|
||||
"results": [
|
||||
{"action": "expire_memory", "count": expired},
|
||||
{"action": "decay_topic_context", "count": decayed},
|
||||
{"action": "purge_forgotten_content", "count": purged},
|
||||
{"action": "report", "count": 1},
|
||||
],
|
||||
}
|
||||
problems = contracts.validate_record(record, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("retention audit failed contract validation", problems)
|
||||
return _strip(store.put(RETENTION_FAMILY, record))
|
||||
|
||||
|
||||
def forget_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> dict[str, Any]:
|
||||
"""Mark a conversation forgotten: its memory entries move to forgotten, its events go to full redaction."""
|
||||
from hux import events, memory
|
||||
|
||||
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, "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]:
|
||||
return {key: value for key, value in record.items() if key != "revision"}
|
||||
|
||||
|
||||
def get_policy(request: Request) -> Response:
|
||||
"""``GET /hux/v1/privacy/policy``: the frozen policy; ``HUX-Audit-Stale`` says whether retention ran lately (SO-27)."""
|
||||
request.audit("privacy.policy", "policy")
|
||||
stale = audit_stale(request.store)
|
||||
return Response(200, rules.privacy_policy(), {"HUX-Audit-Stale": "true" if stale else "false"})
|
||||
|
||||
|
||||
def post_notice(request: Request) -> Response:
|
||||
"""``POST /hux/v1/privacy/notices``: record a just-in-time notice as shown and scope the topic to the conversation."""
|
||||
from hux import events
|
||||
|
||||
body = request.body if isinstance(request.body, dict) else {}
|
||||
topic, conversation_id = body.get("topic"), body.get("conversation_id")
|
||||
if topic not in rules.PRIVACY_TOPICS or not isinstance(conversation_id, str):
|
||||
raise Invalid("topic and conversation_id are required")
|
||||
controls = [c for c in body.get("controls", list(CONTROLS)) if c in CONTROLS] or list(CONTROLS)
|
||||
text = next(t["notice"] for t in rules.privacy_policy()["topics"] if t["topic"] == topic)
|
||||
notice = {"schema": "hux.privacy_notice.v1", "topic": topic, "conversation_id": conversation_id, "text": text, "controls": sorted(set(controls), key=CONTROLS.index), "shown_at": now_iso()}
|
||||
problems = contracts.validate_record(notice, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("notice failed contract validation", problems)
|
||||
request.store.append(FAMILY, "notices", notice)
|
||||
mark_topic(request.store, conversation_id, topic)
|
||||
chosen = body.get("chosen")
|
||||
if chosen == "disable_memory_here":
|
||||
set_flag(request.store, conversation_id, "memory_disabled", True)
|
||||
elif chosen == "forget_this_conversation":
|
||||
forget_conversation(request.store, request.identity, 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)
|
||||
|
||||
|
||||
def post_forget(request: Request) -> Response:
|
||||
"""``POST /hux/v1/conversations/{id}/forget``: user-initiated forget; unknown conversations are 404 (SO-18)."""
|
||||
from hux import events
|
||||
|
||||
conversation_id = request.params["id"]
|
||||
if not (events.conversation_known(request.store, conversation_id) or conversation_id in load_topics(request.store)["items"]):
|
||||
raise NotFound("conversation not found")
|
||||
result = forget_conversation(request.store, request.identity, conversation_id)
|
||||
request.audit("privacy.forget", conversation_id)
|
||||
return Response(200, result)
|
||||
|
||||
|
||||
def get_audit(request: Request) -> Response:
|
||||
"""``GET /hux/v1/privacy/audit``: retention audit records, newest first."""
|
||||
rows = sorted(request.store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"], reverse=True)
|
||||
request.audit("privacy.audit", "retention")
|
||||
return page([_strip(row) for row in rows[:PAGE_LIMIT]], None)
|
||||
|
||||
|
||||
PAGE_LIMIT = 200
|
||||
|
||||
|
||||
def get_conversation_privacy(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}/privacy``: forget/disable state and topics, so the hook can stop proposing memory early."""
|
||||
from hux import events
|
||||
|
||||
conversation_id = check_id(request.params["id"])
|
||||
if not events.conversation_known(request.store, conversation_id):
|
||||
raise NotFound("conversation not found")
|
||||
state = conversation_state(request.store, conversation_id)
|
||||
mode = None
|
||||
try:
|
||||
from hux.organization import CONVERSATIONS
|
||||
mode = request.store.get(CONVERSATIONS, conversation_id).get("mode")
|
||||
except (ModuleNotFoundError, ImportError, NotFound):
|
||||
mode = None
|
||||
request.audit("privacy.conversation_state", conversation_id)
|
||||
return Response(200, {
|
||||
"conversation_id": conversation_id,
|
||||
"forgotten": bool(state.get("forgotten")),
|
||||
"memory_disabled": bool(state.get("memory_disabled")),
|
||||
"topics": sorted(state.get("topics", {})) if isinstance(state.get("topics"), dict) else list(state.get("topics", [])),
|
||||
"mode": mode,
|
||||
"memory_writes_allowed": not (state.get("forgotten") or state.get("memory_disabled") or mode == "private"),
|
||||
})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-10 routes."""
|
||||
router.add("GET", "/hux/v1/privacy/policy", "HUX-10", "privacy.policy", get_policy)
|
||||
router.add("POST", "/hux/v1/privacy/notices", "HUX-10", "privacy.notice", post_notice)
|
||||
router.add("POST", "/hux/v1/conversations/{id}/forget", "HUX-10", "privacy.forget", post_forget)
|
||||
router.add("GET", "/hux/v1/privacy/audit", "HUX-10", "privacy.audit", get_audit)
|
||||
router.add("GET", "/hux/v1/conversations/{id}/privacy", "HUX-10", "privacy.conversation_state", get_conversation_privacy)
|
||||
235
dockerfiles/hermes-hux-foundation/hux/redaction.py
Normal file
235
dockerfiles/hermes-hux-foundation/hux/redaction.py
Normal file
@ -0,0 +1,235 @@
|
||||
"""Redaction pipeline shared by every HUX family (SO-11..SO-14, SO-19).
|
||||
|
||||
Order matters and is fixed here: drop detail keys outside the per-kind
|
||||
allowlist, scrub secret patterns from every remaining string, cap sizes,
|
||||
then derive ``redaction.level``. Serve-time redaction (``redact_record``) is
|
||||
applied again on the way out so a reader that ignores the level still never
|
||||
sees more than the surface is allowed to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
DETAIL_CAP_BYTES = 32 * 1024
|
||||
LINE_CAP_BYTES = 64 * 1024
|
||||
SUMMARY_MAX = 280
|
||||
URI_SCHEMES = ("https://", "hux://", "artifact://")
|
||||
WORKSPACE_PREFIX = "/opt/data/workspace"
|
||||
HASH_KEYS = frozenset({"hash", "argument_hash"})
|
||||
HUMAN_SURFACES = frozenset({"telegram", "voice"})
|
||||
FULL_KEEP = frozenset({"schema", "id", "seq", "ts", "conversation_id", "kind", "provenance", "sensitivity", "redaction", "turn", "identity"})
|
||||
|
||||
# Each pattern is (class, regex). Classes end up in the placeholder so a
|
||||
# reviewer can tell what kind of thing was removed without seeing it.
|
||||
SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)")),
|
||||
("bearer", re.compile(r"(?i)bearer\s+[A-Za-z0-9._-]{16,}")),
|
||||
("jwt", re.compile(r"eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]+)?")),
|
||||
("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{20,}")),
|
||||
("github_token", re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}")),
|
||||
("gitlab_token", re.compile(r"glpat-[A-Za-z0-9_-]{20,}")),
|
||||
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}")),
|
||||
("slack_token", re.compile(r"xox[abp]-[A-Za-z0-9-]{10,}")),
|
||||
("vault_token", re.compile(r"\b(?:hvs|hvb|hvr)\.[A-Za-z0-9_-]{20,}")),
|
||||
("password", re.compile(r"(?i)(?:password|passwd|pwd|secret|token)\s*[=:]\s*\S+")),
|
||||
("hex", re.compile(r"\b[0-9a-fA-F]{32,}\b")),
|
||||
("base64", re.compile(r"\b(?=[A-Za-z0-9+/]*[0-9])(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[A-Z])[A-Za-z0-9+/]{40,}={0,2}")),
|
||||
)
|
||||
|
||||
# Detail keys each event kind may persist. Everything else is dropped before
|
||||
# a byte reaches disk, never filtered on read only.
|
||||
DETAIL_ALLOWLIST: dict[str, frozenset[str]] = {
|
||||
"message.user": frozenset({"message_id", "chars", "has_attachments"}),
|
||||
"message.assistant": frozenset({"message_id", "chars", "has_attachments"}),
|
||||
"decision.route": frozenset({"requested", "resolved_target", "provider", "effort", "reason"}),
|
||||
"decision.plan": frozenset({"steps"}),
|
||||
"tool.call": frozenset({"tool", "capability", "argument_names", "argument_hash", "argument_bytes", "target_path"}),
|
||||
"tool.result": frozenset({"tool", "ok", "duration_ms", "bytes", "exit_code"}),
|
||||
"approval.requested": frozenset({"approval_id", "capability", "choice", "external"}),
|
||||
"approval.resolved": frozenset({"approval_id", "capability", "choice", "external"}),
|
||||
"side_effect.blocked": frozenset({"approval_id", "capability", "choice", "external"}),
|
||||
"side_effect.released": frozenset({"approval_id", "capability", "choice", "external"}),
|
||||
"memory.proposed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"memory.committed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"memory.forgotten": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"memory.suppressed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"memory.retrieval_removed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"artifact.created": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
|
||||
"artifact.version": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
|
||||
"artifact.promoted": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
|
||||
"run.started": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"run.cancelled": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"run.completed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"run.failed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"delegation.started": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"delegation.completed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"delegation.failed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"budget.exhausted": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"privacy.notice": frozenset({"schema", "topic", "conversation_id", "text", "controls", "shown_at"}),
|
||||
"citation.attached": frozenset({"citation_id", "message_id", "passage_ids", "verdict"}),
|
||||
"mode.changed": frozenset({"mode", "previous_mode", "route_id"}),
|
||||
"suggestion.shown": frozenset({"suggestion_id", "action"}),
|
||||
"suggestion.dismissed": frozenset({"suggestion_id", "action"}),
|
||||
"release.transition": frozenset({"release_id", "state", "previous_state"}),
|
||||
}
|
||||
|
||||
|
||||
def canaries(environ: dict[str, str] | None = None) -> list[str]:
|
||||
"""Literal secrets the pod was started with (SO-07); every one is scrubbed wherever it appears."""
|
||||
environ = os.environ if environ is None else environ
|
||||
names = ("HUX_ROUTER_KEY", "HUX_RELAY_KEY", "HUX_WORKER_KEY")
|
||||
values = [environ.get(name, "") for name in names]
|
||||
for name in names:
|
||||
key_file = environ.get(f"{name}_FILE", "")
|
||||
if key_file and os.path.exists(key_file):
|
||||
with open(key_file, encoding="utf-8", errors="replace") as handle:
|
||||
values.append(handle.read(4097).strip())
|
||||
path = environ.get("HUX_CANARY_FILE", "")
|
||||
if path and os.path.exists(path):
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
_, _, value = line.strip().partition("=")
|
||||
values.append(value.strip().strip("'\""))
|
||||
return [v for v in values if len(v) >= 8]
|
||||
|
||||
|
||||
def scrub_text(text: str, environ: dict[str, str] | None = None) -> tuple[str, list[str]]:
|
||||
"""Replace secret-looking substrings with ``[redacted:<class>]``; return the text and the classes hit."""
|
||||
hits: list[str] = []
|
||||
for canary in canaries(environ):
|
||||
if canary in text:
|
||||
text = text.replace(canary, "[redacted:canary]")
|
||||
hits.append("canary")
|
||||
for name, pattern in SECRET_PATTERNS:
|
||||
text, count = pattern.subn(f"[redacted:{name}]", text)
|
||||
if count:
|
||||
hits.append(name)
|
||||
return text, hits
|
||||
|
||||
|
||||
def scrub_value(value: Any, hits: list[str], environ: dict[str, str] | None = None) -> Any:
|
||||
"""Scrub every string inside a JSON-like value, collecting classes into ``hits``.
|
||||
|
||||
Content hashes (``hash``, ``argument_hash``) are digests by construction and
|
||||
are kept verbatim so the hex rule does not eat them.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
text, found = scrub_text(value, environ)
|
||||
hits.extend(found)
|
||||
return text
|
||||
if isinstance(value, list):
|
||||
return [scrub_value(item, hits, environ) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): item if key in HASH_KEYS and isinstance(item, str) else scrub_value(item, hits, environ)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def filter_detail(kind: str, detail: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Keep only the allowlisted keys for ``kind`` (SO-11) and apply the kind-specific shape rules."""
|
||||
if not detail:
|
||||
return {}
|
||||
allowed = DETAIL_ALLOWLIST.get(kind, frozenset())
|
||||
kept = {key: value for key, value in detail.items() if key in allowed}
|
||||
if kind == "decision.plan" and "steps" in kept:
|
||||
steps = kept["steps"] if isinstance(kept["steps"], list) else []
|
||||
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.
|
||||
# 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 []
|
||||
kept["argument_names"] = sorted({str(name)[:80] for name in names})[:64]
|
||||
return kept
|
||||
|
||||
|
||||
def cap_detail(detail: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""Replace detail over 32 KiB with a truncation marker (SO-13)."""
|
||||
size = len(json.dumps(detail, sort_keys=True, separators=(",", ":")).encode())
|
||||
if size > DETAIL_CAP_BYTES:
|
||||
return {"truncated": True, "bytes": size}, True
|
||||
return detail, False
|
||||
|
||||
|
||||
def filter_evidence(evidence: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""Keep well-formed refs, drop URIs outside the allowlisted schemes (SO-19), cap at 64."""
|
||||
kept: list[dict[str, Any]] = []
|
||||
for ref in evidence or []:
|
||||
if not isinstance(ref, dict) or not isinstance(ref.get("kind"), str) or not isinstance(ref.get("id"), str):
|
||||
continue
|
||||
clean = {"kind": ref["kind"], "id": ref["id"][:200]}
|
||||
uri = ref.get("uri")
|
||||
if isinstance(uri, str) and uri.startswith(URI_SCHEMES):
|
||||
clean["uri"] = uri[:2000]
|
||||
if isinstance(ref.get("hash"), str):
|
||||
clean["hash"] = ref["hash"]
|
||||
kept.append(clean)
|
||||
return kept[:64]
|
||||
|
||||
|
||||
def derive_level(sensitivity: str, hits: list[str], truncated: bool) -> dict[str, str]:
|
||||
"""Redaction block for a new event from its sensitivity and what the pipeline did."""
|
||||
reasons: list[str] = []
|
||||
level = "none"
|
||||
if sensitivity == "sensitive":
|
||||
level, reasons = "partial", ["sensitive topic"]
|
||||
if hits:
|
||||
level = "partial" if level == "none" else level
|
||||
reasons.append("scrubbed " + ",".join(sorted(set(hits)))[:120])
|
||||
if truncated:
|
||||
level = "partial" if level == "none" else level
|
||||
reasons.append("detail truncated")
|
||||
if sensitivity == "restricted":
|
||||
level, reasons = "full", ["restricted content"]
|
||||
block = {"level": level}
|
||||
if reasons:
|
||||
block["reason"] = "; ".join(reasons)[:200]
|
||||
return block
|
||||
|
||||
|
||||
def redact_record(record: dict[str, Any], surface: str) -> dict[str, Any]:
|
||||
"""Serve-time redaction (SO-14) for any record that carries ``redaction``; strips ``_meta`` (SO-51).
|
||||
|
||||
``none`` is served as stored except that telegram and voice are lifted to
|
||||
``partial``; ``partial`` drops ``detail``; ``full`` keeps only the
|
||||
contract-required envelope with the summary replaced by a placeholder.
|
||||
"""
|
||||
served = {key: value for key, value in record.items() if key != "_meta"}
|
||||
block = served.get("redaction")
|
||||
if not isinstance(block, dict):
|
||||
return served
|
||||
level = block.get("level", "none")
|
||||
if level == "none" and surface in HUMAN_SURFACES:
|
||||
level = "partial"
|
||||
served["redaction"] = {"level": "partial", "reason": f"{surface} surface never receives unredacted detail"}
|
||||
if level == "partial":
|
||||
served.pop("detail", None)
|
||||
if level == "full":
|
||||
served = {key: value for key, value in served.items() if key in FULL_KEEP}
|
||||
if "summary" in record:
|
||||
served["summary"] = "[redacted]"
|
||||
if "content" in record:
|
||||
served["content"] = ""
|
||||
return served
|
||||
|
||||
|
||||
def full_redaction(record: dict[str, Any], summary: str, reason: str) -> dict[str, Any]:
|
||||
"""Rewrite a stored event to ``redaction.level: full`` in place (forget and topic decay)."""
|
||||
kept = {key: value for key, value in record.items() if key in FULL_KEEP}
|
||||
kept["summary"] = summary[:SUMMARY_MAX] or "[redacted]"
|
||||
kept["redaction"] = {"level": "full", "reason": reason[:200]}
|
||||
if "idempotency_key" in record:
|
||||
kept["idempotency_key"] = record["idempotency_key"]
|
||||
return kept
|
||||
134
dockerfiles/hermes-hux-foundation/hux/release_security.py
Normal file
134
dockerfiles/hermes-hux-foundation/hux/release_security.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""Fail-closed authentication and policy checks for HUX-12 evidence.
|
||||
|
||||
The evidence producer is a loopback-only companion process. It receives a
|
||||
dedicated Vault key and a non-secret policy file; neither the browser relay nor
|
||||
the agent worker can use its trust class. This module deliberately performs no
|
||||
network discovery: the producer supplies observations and this boundary binds
|
||||
them to the configured Jenkins, Harbor, Flux, workload, pod and health facts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from hux.errors import Forbidden, Invalid
|
||||
|
||||
KEY_FILE_ENV = "HUX_RELEASE_EVIDENCE_KEY_FILE"
|
||||
POLICY_FILE_ENV = "HUX_RELEASE_EVIDENCE_POLICY_FILE"
|
||||
MAX_KEY_BYTES = 4096
|
||||
MAX_POLICY_BYTES = 64 * 1024
|
||||
POLICY_FIELDS = {"schema", "max_evidence_age_seconds", "workloads"}
|
||||
WORKLOAD_FIELDS = {
|
||||
"review_url_prefix", "jenkins_job_url", "image_repository",
|
||||
"flux_kustomization", "health_url",
|
||||
}
|
||||
|
||||
|
||||
def _regular_file(path: str, modes: set[int], limit: int) -> bytes:
|
||||
"""Read one bounded regular file without accepting a symlink or loose mode."""
|
||||
if not path:
|
||||
raise Invalid("HUX-12 evidence configuration is missing")
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = os.open(Path(path), flags)
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) not in modes:
|
||||
raise Invalid("HUX-12 evidence file permissions are invalid")
|
||||
data = os.read(descriptor, limit + 1)
|
||||
except OSError as error:
|
||||
raise Invalid("HUX-12 evidence configuration is unavailable") from error
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
if not data or len(data) > limit:
|
||||
raise Invalid("HUX-12 evidence file is empty or too large")
|
||||
return data
|
||||
|
||||
|
||||
def evidence_key(environ: Mapping[str, str]) -> str:
|
||||
"""Return the file-only producer key; inline environment secrets are ignored."""
|
||||
data = _regular_file(environ.get(KEY_FILE_ENV, ""), {0o400}, MAX_KEY_BYTES)
|
||||
try:
|
||||
key = data.decode("utf-8", errors="strict").strip()
|
||||
except UnicodeDecodeError as error:
|
||||
raise Invalid("HUX-12 evidence key is invalid") from error
|
||||
if len(key) < 32:
|
||||
raise Invalid("HUX-12 evidence key is too short")
|
||||
return key
|
||||
|
||||
|
||||
def _https_url(value: Any, field: str, *, prefix: bool = False) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > 500:
|
||||
raise Invalid(f"{field} is missing or too long")
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise Invalid(f"{field} must be a credential-free HTTPS URL")
|
||||
if prefix and not value.endswith("/"):
|
||||
raise Invalid(f"{field} must end with /")
|
||||
return value
|
||||
|
||||
|
||||
def load_policy(environ: Mapping[str, str]) -> dict[str, Any]:
|
||||
"""Load and strictly validate the non-secret producer policy."""
|
||||
raw = _regular_file(environ.get(POLICY_FILE_ENV, ""), {0o400, 0o440, 0o444}, MAX_POLICY_BYTES)
|
||||
try:
|
||||
policy = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise Invalid("HUX-12 evidence policy is not valid JSON") from error
|
||||
if not isinstance(policy, dict) or set(policy) != POLICY_FIELDS or policy.get("schema") != "hux.release_evidence_policy.v1":
|
||||
raise Invalid("HUX-12 evidence policy shape is invalid")
|
||||
age = policy.get("max_evidence_age_seconds")
|
||||
workloads = policy.get("workloads")
|
||||
if not isinstance(age, int) or isinstance(age, bool) or not 60 <= age <= 86400:
|
||||
raise Invalid("HUX-12 evidence age bound is invalid")
|
||||
if not isinstance(workloads, dict) or not workloads or len(workloads) > 16:
|
||||
raise Invalid("HUX-12 workload policy is invalid")
|
||||
for name, item in workloads.items():
|
||||
if not isinstance(name, str) or not isinstance(item, dict) or set(item) != WORKLOAD_FIELDS:
|
||||
raise Invalid("HUX-12 workload policy shape is invalid")
|
||||
_https_url(item["review_url_prefix"], "review_url_prefix", prefix=True)
|
||||
_https_url(item["jenkins_job_url"], "jenkins_job_url")
|
||||
_https_url(item["health_url"], "health_url")
|
||||
if not re.fullmatch(r"[a-z0-9./_-]+", str(item["image_repository"])):
|
||||
raise Invalid("HUX-12 image repository is invalid")
|
||||
if not re.fullmatch(r"[a-z0-9-]{1,63}", str(item["flux_kustomization"])):
|
||||
raise Invalid("HUX-12 Flux kustomization is invalid")
|
||||
return policy
|
||||
|
||||
|
||||
def configured(environ: Mapping[str, str]) -> bool:
|
||||
"""Whether producer authentication and policy are both healthy."""
|
||||
try:
|
||||
evidence_key(environ)
|
||||
load_policy(environ)
|
||||
except Invalid:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def workload_policy(environ: Mapping[str, str], workload: str) -> dict[str, Any]:
|
||||
"""Return the exact allowlist for a workload or fail closed."""
|
||||
item = load_policy(environ)["workloads"].get(workload)
|
||||
if item is None:
|
||||
raise Invalid("workload is not enabled for release evidence")
|
||||
return item
|
||||
|
||||
|
||||
def require_review_creator(trust: str) -> None:
|
||||
"""Only the authenticated browser path may record the human review decision."""
|
||||
if trust != "router":
|
||||
raise Forbidden("reviewed releases require router trust")
|
||||
|
||||
|
||||
def require_evidence_producer(trust: str) -> None:
|
||||
"""Only the dedicated producer may advance a reviewed release."""
|
||||
if trust != "evidence":
|
||||
raise Forbidden("release transitions require evidence trust")
|
||||
337
dockerfiles/hermes-hux-foundation/hux/releases.py
Normal file
337
dockerfiles/hermes-hux-foundation/hux/releases.py
Normal file
@ -0,0 +1,337 @@
|
||||
"""HUX-12 immutable, hash-chained deployment follow-through.
|
||||
|
||||
Each release is bound to one project and conversation. The current view is
|
||||
reconstructed from append-only entries; every read verifies sequence, hash
|
||||
chain and contract before returning anything. ``live_verified`` is reachable
|
||||
only from a digest-converged pod with a passing health check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from hux import contracts, redaction, release_security
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.store import check_id, new_id, now_iso
|
||||
|
||||
CARD = "HUX-12"
|
||||
FAMILY = "release_ledger"
|
||||
MAX_RELEASES = 500
|
||||
ZERO_HASH = "sha256:" + "0" * 64
|
||||
ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
|
||||
WORKLOADS = frozenset({"hermes-chat-router", "hermes-chat-tenant", "hermes-webui", "hermes-agent", "hermes-switchyard"})
|
||||
EVIDENCE_FIELDS = frozenset({"review_url", "merge_commit", "ci_build_url", "image_ref", "image_digest", "harbor_digest", "flux_revision", "pod_digest", "health_check", "rollback_target"})
|
||||
SHA = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
COMMIT = re.compile(r"^[0-9a-f]{40}$")
|
||||
IMAGE = re.compile(r"^[a-z0-9./-]+:[A-Za-z0-9._-]+@(sha256:[0-9a-f]{64})$")
|
||||
SCHEMAS = contracts.load_all()
|
||||
SCHEMAS["release-ledger.schema.json"] = contracts.load_schema("release-ledger.schema.json")
|
||||
|
||||
|
||||
def _body(request: Request, allowed: set[str]) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
extra = set(request.body) - allowed
|
||||
if extra:
|
||||
raise Invalid("unexpected release fields", sorted(extra))
|
||||
return request.body
|
||||
|
||||
|
||||
def _scope(request: Request) -> tuple[str, str]:
|
||||
from hux import organization
|
||||
|
||||
project_id, conversation_id = request.params["project_id"], request.params["id"]
|
||||
try:
|
||||
conversation = request.store.get(organization.CONVERSATIONS, conversation_id)
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("project or conversation not found") from error
|
||||
if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id):
|
||||
raise NotFound("project or conversation not found")
|
||||
return project_id, conversation_id
|
||||
|
||||
|
||||
def _actor(request: Request) -> dict[str, str]:
|
||||
if request.identity.trust == "evidence":
|
||||
return {"type": "system", "id": "release-evidence-producer"}
|
||||
if request.identity.trust == "worker":
|
||||
return {"type": "system", "id": "release-worker"}
|
||||
return {"type": "user", "id": request.identity.subject}
|
||||
|
||||
|
||||
def _safe_url(value: Any, *, relative: bool = False) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > 500:
|
||||
raise Invalid("evidence URL is missing or too long")
|
||||
parsed = urlsplit(value)
|
||||
if parsed.query or parsed.fragment or parsed.username or parsed.password:
|
||||
raise Invalid("evidence URLs may not contain credentials, queries or fragments")
|
||||
if relative:
|
||||
if not ((not parsed.scheme and value.startswith("/")) or parsed.scheme == "https"):
|
||||
raise Invalid("health URL must be HTTPS or a local absolute path")
|
||||
elif parsed.scheme != "https" or not parsed.netloc:
|
||||
raise Invalid("evidence URL must use HTTPS")
|
||||
if redaction.scrub_text(value)[1]:
|
||||
raise Invalid("evidence URL appears to contain a secret")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(data: Any) -> bytes:
|
||||
return json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
def _entry_hash(entry: dict[str, Any]) -> str:
|
||||
unsigned = {key: value for key, value in entry.items() if key != "entry_hash"}
|
||||
return "sha256:" + hashlib.sha256(_canonical(unsigned)).hexdigest()
|
||||
|
||||
|
||||
def _validate_release(release: dict[str, Any]) -> None:
|
||||
problems = contracts.validate("release.schema.json", release, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("release failed contract validation", problems)
|
||||
|
||||
|
||||
def _validate_entry(entry: dict[str, Any]) -> None:
|
||||
problems = contracts.validate("release-ledger.schema.json", entry, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("release ledger entry failed contract validation", problems)
|
||||
|
||||
|
||||
def _rows(request: Request, release_id: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
check_id(release_id)
|
||||
except Invalid as error:
|
||||
raise NotFound("release not found") from error
|
||||
rows = request.store.read(FAMILY, release_id)
|
||||
if not rows:
|
||||
raise NotFound("release not found")
|
||||
previous = ZERO_HASH
|
||||
for sequence, row in enumerate(rows, 1):
|
||||
if row.get("sequence") != sequence or row.get("previous_hash") != previous or row.get("entry_hash") != _entry_hash(row):
|
||||
raise Invalid("release ledger integrity check failed")
|
||||
_validate_entry(row)
|
||||
previous = row["entry_hash"]
|
||||
return rows
|
||||
|
||||
|
||||
def _append(request: Request, release: dict[str, Any], project_id: str, conversation_id: str, previous: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
entry = {
|
||||
"schema": "hux.release_ledger_entry.v1", "release_id": release["id"], "owner": request.identity.subject,
|
||||
"project_id": project_id, "conversation_id": conversation_id, "sequence": len(previous) + 1,
|
||||
"at": now_iso(), "previous_hash": previous[-1]["entry_hash"] if previous else ZERO_HASH, "snapshot": release,
|
||||
}
|
||||
entry["entry_hash"] = _entry_hash(entry)
|
||||
_validate_entry(entry)
|
||||
request.store.append(FAMILY, release["id"], entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _view(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"release": entry["snapshot"],
|
||||
"scope": {"project_id": entry["project_id"], "conversation_id": entry["conversation_id"]},
|
||||
"revision": entry["sequence"],
|
||||
"ledger_hash": entry["entry_hash"],
|
||||
}
|
||||
|
||||
|
||||
def _key(request: Request) -> str:
|
||||
key = request.idempotency_key()
|
||||
if not key:
|
||||
raise Invalid("Idempotency-Key is required")
|
||||
return key
|
||||
|
||||
|
||||
def _fingerprint(body: dict[str, Any], path: str) -> str:
|
||||
return hashlib.sha256(_canonical({"body": body, "path": path})).hexdigest()
|
||||
|
||||
|
||||
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
|
||||
for row in request.store.read(FAMILY, "idempotency"):
|
||||
if row.get("key") != key:
|
||||
continue
|
||||
if row.get("fingerprint") != fingerprint:
|
||||
raise Conflict("Idempotency-Key was already used for different release evidence")
|
||||
rows = _rows(request, row["release_id"])
|
||||
if row["sequence"] > len(rows):
|
||||
raise Invalid("release idempotency ledger is inconsistent")
|
||||
return _view(rows[row["sequence"] - 1])
|
||||
return None
|
||||
|
||||
|
||||
def _remember(request: Request, key: str, fingerprint: str, release_id: str, sequence: int) -> None:
|
||||
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "release_id": release_id, "sequence": sequence, "at": now_iso()})
|
||||
|
||||
|
||||
def _initial_evidence(body: dict[str, Any]) -> dict[str, Any]:
|
||||
evidence = body.get("evidence")
|
||||
if not isinstance(evidence, dict) or set(evidence) != {"review_url"}:
|
||||
raise Invalid("reviewed release requires exactly evidence.review_url")
|
||||
return {"review_url": _safe_url(evidence["review_url"])}
|
||||
|
||||
|
||||
def create_release(request: Request) -> Response:
|
||||
"""Create the sole reviewed ledger for one scoped workload commit."""
|
||||
release_security.require_review_creator(request.identity.trust)
|
||||
body = _body(request, {"workload", "commit", "feature_flags", "evidence"})
|
||||
project_id, conversation_id = _scope(request)
|
||||
key, fingerprint = _key(request), _fingerprint(body, request.path)
|
||||
if request.if_match() != 0:
|
||||
raise Invalid("If-Match: 0 is required when creating a release")
|
||||
workload, commit = body.get("workload"), body.get("commit")
|
||||
if workload not in WORKLOADS or not isinstance(commit, str) or not COMMIT.fullmatch(commit):
|
||||
raise Invalid("workload and exact 40-character commit are required")
|
||||
flags = body.get("feature_flags", [])
|
||||
if not isinstance(flags, list) or len(flags) > 32:
|
||||
raise Invalid("feature_flags must be a bounded list")
|
||||
with request.store.lock(FAMILY):
|
||||
replayed = _replay(request, key, fingerprint)
|
||||
if replayed is not None:
|
||||
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
|
||||
releases = [name for name in request.store.ledgers(FAMILY) if name.startswith("rel_")]
|
||||
if len(releases) >= MAX_RELEASES:
|
||||
raise TooLarge("release ledger limit reached")
|
||||
for name in releases:
|
||||
entry = _rows(request, name)[0]
|
||||
release = entry["snapshot"]
|
||||
if entry["project_id"] == project_id and entry["conversation_id"] == conversation_id and release["workload"] == workload and release["commit"] == commit:
|
||||
raise Conflict("a release already exists for this scoped workload commit")
|
||||
release = {
|
||||
"schema": "hux.release.v1", "id": new_id("rel"), "workload": workload, "feature_flags": flags,
|
||||
"commit": commit, "state": "reviewed", "evidence": _initial_evidence(body), "transitions": [],
|
||||
}
|
||||
_validate_release(release)
|
||||
entry = _append(request, release, project_id, conversation_id, [])
|
||||
result = _view(entry)
|
||||
_remember(request, key, fingerprint, release["id"], 1)
|
||||
request.audit("releases.create", release["id"])
|
||||
return Response(201, result, {"ETag": "1", "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def _transition_evidence(current: dict[str, Any], target: str, supplied: Any) -> dict[str, Any]:
|
||||
if not isinstance(supplied, dict) or set(supplied) - EVIDENCE_FIELDS:
|
||||
raise Invalid("evidence must be a bounded object of known fields")
|
||||
expected = {
|
||||
"merged": {"merge_commit"},
|
||||
"built": {"ci_build_url", "image_ref", "image_digest", "harbor_digest"},
|
||||
"verified": set(),
|
||||
"deployed": {"flux_revision"},
|
||||
"converged": {"pod_digest"},
|
||||
"live_verified": {"health_check"},
|
||||
"rolled_back": {"rollback_target"},
|
||||
}[target]
|
||||
if set(supplied) != expected:
|
||||
raise Invalid(f"{target} requires exactly: {', '.join(sorted(expected)) or 'no new evidence'}")
|
||||
evidence = {**current.get("evidence", {}), **supplied}
|
||||
if target == "merged" and not COMMIT.fullmatch(str(evidence.get("merge_commit", ""))):
|
||||
raise Invalid("merged requires an exact merge commit")
|
||||
if target == "built":
|
||||
match = IMAGE.fullmatch(str(evidence.get("image_ref", "")))
|
||||
digest = evidence.get("image_digest")
|
||||
if not match or not SHA.fullmatch(str(digest)) or evidence.get("harbor_digest") != digest or match.group(1) != digest:
|
||||
raise Invalid("image ref, image digest and Harbor digest must match exactly")
|
||||
evidence["ci_build_url"] = _safe_url(evidence.get("ci_build_url"))
|
||||
if target == "verified" and evidence.get("harbor_digest") != evidence.get("image_digest"):
|
||||
raise Invalid("verified image does not match Harbor")
|
||||
if target == "deployed" and not re.fullmatch(r"main@sha1:[0-9a-f]{40}", str(evidence.get("flux_revision", ""))):
|
||||
raise Invalid("deployed requires an exact Flux revision")
|
||||
if target == "converged" and evidence.get("pod_digest") != evidence.get("image_digest"):
|
||||
raise Invalid("pod digest does not equal the built image digest")
|
||||
if target == "live_verified":
|
||||
health = evidence.get("health_check")
|
||||
if not isinstance(health, dict) or set(health) != {"url", "status", "at"} or health.get("status") != "pass":
|
||||
raise Invalid("live verification requires one passing health check")
|
||||
health["url"] = _safe_url(health["url"], relative=True)
|
||||
if target == "rolled_back" and not SHA.fullmatch(str(evidence.get("rollback_target", ""))):
|
||||
raise Invalid("rollback requires an exact target digest")
|
||||
return evidence
|
||||
|
||||
|
||||
def _evidence_refs(target: str, evidence: dict[str, Any]) -> list[dict[str, str]]:
|
||||
mapping = {
|
||||
"merged": ("build", "merge_commit"), "built": ("build", "image_digest"),
|
||||
"verified": ("build", "harbor_digest"), "deployed": ("flux", "flux_revision"),
|
||||
"converged": ("pod", "pod_digest"), "live_verified": ("receipt", "health_check"),
|
||||
"rolled_back": ("pod", "rollback_target"),
|
||||
}
|
||||
kind, key = mapping[target]
|
||||
value = evidence[key]
|
||||
identifier = value["at"] if key == "health_check" else value
|
||||
return [{"kind": kind, "id": str(identifier)[:200]}]
|
||||
|
||||
|
||||
def transition_release(request: Request) -> Response:
|
||||
"""Append exactly one legal, evidence-backed release transition."""
|
||||
release_security.require_evidence_producer(request.identity.trust)
|
||||
body = _body(request, {"to", "evidence"})
|
||||
project_id, conversation_id = _scope(request)
|
||||
key, fingerprint = _key(request), _fingerprint(body, request.path)
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required")
|
||||
with request.store.lock(FAMILY):
|
||||
replayed = _replay(request, key, fingerprint)
|
||||
if replayed is not None:
|
||||
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
|
||||
rows = _rows(request, request.params["release_id"])
|
||||
head, current = rows[-1], rows[-1]["snapshot"]
|
||||
if head["project_id"] != project_id or head["conversation_id"] != conversation_id:
|
||||
raise NotFound("release not found")
|
||||
if expected != len(rows):
|
||||
raise Conflict("release revision does not match If-Match")
|
||||
target = body.get("to")
|
||||
if target == "rolled_back":
|
||||
if current["state"] not in {"built", "verified", "deployed", "converged", "live_verified"}:
|
||||
raise Conflict("there is no deployed image to roll back")
|
||||
elif current["state"] == "rolled_back" or target not in ORDER or ORDER.index(target) != ORDER.index(current["state"]) + 1:
|
||||
raise Conflict("release transition skips, reverses, or follows a terminal state")
|
||||
evidence = _transition_evidence(current, target, body.get("evidence"))
|
||||
stamp = now_iso()
|
||||
transition = {"from": current["state"], "to": target, "at": stamp, "by": _actor(request), "evidence": _evidence_refs(target, evidence)}
|
||||
release = {**current, "state": target, "evidence": evidence, "transitions": [*current["transitions"], transition]}
|
||||
_validate_release(release)
|
||||
entry = _append(request, release, project_id, conversation_id, rows)
|
||||
result = _view(entry)
|
||||
_remember(request, key, fingerprint, release["id"], entry["sequence"])
|
||||
request.audit("releases.transition", release["id"], reason=target)
|
||||
return Response(200, result, {"ETag": str(entry["sequence"]), "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def get_release(request: Request) -> Response:
|
||||
"""Read one verified release view within its project/conversation scope."""
|
||||
project_id, conversation_id = _scope(request)
|
||||
rows = _rows(request, request.params["release_id"])
|
||||
head = rows[-1]
|
||||
if head["project_id"] != project_id or head["conversation_id"] != conversation_id:
|
||||
raise NotFound("release not found")
|
||||
request.audit("releases.read", request.params["release_id"])
|
||||
return Response(200, _view(head), {"ETag": str(len(rows)), "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def list_releases(request: Request) -> Response:
|
||||
"""List verified heads for one scope, newest ledger first."""
|
||||
project_id, conversation_id = _scope(request)
|
||||
items = []
|
||||
for name in request.store.ledgers(FAMILY):
|
||||
if not name.startswith("rel_"):
|
||||
continue
|
||||
head = _rows(request, name)[-1]
|
||||
if head["project_id"] == project_id and head["conversation_id"] == conversation_id:
|
||||
items.append(_view(head))
|
||||
items.sort(key=lambda row: row["release"]["transitions"][-1]["at"] if row["release"]["transitions"] else "", reverse=True)
|
||||
request.audit("releases.list", conversation_id)
|
||||
response = page(items)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-12 routes."""
|
||||
base = "/hux/v1/projects/{project_id}/conversations/{id}/releases"
|
||||
router.add("POST", base, CARD, "releases.create", create_release)
|
||||
router.add("GET", base, CARD, "releases.list", list_releases)
|
||||
router.add("GET", base + "/{release_id}", CARD, "releases.read", get_release)
|
||||
router.add("POST", base + "/{release_id}/transitions", CARD, "releases.transition", transition_release)
|
||||
384
dockerfiles/hermes-hux-foundation/hux/research.py
Normal file
384
dockerfiles/hermes-hux-foundation/hux/research.py
Normal file
@ -0,0 +1,384 @@
|
||||
"""HUX-08 research model: sources, passages, citations and notebooks.
|
||||
|
||||
A source says where evidence came from, a passage is the exact excerpt, a
|
||||
citation ties a claim in a message to passages with a support verdict, and a
|
||||
notebook collects them per research question. The service never fetches a
|
||||
``uri`` (SO-19, SO-29): URIs are stored metadata under an allowlisted scheme.
|
||||
Every referenced id resolves under the caller's own subtree (SO-33); a foreign
|
||||
or unknown id is ``404`` so ids cannot be probed. Duplicates are merged on a
|
||||
server-computed ``dedupe_key`` and the original record is returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
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
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id, new_id, now_iso
|
||||
|
||||
FAMILY = "research"
|
||||
SOURCES, PASSAGES, CITATIONS, NOTEBOOKS = "sources", "passages", "citations", "notebooks"
|
||||
SCHEMES = frozenset({"http", "https", "artifact", "memory", "file"})
|
||||
SOURCE_KINDS = ("web", "document", "artifact", "memory", "tool_output", "dataset")
|
||||
CLASSIFICATIONS = ("primary", "secondary", "unknown")
|
||||
SUPPORT = ("supports", "partially_supports", "contradicts", "unverified")
|
||||
NOTEBOOK_TRANSITIONS = {"open": frozenset({"answered", "abandoned"}), "answered": frozenset(), "abandoned": frozenset()}
|
||||
MAX_SOURCES, MAX_PASSAGES = 10000, 20000
|
||||
_SCHEMAS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _schemas() -> dict[str, dict[str, Any]]:
|
||||
if not _SCHEMAS:
|
||||
_SCHEMAS.update(load_all())
|
||||
return _SCHEMAS
|
||||
|
||||
|
||||
def _check(record: dict[str, Any]) -> None:
|
||||
problems = validate_record(record, _schemas())
|
||||
if problems:
|
||||
raise Invalid(f"{record.get('schema')} record is not valid", problems)
|
||||
|
||||
|
||||
def _body(request: Request) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
|
||||
value = body.get(key)
|
||||
if value is None:
|
||||
if required:
|
||||
raise Invalid(f"{key} is required")
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
|
||||
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()
|
||||
|
||||
|
||||
def _lookup(store: TenantStore, ledger: str, key: str) -> str | None:
|
||||
for row in store.read(FAMILY, ledger):
|
||||
if row.get("key") == key:
|
||||
return row["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _resolve(store: TenantStore, family: str, record_id: Any) -> dict[str, Any]:
|
||||
"""Load a record under the caller's subtree; malformed or missing is 404 (SO-33).
|
||||
|
||||
Sources, passages and citations are immutable, so the store's ``revision``
|
||||
bookkeeping is stripped before they are served; notebooks keep theirs.
|
||||
"""
|
||||
try:
|
||||
record = store.get(family, check_id(record_id))
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound(f"{family[:-1]} not found") from error
|
||||
return record if family == NOTEBOOKS else {k: v for k, v in record.items() if k != "revision"}
|
||||
|
||||
|
||||
def _id_list(body: dict[str, Any], key: str, maximum: int = 32) -> list[str]:
|
||||
values = body.get(key, [])
|
||||
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
|
||||
raise Invalid(f"{key} must be a list of ids")
|
||||
unique = list(dict.fromkeys(values))
|
||||
if len(unique) > maximum:
|
||||
raise TooLarge(f"{key} exceeds {maximum} ids")
|
||||
return unique
|
||||
|
||||
|
||||
def _emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, detail: dict[str, Any], evidence: list[dict[str, Any]]) -> None:
|
||||
if conversation_id is None:
|
||||
return
|
||||
try:
|
||||
from hux.events import emit
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence)
|
||||
|
||||
|
||||
# -- sources ---------------------------------------------------------------------
|
||||
|
||||
def normalise_uri(uri: str) -> str:
|
||||
"""Canonical form used for dedupe: lowercase scheme and host, no fragment, no trailing slash."""
|
||||
parts = urlsplit(uri.strip())
|
||||
if parts.scheme.lower() not in SCHEMES:
|
||||
raise Invalid("uri scheme must be one of http, https, artifact, memory, file")
|
||||
path = parts.path.rstrip("/") or ("/" if parts.netloc else "")
|
||||
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, parts.query, ""))
|
||||
|
||||
|
||||
def create_source(request: Request) -> Response:
|
||||
"""``POST /hux/v1/sources``: record where evidence came from; duplicates return the original."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
kind = _string(body, "kind", 40)
|
||||
if kind not in SOURCE_KINDS:
|
||||
raise Invalid("unknown source kind")
|
||||
classification = body.get("classification", "unknown")
|
||||
if classification not in CLASSIFICATIONS:
|
||||
raise Invalid("unknown classification")
|
||||
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()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "source_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.source_create", existing, reason="deduplicated")
|
||||
return Response(200, _resolve(request.store, SOURCES, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(SOURCES) >= MAX_SOURCES:
|
||||
raise TooLarge("tenant has reached 10000 sources")
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.source.v1", "id": new_id("src"), "kind": kind, "title": title,
|
||||
"classification": classification, "retrieved_at": stamp, "dedupe_key": dedupe,
|
||||
"provenance": {"surface": request.identity.surface, "actor": _actor(request.identity), "recorded_at": stamp},
|
||||
}
|
||||
if uri is not None:
|
||||
record["uri"] = uri
|
||||
for field, limit in (("publisher", 200), ("published_at", 40), ("content_hash", 71)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
record[field] = body[field]
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None:
|
||||
record["provenance"]["conversation_id"] = check_id(conversation_id)
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(SOURCES, record)
|
||||
request.store.append(FAMILY, "source_keys", {"key": dedupe, "id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.source_create", stored["id"])
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
def _actor(identity: Identity) -> dict[str, str]:
|
||||
if identity.trust == "worker":
|
||||
return {"type": "system", "id": "worker"}
|
||||
return {"type": "user", "id": identity.subject}
|
||||
|
||||
|
||||
def get_source(request: Request) -> Response:
|
||||
"""``GET /hux/v1/sources/{id}``: one source record."""
|
||||
record = _resolve(request.store, SOURCES, request.params["id"])
|
||||
request.audit("research.source_get", record["id"])
|
||||
return Response(200, record)
|
||||
|
||||
|
||||
# -- passages --------------------------------------------------------------------
|
||||
|
||||
def create_passage(request: Request) -> Response:
|
||||
"""``POST /hux/v1/passages``: an exact excerpt of a source; hash and dedupe key are server-computed."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
source = _resolve(request.store, SOURCES, body.get("source_id"))
|
||||
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):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "passage_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.passage_create", existing, reason="deduplicated")
|
||||
return Response(200, _resolve(request.store, PASSAGES, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(PASSAGES) >= MAX_PASSAGES:
|
||||
raise TooLarge("tenant has reached 20000 passages")
|
||||
record: dict[str, Any] = {"schema": "hux.passage.v1", "id": new_id("psg"), "source_id": source["id"], "text": text, "hash": text_hash, "dedupe_key": dedupe}
|
||||
if body.get("locator") is not None:
|
||||
record["locator"] = body["locator"]
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(PASSAGES, record)
|
||||
request.store.append(FAMILY, "passage_keys", {"key": dedupe, "id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.passage_create", stored["id"])
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
# -- citations -------------------------------------------------------------------
|
||||
|
||||
def _message_ledger(message_id: str) -> str:
|
||||
return "msg_" + hashlib.sha256(message_id.encode("utf-8")).hexdigest()[:40]
|
||||
|
||||
|
||||
def attach_citation(request: Request) -> Response:
|
||||
"""``POST /hux/v1/messages/{id}/citations``: tie a claim to passages with a support verdict."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
message_id = request.params["id"]
|
||||
if len(message_id) > 120:
|
||||
raise Invalid("message id is too long")
|
||||
claim = _clean(_string(body, "claim", 1000))
|
||||
support = body.get("support", "unverified")
|
||||
if support not in SUPPORT:
|
||||
raise Invalid("unknown support verdict")
|
||||
passage_ids = _id_list(body, "passage_ids")
|
||||
if not passage_ids:
|
||||
raise Invalid("at least one passage_id is required")
|
||||
passages = [_resolve(request.store, PASSAGES, pid) for pid in passage_ids]
|
||||
dedupe = _sha("citation", message_id, claim, *sorted(passage_ids))
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "citation_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.citation_attach", existing, reason="deduplicated")
|
||||
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"] = _clean(body["note"])
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(CITATIONS, record)
|
||||
request.store.append(FAMILY, "citation_keys", {"key": dedupe, "id": stored["id"]})
|
||||
request.store.append(FAMILY, _message_ledger(message_id), {"id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.citation_attach", stored["id"])
|
||||
evidence = [{"kind": "passage", "id": p["id"], "hash": p["hash"]} for p in passages]
|
||||
conversation_id = body.get("conversation_id")
|
||||
_emit(request.store, request.identity, None if conversation_id is None else check_id(conversation_id), "citation.attached", f"Citation attached ({support})", {"message_id": message_id, "citation_id": stored["id"], "support": support}, evidence)
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
def list_citations(request: Request) -> Response:
|
||||
"""``GET /hux/v1/messages/{id}/citations``: citations with passages and sources embedded."""
|
||||
items = []
|
||||
for row in request.store.read(FAMILY, _message_ledger(request.params["id"])):
|
||||
citation = _resolve(request.store, CITATIONS, row["id"])
|
||||
passages = [_resolve(request.store, PASSAGES, pid) for pid in citation["passage_ids"]]
|
||||
sources = {p["source_id"]: _resolve(request.store, SOURCES, p["source_id"]) for p in passages}
|
||||
items.append({"citation": citation, "passages": passages, "sources": list(sources.values())})
|
||||
request.audit("research.citation_list", request.params["id"])
|
||||
return page(items)
|
||||
|
||||
|
||||
def validate_citations(store: TenantStore, citation_ids: list[str]) -> list[str]:
|
||||
"""Integrity problems for a set of citations: dangling passages, passage/source mismatch, contradictions without a note."""
|
||||
problems: list[str] = []
|
||||
for citation_id in citation_ids:
|
||||
try:
|
||||
citation = _resolve(store, CITATIONS, citation_id)
|
||||
except NotFound:
|
||||
problems.append(f"{citation_id}: citation does not resolve")
|
||||
continue
|
||||
if citation["support"] == "contradicts" and not citation.get("note"):
|
||||
problems.append(f"{citation_id}: contradicts without a note")
|
||||
for passage_id in citation["passage_ids"]:
|
||||
try:
|
||||
passage = _resolve(store, PASSAGES, passage_id)
|
||||
except NotFound:
|
||||
problems.append(f"{citation_id}: passage {passage_id} does not resolve")
|
||||
continue
|
||||
if not store.exists(SOURCES, passage["source_id"]):
|
||||
problems.append(f"{citation_id}: passage {passage_id} names missing source {passage['source_id']}")
|
||||
return problems
|
||||
|
||||
|
||||
# -- notebooks -------------------------------------------------------------------
|
||||
|
||||
def _text_list(body: dict[str, Any], key: str, current: list[str]) -> list[str]:
|
||||
values = body.get(key)
|
||||
if values is None:
|
||||
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 _clean(values)
|
||||
|
||||
|
||||
def create_notebook(request: Request) -> Response:
|
||||
"""``POST /hux/v1/notebooks``: open a research question for a conversation."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None:
|
||||
request.audit("research.notebook_create", existing, reason="idempotent_replay")
|
||||
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": _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,
|
||||
}
|
||||
_check(record)
|
||||
stored = request.store.put(NOTEBOOKS, record)
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.notebook_create", stored["id"])
|
||||
return Response(201, stored, {"ETag": "1"})
|
||||
|
||||
|
||||
def get_notebook(request: Request) -> Response:
|
||||
"""``GET /hux/v1/notebooks/{id}``: one notebook."""
|
||||
record = _resolve(request.store, NOTEBOOKS, request.params["id"])
|
||||
request.audit("research.notebook_get", record["id"])
|
||||
return Response(200, record, {"ETag": str(record["revision"])})
|
||||
|
||||
|
||||
def _merged_ids(store: TenantStore, family: str, current: list[str], body: dict[str, Any], key: str) -> list[str]:
|
||||
added = _id_list(body, key, maximum=500)
|
||||
for record_id in added:
|
||||
_resolve(store, family, record_id)
|
||||
return list(dict.fromkeys([*current, *added]))
|
||||
|
||||
|
||||
def patch_notebook(request: Request) -> Response:
|
||||
"""``PATCH /hux/v1/notebooks/{id}``: add references and notes, replace assumptions, move status (If-Match required)."""
|
||||
body = _body(request)
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required")
|
||||
with request.store.lock(FAMILY):
|
||||
notebook = _resolve(request.store, NOTEBOOKS, request.params["id"])
|
||||
if expected != notebook["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {notebook['revision']}", [str(notebook["revision"])])
|
||||
stamp = now_iso()
|
||||
updated = {
|
||||
**notebook,
|
||||
"source_ids": _merged_ids(request.store, SOURCES, notebook["source_ids"], body, "add_source_ids"),
|
||||
"passage_ids": _merged_ids(request.store, PASSAGES, notebook["passage_ids"], body, "add_passage_ids"),
|
||||
"citation_ids": _merged_ids(request.store, CITATIONS, notebook["citation_ids"], body, "add_citation_ids"),
|
||||
"assumptions": _text_list(body, "assumptions", notebook["assumptions"]),
|
||||
"unresolved_questions": _text_list(body, "unresolved_questions", notebook["unresolved_questions"]),
|
||||
"updated_at": stamp,
|
||||
}
|
||||
notes = body.get("add_notes", [])
|
||||
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": _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]
|
||||
status = body.get("status")
|
||||
if status is not None and status != notebook["status"]:
|
||||
if status not in NOTEBOOK_TRANSITIONS or status not in NOTEBOOK_TRANSITIONS[notebook["status"]]:
|
||||
raise Conflict(f"notebook cannot move from {notebook['status']} to {status}")
|
||||
updated["status"] = status
|
||||
_check(updated)
|
||||
stored = request.store.put(NOTEBOOKS, updated, expected_revision=expected)
|
||||
request.audit("research.notebook_patch", stored["id"])
|
||||
return Response(200, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-08 routes."""
|
||||
card = "HUX-08"
|
||||
router.add("POST", "/hux/v1/sources", card, "research.source_create", create_source)
|
||||
router.add("GET", "/hux/v1/sources/{id}", card, "research.source_get", get_source)
|
||||
router.add("POST", "/hux/v1/passages", card, "research.passage_create", create_passage)
|
||||
router.add("POST", "/hux/v1/messages/{id}/citations", card, "research.citation_attach", attach_citation)
|
||||
router.add("GET", "/hux/v1/messages/{id}/citations", card, "research.citation_list", list_citations)
|
||||
router.add("POST", "/hux/v1/notebooks", card, "research.notebook_create", create_notebook)
|
||||
router.add("GET", "/hux/v1/notebooks/{id}", card, "research.notebook_get", get_notebook)
|
||||
router.add("PATCH", "/hux/v1/notebooks/{id}", card, "research.notebook_patch", patch_notebook)
|
||||
138
dockerfiles/hermes-hux-foundation/hux/retention_scheduler.py
Normal file
138
dockerfiles/hermes-hux-foundation/hux/retention_scheduler.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""Bounded in-process HUX-10 retention scheduling for one tenant pod."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hux import privacy
|
||||
from hux.errors import Unauthorized
|
||||
from hux.identity import SLOT_RE, Identity, _read_subject_binding
|
||||
from hux.store import TenantStore
|
||||
|
||||
DAY_SECONDS = 86_400.0
|
||||
MAX_JITTER_SECONDS = 3_600
|
||||
MAX_BACKOFF_SECONDS = 900.0
|
||||
|
||||
|
||||
class BindingUnavailable(ValueError):
|
||||
"""The projected subject binding is absent or unsafe."""
|
||||
|
||||
|
||||
def subject_binding(environ: Mapping[str, str]) -> Identity:
|
||||
"""Read the canonical edge-published binding and combine it with the configured tenant slot."""
|
||||
slot = environ.get("HUX_TENANT_SLOT", "")
|
||||
raw_path = environ.get("HUX_SUBJECT_BINDING_FILE", "")
|
||||
if not SLOT_RE.fullmatch(slot) or not raw_path:
|
||||
raise BindingUnavailable("binding configuration unavailable")
|
||||
try:
|
||||
subject = _read_subject_binding(Path(raw_path))
|
||||
except Unauthorized as error:
|
||||
raise BindingUnavailable("binding file unavailable") from error
|
||||
if subject is None:
|
||||
raise BindingUnavailable("binding file unavailable")
|
||||
return Identity(slot, subject, "worker", "worker")
|
||||
|
||||
|
||||
class RetentionScheduler:
|
||||
"""Run privacy retention without leaving the API process or tenant lock domain."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
router: Any,
|
||||
*,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
runner: Callable[..., dict[str, Any]] | None = None,
|
||||
waiter: Callable[[float], bool] | None = None,
|
||||
daily_seconds: float = DAY_SECONDS,
|
||||
) -> None:
|
||||
self.router = router
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.runner = runner or privacy.run_retention
|
||||
self.daily_seconds = max(1.0, daily_seconds)
|
||||
self._stop = threading.Event()
|
||||
self.waiter = waiter or self._stop.wait
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._health: dict[str, Any] = {
|
||||
"enabled": bool(router.flags.enabled("HUX-10")),
|
||||
"status": "starting" if router.flags.enabled("HUX-10") else "disabled",
|
||||
"last_run": None,
|
||||
"last_success": None,
|
||||
"errors": 0,
|
||||
}
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
"""Return bounded, credential-free scheduler evidence."""
|
||||
with self._lock:
|
||||
return dict(self._health)
|
||||
|
||||
def _set(self, **changes: Any) -> None:
|
||||
with self._lock:
|
||||
self._health.update(changes)
|
||||
|
||||
def _jitter(self, identity: Identity) -> float:
|
||||
try:
|
||||
cap = int(self.router.environ.get("HUX_RETENTION_JITTER_SECONDS", "900"))
|
||||
except ValueError:
|
||||
cap = 900
|
||||
cap = min(MAX_JITTER_SECONDS, max(0, cap))
|
||||
if cap == 0:
|
||||
return 0.0
|
||||
digest = hashlib.sha256(f"{identity.tenant_slot}\0{identity.subject}".encode()).digest()
|
||||
return float(int.from_bytes(digest[:4], "big") % (cap + 1))
|
||||
|
||||
@staticmethod
|
||||
def _backoff(failures: int) -> float:
|
||||
return min(MAX_BACKOFF_SECONDS, float(5 * (2 ** min(max(failures - 1, 0), 7))))
|
||||
|
||||
def run(self) -> None:
|
||||
"""Wait for a binding, run stale startup work, then run once per day."""
|
||||
if not self.health()["enabled"]:
|
||||
return
|
||||
failures = 0
|
||||
first = True
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
identity = subject_binding(self.router.environ)
|
||||
tenant = TenantStore(self.router.data_root, identity)
|
||||
now = self.clock()
|
||||
ran = not first or privacy.audit_stale(tenant, now)
|
||||
if ran:
|
||||
self.runner(tenant, now, identity)
|
||||
stamp = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
self._set(last_run=stamp, last_success=stamp)
|
||||
failures = 0
|
||||
first = False
|
||||
self._set(status="healthy", errors=0)
|
||||
if self.waiter(self.daily_seconds + self._jitter(identity)):
|
||||
break
|
||||
except BindingUnavailable:
|
||||
failures += 1
|
||||
self._set(status="waiting_for_binding", errors=failures)
|
||||
if self.waiter(self._backoff(failures)):
|
||||
break
|
||||
except Exception: # noqa: BLE001 - background failure cannot take down the API
|
||||
failures += 1
|
||||
self._set(status="error", errors=failures)
|
||||
if self.waiter(self._backoff(failures)):
|
||||
break
|
||||
self._set(status="stopped")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start one daemon thread when HUX-10 is enabled."""
|
||||
if not self.health()["enabled"] or self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(target=self.run, name="hux-retention", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Wake and join the scheduler during server shutdown."""
|
||||
self._stop.set()
|
||||
if self._thread is not None and self._thread is not threading.current_thread():
|
||||
self._thread.join(timeout=2.0)
|
||||
self._set(status="stopped")
|
||||
@ -13,7 +13,7 @@ import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from hux_contracts import load_flags
|
||||
from hux.contracts import load_flags
|
||||
|
||||
EFFORT_ORDER = ("low", "medium", "high", "xhigh")
|
||||
|
||||
@ -44,11 +44,12 @@ RELEASE_EVIDENCE: dict[str, tuple[str, ...]] = {
|
||||
|
||||
CAPABILITIES = (
|
||||
"read_files", "write_files", "shell", "network", "web_search", "send_message",
|
||||
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy",
|
||||
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect",
|
||||
)
|
||||
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
|
||||
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
|
||||
_EXTERNAL = frozenset({"network", "deploy"})
|
||||
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
|
||||
|
||||
MODE_CATALOG: dict[str, dict[str, Any]] = {
|
||||
"fast": {
|
||||
@ -123,11 +124,12 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
|
||||
topic = entry.get("topic", "general")
|
||||
if sensitivity == "restricted" and entry.get("status") in {"proposed", "active"}:
|
||||
problems.append("restricted content may not be remembered")
|
||||
if sensitivity == "sensitive" and entry.get("approval_mode") != "ask":
|
||||
stored = entry.get("status") not in {"rejected", "no_store", "forgotten"}
|
||||
if sensitivity == "sensitive" and stored and entry.get("approval_mode") != "ask":
|
||||
problems.append("sensitive memory requires approval_mode=ask")
|
||||
if sensitivity == "sensitive" and entry.get("ttl", {}).get("policy") == "never":
|
||||
if sensitivity == "sensitive" and stored and entry.get("ttl", {}).get("policy") == "never":
|
||||
problems.append("sensitive memory must expire or decay")
|
||||
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and entry.get("status") != "rejected":
|
||||
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and stored:
|
||||
problems.append(f"topic {topic} may not be written to memory")
|
||||
ttl = entry.get("ttl", {})
|
||||
if ttl.get("policy") == "expires_at" and "expires_at" not in ttl:
|
||||
@ -140,12 +142,12 @@ 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 always asks."""
|
||||
"""Autonomy level -> capability -> allow|ask|deny. Deploy and external side effects always ask."""
|
||||
matrix: dict[str, dict[str, str]] = {}
|
||||
for level in ("ask_first", "safe", "autonomous"):
|
||||
row: dict[str, str] = {}
|
||||
for capability in CAPABILITIES:
|
||||
if capability == "deploy":
|
||||
if capability in _ALWAYS_ASK:
|
||||
row[capability] = "ask"
|
||||
elif capability in _READ_ONLY:
|
||||
row[capability] = "allow"
|
||||
@ -171,7 +173,7 @@ def effective_decision(policy: dict[str, Any], capability: str, now: datetime |
|
||||
if grant["decision"] == "deny":
|
||||
return "deny"
|
||||
decision = grant["decision"]
|
||||
if capability == "deploy" and decision == "allow":
|
||||
if capability in _ALWAYS_ASK and decision == "allow":
|
||||
return "ask"
|
||||
return decision
|
||||
|
||||
52
dockerfiles/hermes-hux-foundation/hux/server.py
Normal file
52
dockerfiles/hermes-hux-foundation/hux/server.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Process entry point: ``python -m hux.server``.
|
||||
|
||||
Environment: ``HUX_DATA_ROOT`` (tenant PVC path, default /opt/data),
|
||||
``HUX_BIND`` (default 127.0.0.1), ``HUX_PORT`` (default 8790), ``HUX_FLAGS``,
|
||||
``HUX_RELAY_KEY``, ``HUX_WORKER_KEY``, ``HUX_BUILD_COMMIT``, ``HUX_IMAGE_DIGEST``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from hux.http import Router, serve
|
||||
|
||||
FAMILIES = (
|
||||
"foundation", "events", "memory", "privacy", "organization", "artifacts",
|
||||
"modes", "research", "policy", "multimodal", "suggestions", "releases",
|
||||
)
|
||||
|
||||
|
||||
def build_router(data_root: Path, environ: dict[str, str] | None = None) -> Router:
|
||||
"""Create a router with every required family registered and route-bound to its flag."""
|
||||
router = Router(data_root, environ)
|
||||
for name in FAMILIES:
|
||||
module = importlib.import_module(f"hux.{name}")
|
||||
module.register(router)
|
||||
router.bind_capability_routes()
|
||||
return router
|
||||
|
||||
|
||||
def bind_address(env: dict[str, str]) -> str:
|
||||
"""Loopback only: the router is in the same pod, nothing else may reach the service."""
|
||||
bind = env.get("HUX_BIND", "127.0.0.1")
|
||||
if bind not in {"127.0.0.1", "::1", "localhost"}:
|
||||
raise SystemExit(f"refusing to bind {bind}: hux-foundation is loopback-only")
|
||||
return bind
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the server until killed."""
|
||||
env = dict(os.environ)
|
||||
router = build_router(Path(env.get("HUX_DATA_ROOT", "/opt/data")), env)
|
||||
server = serve(router, bind_address(env), int(env.get("HUX_PORT", "8790")))
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
223
dockerfiles/hermes-hux-foundation/hux/store.py
Normal file
223
dockerfiles/hermes-hux-foundation/hux/store.py
Normal file
@ -0,0 +1,223 @@
|
||||
"""Per-tenant durable storage on the tenant PVC.
|
||||
|
||||
Every path is derived from the caller's tenant slot and hashed subject, so
|
||||
there is no shared cross-tenant store and no way to address another tenant's
|
||||
data. Records are JSON documents with a ``revision`` for optimistic
|
||||
concurrency; ledgers are append-only JSONL. Writes are atomic (temp file +
|
||||
fsync + rename) and guarded by one lock per family per tenant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from collections.abc import Iterator
|
||||
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
from hux.identity import Identity
|
||||
|
||||
ID_RE = re.compile(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$")
|
||||
LAYOUT_VERSION = 1
|
||||
MAX_RECORD_BYTES = 256 * 1024
|
||||
MAX_FAMILY_RECORDS = 20000
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""RFC 3339 UTC timestamp with second precision and a Z suffix."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
"""Opaque, prefix-typed identifier matching common.schema.json#/$defs/id."""
|
||||
return f"{prefix}_{os.urandom(8).hex()}{int(time.time() * 1000) % 100000:05d}"
|
||||
|
||||
|
||||
def check_id(value: Any) -> str:
|
||||
"""Return ``value`` when it is a well-formed record id, else raise Invalid."""
|
||||
if not isinstance(value, str) or not ID_RE.match(value):
|
||||
raise Invalid("malformed id")
|
||||
return value
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes) -> None:
|
||||
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
||||
with open(tmp, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
class TenantStore:
|
||||
"""Files for exactly one (tenant slot, subject) pair."""
|
||||
|
||||
_locks: dict[str, threading.RLock] = {}
|
||||
_locks_guard = threading.Lock()
|
||||
|
||||
def __init__(self, root: Path, identity: Identity) -> None:
|
||||
self.identity = identity
|
||||
self.root = Path(root) / "hux" / f"v{LAYOUT_VERSION}" / identity.tenant_slot / identity.subject
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# -- locking -----------------------------------------------------------
|
||||
def lock(self, family: str) -> threading.RLock:
|
||||
"""One re-entrant lock per family per tenant directory."""
|
||||
key = f"{self.root}:{family}"
|
||||
with self._locks_guard:
|
||||
return self._locks.setdefault(key, threading.RLock())
|
||||
|
||||
# -- documents ---------------------------------------------------------
|
||||
def _doc_path(self, family: str, record_id: str) -> Path:
|
||||
directory = self.root / family
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory / f"{check_id(record_id)}.json"
|
||||
|
||||
def get(self, family: str, record_id: str) -> dict[str, Any]:
|
||||
"""Read one document or raise NotFound."""
|
||||
path = self._doc_path(family, record_id)
|
||||
if not path.exists():
|
||||
raise NotFound(f"{family} {record_id} not found")
|
||||
return json.loads(path.read_bytes())
|
||||
|
||||
def exists(self, family: str, record_id: str) -> bool:
|
||||
"""True when the document is present."""
|
||||
return self._doc_path(family, record_id).exists()
|
||||
|
||||
def put(self, family: str, record: dict[str, Any], expected_revision: int | None = None) -> dict[str, Any]:
|
||||
"""Create or replace a document, bumping ``revision``.
|
||||
|
||||
``expected_revision`` implements If-Match: it must equal the stored
|
||||
revision (or be None for a create of a new id) or Conflict is raised.
|
||||
"""
|
||||
record_id = check_id(record.get("id"))
|
||||
with self.lock(family):
|
||||
path = self._doc_path(family, record_id)
|
||||
current = json.loads(path.read_bytes()) if path.exists() else None
|
||||
if current is None:
|
||||
if expected_revision not in (None, 0):
|
||||
raise Conflict("record does not exist yet")
|
||||
if self.count(family) >= MAX_FAMILY_RECORDS:
|
||||
raise TooLarge(f"{family} is full")
|
||||
revision = 1
|
||||
else:
|
||||
if expected_revision is not None and expected_revision != current.get("revision"):
|
||||
raise Conflict(f"revision {expected_revision} does not match current revision {current.get('revision')}")
|
||||
revision = int(current.get("revision", 0)) + 1
|
||||
stored = {**record, "revision": revision}
|
||||
data = json.dumps(stored, sort_keys=True, separators=(",", ":")).encode()
|
||||
if len(data) > MAX_RECORD_BYTES:
|
||||
raise TooLarge("record exceeds size bound")
|
||||
_atomic_write(path, data)
|
||||
return stored
|
||||
|
||||
def delete(self, family: str, record_id: str) -> None:
|
||||
"""Remove a document; missing is not an error."""
|
||||
path = self._doc_path(family, record_id)
|
||||
with self.lock(family):
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def count(self, family: str) -> int:
|
||||
"""Number of documents in a family."""
|
||||
directory = self.root / family
|
||||
return sum(1 for p in directory.glob("*.json")) if directory.exists() else 0
|
||||
|
||||
def scan(self, family: str) -> Iterator[dict[str, Any]]:
|
||||
"""Yield every document in a family, oldest file first."""
|
||||
directory = self.root / family
|
||||
if not directory.exists():
|
||||
return
|
||||
for path in sorted(directory.glob("*.json"), key=lambda p: (p.stat().st_mtime_ns, p.name)):
|
||||
yield json.loads(path.read_bytes())
|
||||
|
||||
# -- ledgers -----------------------------------------------------------
|
||||
def _ledger_path(self, family: str, name: str) -> Path:
|
||||
directory = self.root / family
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
if not re.match(r"^[A-Za-z0-9._-]{1,120}$", name):
|
||||
raise Invalid("malformed ledger name")
|
||||
return directory / f"{name}.jsonl"
|
||||
|
||||
def append(self, family: str, name: str, record: dict[str, Any]) -> None:
|
||||
"""Append one JSON line with fsync."""
|
||||
line = json.dumps(record, sort_keys=True, separators=(",", ":")).encode() + b"\n"
|
||||
if len(line) > MAX_RECORD_BYTES:
|
||||
raise TooLarge("ledger record exceeds size bound")
|
||||
with self.lock(family):
|
||||
path = self._ledger_path(family, name)
|
||||
with open(path, "ab") as handle:
|
||||
handle.write(line)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def read(self, family: str, name: str) -> list[dict[str, Any]]:
|
||||
"""Read a whole ledger; a torn trailing line from a crash is dropped."""
|
||||
path = self._ledger_path(family, name)
|
||||
if not path.exists():
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for line in path.read_bytes().split(b"\n"):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
return rows
|
||||
|
||||
def ledgers(self, family: str) -> list[str]:
|
||||
"""Names of the ledgers in a family."""
|
||||
directory = self.root / family
|
||||
return sorted(p.stem for p in directory.glob("*.jsonl")) if directory.exists() else []
|
||||
|
||||
def rewrite(self, family: str, name: str, rows: list[dict[str, Any]]) -> None:
|
||||
"""Replace a ledger atomically (used by retention purges)."""
|
||||
data = b"".join(json.dumps(r, sort_keys=True, separators=(",", ":")).encode() + b"\n" for r in rows)
|
||||
with self.lock(family):
|
||||
_atomic_write(self._ledger_path(family, name), data)
|
||||
|
||||
# -- blobs -------------------------------------------------------------
|
||||
def put_blob(self, digest: str, data: bytes) -> Path:
|
||||
"""Store content-addressed bytes; the caller has already verified the digest."""
|
||||
if not re.match(r"^[0-9a-f]{64}$", digest):
|
||||
raise Invalid("malformed digest")
|
||||
directory = self.root / "blobs" / digest[:2]
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / digest
|
||||
if not path.exists():
|
||||
_atomic_write(path, data)
|
||||
return path
|
||||
|
||||
def get_blob(self, digest: str) -> bytes:
|
||||
"""Read content-addressed bytes or raise NotFound."""
|
||||
if not re.match(r"^[0-9a-f]{64}$", digest):
|
||||
raise Invalid("malformed digest")
|
||||
path = self.root / "blobs" / digest[:2] / digest
|
||||
if not path.exists():
|
||||
raise NotFound("blob not found")
|
||||
return path.read_bytes()
|
||||
|
||||
# -- manifest ----------------------------------------------------------
|
||||
def manifest(self, contract_version: str) -> dict[str, Any]:
|
||||
"""Read or create the ``hux.manifest.v1`` for this tenant directory."""
|
||||
path = self.root / "MANIFEST.json"
|
||||
with self.lock("manifest"):
|
||||
if path.exists():
|
||||
return json.loads(path.read_bytes())
|
||||
stamp = now_iso()
|
||||
record = {
|
||||
"schema": "hux.manifest.v1",
|
||||
"contract_version": contract_version,
|
||||
"data_layout_version": LAYOUT_VERSION,
|
||||
"min_reader_contract_version": "1.0.0",
|
||||
"created_at": stamp,
|
||||
"updated_at": stamp,
|
||||
}
|
||||
_atomic_write(path, json.dumps(record, sort_keys=True).encode())
|
||||
return record
|
||||
237
dockerfiles/hermes-hux-foundation/hux/suggestions.py
Normal file
237
dockerfiles/hermes-hux-foundation/hux/suggestions.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""HUX-09 restrained contextual suggestions with server-owned suppression.
|
||||
|
||||
Clients submit a bounded context signal; they cannot create suggestion text,
|
||||
change cooldowns, or claim that a suggestion was clicked. Private and
|
||||
sensitive conversations return an explicit no-store result before any
|
||||
suggestion state or idempotency record is written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts
|
||||
from hux.errors import Conflict, Invalid, NotFound
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.store import now_iso
|
||||
|
||||
CARD = "HUX-09"
|
||||
FAMILY = "suggestion_states"
|
||||
MAX_STATES = 1000
|
||||
CONTEXTS = ("first_session", "empty_project", "after_artifact", "after_research", "after_approval", "idle")
|
||||
CATALOG: dict[str, dict[str, Any]] = {
|
||||
"first_session": {"id": "sug_first_session", "kind": "tip", "title": "Choose how Hermes works", "body": "Pick Fast, Thoughtful, Research, Create, or Private/local for this conversation.", "action": {"type": "open_mode"}, "priority": 80},
|
||||
"empty_project": {"id": "sug_empty_project", "kind": "project", "title": "Give this project a starting point", "body": "Add a goal or move a related conversation here when it is useful.", "action": {"type": "create_project"}, "priority": 50},
|
||||
"after_artifact": {"id": "sug_after_artifact", "kind": "feature", "title": "Keep working on this artifact", "body": "Open the artifact workspace to compare versions or continue editing.", "action": {"type": "open_artifacts"}, "priority": 70},
|
||||
"after_research": {"id": "sug_after_research", "kind": "workflow", "title": "Keep the research together", "body": "Save the sources, assumptions, and open questions as a reusable workflow.", "action": {"type": "start_workflow"}, "priority": 65},
|
||||
"after_approval": {"id": "sug_after_approval", "kind": "tip", "title": "Review autonomy controls", "body": "You can adjust what Hermes asks before doing for this conversation.", "action": {"type": "none"}, "priority": 40},
|
||||
"idle": {"id": "sug_idle_workflow", "kind": "workflow", "title": "Make repeated work reusable", "body": "If this repeats, Hermes can turn it into a workflow without running it now.", "action": {"type": "start_workflow"}, "priority": 20},
|
||||
}
|
||||
SCHEMAS = contracts.load_all()
|
||||
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _body(request: Request, allowed: set[str]) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
extra = set(request.body) - allowed
|
||||
if extra:
|
||||
raise Invalid("unexpected suggestion fields", sorted(extra))
|
||||
return request.body
|
||||
|
||||
|
||||
def _scope(request: Request) -> tuple[str, dict[str, Any]]:
|
||||
from hux import organization
|
||||
|
||||
project_id, conversation_id = request.params["project_id"], request.params["id"]
|
||||
try:
|
||||
conversation = request.store.get(organization.CONVERSATIONS, conversation_id)
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("project or conversation not found") from error
|
||||
if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id):
|
||||
raise NotFound("project or conversation not found")
|
||||
return project_id, conversation
|
||||
|
||||
|
||||
def _privacy_gate(request: Request, conversation: dict[str, Any], body: dict[str, Any]) -> str | None:
|
||||
from hux import privacy
|
||||
|
||||
if conversation.get("mode") == "private":
|
||||
return "private_mode"
|
||||
state = privacy.conversation_state(request.store, conversation["id"])
|
||||
if state.get("forgotten") or state.get("memory_disabled"):
|
||||
return "conversation_no_store"
|
||||
if state.get("topics"):
|
||||
return "sensitive_topic"
|
||||
if body.get("no_store") is True or body.get("sensitivity") in {"sensitive", "restricted"}:
|
||||
return "client_no_store"
|
||||
return None
|
||||
|
||||
|
||||
def _suggestion(context: str, surface: str) -> dict[str, Any]:
|
||||
base = CATALOG[context]
|
||||
record = {
|
||||
"schema": "hux.suggestion.v1", **base,
|
||||
"trigger": {"surface": surface, "context": context},
|
||||
"suppression": {"dismissable": True, "max_shows": 3, "cooldown_seconds": 86400, "never_again_supported": True},
|
||||
}
|
||||
problems = contracts.validate("suggestion.schema.json", record, SCHEMAS, "/$defs/suggestion")
|
||||
if problems:
|
||||
raise Invalid("suggestion failed contract validation", problems)
|
||||
return record
|
||||
|
||||
|
||||
def _state_id(suggestion_id: str, project_id: str, conversation_id: str) -> str:
|
||||
value = f"{suggestion_id}:{project_id}:{conversation_id}".encode()
|
||||
return f"sugs_{hashlib.sha256(value).hexdigest()[:28]}"
|
||||
|
||||
|
||||
def _public(state: dict[str, Any]) -> dict[str, Any]:
|
||||
result = {key: state[key] for key in ("schema", "owner", "suggestion_id", "shows", "never_again")}
|
||||
for key in ("last_shown_at", "dismissed_at", "acted_at"):
|
||||
if key in state:
|
||||
result[key] = state[key]
|
||||
problems = contracts.validate("suggestion.schema.json", result, SCHEMAS, "/$defs/state")
|
||||
if problems:
|
||||
raise Invalid("suggestion state failed contract validation", problems)
|
||||
return result
|
||||
|
||||
|
||||
def _eligible(suggestion: dict[str, Any], state: dict[str, Any] | None, now: datetime) -> str | None:
|
||||
if state is None:
|
||||
return None
|
||||
if state.get("never_again"):
|
||||
return "never_again"
|
||||
rules = suggestion["suppression"]
|
||||
if state.get("shows", 0) >= rules["max_shows"]:
|
||||
return "max_shows"
|
||||
last = state.get("last_shown_at")
|
||||
if last and now - datetime.fromisoformat(last.replace("Z", "+00:00")) < timedelta(seconds=rules["cooldown_seconds"]):
|
||||
return "cooldown"
|
||||
return None
|
||||
|
||||
|
||||
def _key(request: Request) -> str:
|
||||
key = request.idempotency_key()
|
||||
if not key:
|
||||
raise Invalid("Idempotency-Key is required")
|
||||
return key
|
||||
|
||||
|
||||
def _fingerprint(body: dict[str, Any], path: str) -> str:
|
||||
return hashlib.sha256(json.dumps({"body": body, "path": path}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
|
||||
for row in request.store.read(FAMILY, "idempotency"):
|
||||
if row.get("key") != key:
|
||||
continue
|
||||
if row.get("fingerprint") != fingerprint:
|
||||
raise Conflict("Idempotency-Key was already used for a different suggestion action")
|
||||
return row["result"]
|
||||
return None
|
||||
|
||||
|
||||
def evaluate(request: Request) -> Response:
|
||||
"""Evaluate and atomically count one eligible suggestion display."""
|
||||
body = _body(request, {"context", "no_store", "sensitivity"})
|
||||
project_id, conversation = _scope(request)
|
||||
context = body.get("context")
|
||||
if context not in CONTEXTS:
|
||||
raise Invalid("unknown suggestion context")
|
||||
gated = _privacy_gate(request, conversation, body)
|
||||
if gated:
|
||||
request.audit("suggestions.evaluate", conversation["id"], reason=gated)
|
||||
return Response(200, {"suggestion": None, "reason": gated, "stored": False}, {"Cache-Control": "no-store"})
|
||||
key, fingerprint = _key(request), _fingerprint(body, request.path)
|
||||
suggestion = _suggestion(context, request.identity.surface)
|
||||
state_id = _state_id(suggestion["id"], project_id, conversation["id"])
|
||||
with request.store.lock(FAMILY):
|
||||
replayed = _replay(request, key, fingerprint)
|
||||
if replayed is not None:
|
||||
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
|
||||
state = request.store.get(FAMILY, state_id) if request.store.exists(FAMILY, state_id) else None
|
||||
reason = _eligible(suggestion, state, clock())
|
||||
if reason:
|
||||
result = {"suggestion": None, "reason": reason, "stored": False}
|
||||
else:
|
||||
if state is None and request.store.count(FAMILY) >= MAX_STATES:
|
||||
result = {"suggestion": None, "reason": "state_limit", "stored": False}
|
||||
else:
|
||||
stamp = now_iso()
|
||||
record = {
|
||||
"id": state_id, "schema": "hux.suggestion_state.v1", "owner": request.identity.subject,
|
||||
"suggestion_id": suggestion["id"], "shows": (state or {}).get("shows", 0) + 1,
|
||||
"last_shown_at": stamp, "never_again": False,
|
||||
}
|
||||
stored = request.store.put(FAMILY, record, expected_revision=state["revision"] if state else 0)
|
||||
result = {"suggestion": suggestion, "state": _public(stored), "revision": stored["revision"], "stored": True}
|
||||
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": now_iso()})
|
||||
request.audit("suggestions.evaluate", suggestion["id"], reason=result.get("reason", "shown"))
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
if result.get("revision"):
|
||||
headers["ETag"] = str(result["revision"])
|
||||
return Response(200, result, headers)
|
||||
|
||||
|
||||
def decide(request: Request) -> Response:
|
||||
"""Record only an explicit user click, guarded by If-Match and idempotency."""
|
||||
body = _body(request, {"decision", "clicked"})
|
||||
_, conversation = _scope(request)
|
||||
gated = _privacy_gate(request, conversation, body)
|
||||
if gated:
|
||||
raise NotFound("suggestion state not found")
|
||||
if body.get("clicked") is not True or body.get("decision") not in {"dismissed", "acted", "never_again"}:
|
||||
raise Invalid("an explicit clicked decision is required")
|
||||
key, fingerprint = _key(request), _fingerprint(body, request.path)
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required")
|
||||
state_id = _state_id(request.params["suggestion_id"], request.params["project_id"], conversation["id"])
|
||||
with request.store.lock(FAMILY):
|
||||
replayed = _replay(request, key, fingerprint)
|
||||
if replayed is not None:
|
||||
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
|
||||
if not request.store.exists(FAMILY, state_id):
|
||||
raise NotFound("suggestion state not found")
|
||||
current = request.store.get(FAMILY, state_id)
|
||||
if current["revision"] != expected:
|
||||
raise Conflict("suggestion state revision does not match If-Match")
|
||||
stamp, decision = now_iso(), body["decision"]
|
||||
updated = {**current}
|
||||
if decision in {"dismissed", "never_again"}:
|
||||
updated["dismissed_at"] = stamp
|
||||
if decision == "acted":
|
||||
updated["acted_at"] = stamp
|
||||
if decision == "never_again":
|
||||
updated["never_again"] = True
|
||||
stored = request.store.put(FAMILY, updated, expected_revision=expected)
|
||||
result = {"state": _public(stored), "revision": stored["revision"], "decision": decision}
|
||||
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": stamp})
|
||||
request.audit("suggestions.decide", request.params["suggestion_id"], reason=decision)
|
||||
return Response(200, result, {"ETag": str(stored["revision"]), "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def list_states(request: Request) -> Response:
|
||||
"""Inspect suppression state for exactly one project/conversation scope."""
|
||||
project_id, conversation = _scope(request)
|
||||
if _privacy_gate(request, conversation, {}):
|
||||
return Response(200, {"items": [], "next": None}, {"Cache-Control": "no-store"})
|
||||
prefix = lambda row: row["id"] == _state_id(row["suggestion_id"], project_id, conversation["id"])
|
||||
items = [_public(row) | {"revision": row["revision"]} for row in request.store.scan(FAMILY) if prefix(row)]
|
||||
request.audit("suggestions.list", conversation["id"])
|
||||
response = page(items)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-09 routes."""
|
||||
base = "/hux/v1/projects/{project_id}/conversations/{id}/suggestions"
|
||||
router.add("POST", base + "/evaluate", CARD, "suggestions.evaluate", evaluate)
|
||||
router.add("POST", base + "/{suggestion_id}/decisions", CARD, "suggestions.decide", decide)
|
||||
router.add("GET", base + "/states", CARD, "suggestions.list", list_states)
|
||||
478
dockerfiles/hermes-hux-foundation/hux_producer/__init__.py
Normal file
478
dockerfiles/hermes-hux-foundation/hux_producer/__init__.py
Normal file
@ -0,0 +1,478 @@
|
||||
"""HUX-12 release follow-through evidence producer (companion process).
|
||||
|
||||
This package lives OUTSIDE the network-free ``hux`` service package (SO-29):
|
||||
it is the only component allowed to open outbound connections. It may import
|
||||
``hux.errors`` and ``hux.release_security``; ``hux`` never imports it.
|
||||
|
||||
A pure observation -> verdict core (``evidence_for``) binds every observation
|
||||
to the release being advanced and the workload policy, returning the exact
|
||||
evidence payload for the next legal transition or raising ``Invalid``. A thin
|
||||
``Collector`` gathers observations from Jenkins, Harbor, the Kubernetes API
|
||||
and the health URL over HTTPS with an injectable transport. The driver
|
||||
(``advance``/``run_once``) posts at most one verified transition per pass with
|
||||
If-Match and a deterministic Idempotency-Key; verdicts derive only from passed
|
||||
observations and the injected clock, and nothing here logs the evidence key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
from hux import release_security
|
||||
from hux.errors import Invalid
|
||||
|
||||
ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
|
||||
TERMINAL = frozenset({"live_verified", "rolled_back"})
|
||||
ROLLBACK_STATES = frozenset({"built", "verified", "deployed", "converged", "live_verified"})
|
||||
COMMIT = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
SHA_ANY = re.compile(r"sha256:[0-9a-f]{64}")
|
||||
TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||
FLUX_REV = re.compile(r"^main@sha1:[0-9a-f]{40}$")
|
||||
NAMESPACE = re.compile(r"^[a-z0-9-]{1,63}$")
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:8790"
|
||||
DEFAULT_KUBE_API = "https://kubernetes.default.svc"
|
||||
DEFAULT_KUBE_TOKEN = "/var/run/secrets/kubernetes.io/serviceaccount/token" # noqa: S105
|
||||
MAX_RESPONSE_BYTES = 1024 * 1024
|
||||
Opener = Callable[[urllib.request.Request, float, ssl.SSLContext | None], Any]
|
||||
|
||||
def _check(condition: Any, message: str) -> None:
|
||||
"""Fail closed with a precise reason unless ``condition`` holds."""
|
||||
if not condition:
|
||||
raise Invalid(message)
|
||||
|
||||
def parse_time(value: Any, label: str) -> datetime:
|
||||
"""Parse one strict RFC 3339 UTC second-precision timestamp."""
|
||||
_check(isinstance(value, str) and TIME.fullmatch(value), f"{label} must be an RFC 3339 UTC timestamp")
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
|
||||
def _fresh(value: Any, now: datetime, max_age: int, label: str) -> str:
|
||||
age = (now - parse_time(value, label)).total_seconds()
|
||||
_check(0 <= age <= max_age, f"stale evidence: {label} is outside the freshness window")
|
||||
return str(value)
|
||||
|
||||
def _observation(observations: Mapping[str, Any], name: str, workload: str, now: datetime, max_age: int) -> dict[str, Any]:
|
||||
obs = observations.get(name)
|
||||
_check(isinstance(obs, dict), f"incomplete observations: {name} is missing")
|
||||
_check(obs.get("workload") == workload, f"{name} observation is bound to a different workload")
|
||||
_fresh(obs.get("observed_at"), now, max_age, f"{name}.observed_at")
|
||||
return obs
|
||||
|
||||
def _need(obs: Mapping[str, Any], name: str, field: str, kind: type = str) -> Any:
|
||||
value = obs.get(field)
|
||||
_check(not isinstance(value, bool) and isinstance(value, kind), f"incomplete observations: {name}.{field} is missing or malformed")
|
||||
return value
|
||||
|
||||
def _verify_merged(release, observations, wpolicy, max_age, now):
|
||||
git = _observation(observations, "git", release["workload"], now, max_age)
|
||||
commit = _need(git, "git", "commit")
|
||||
_check(COMMIT.fullmatch(commit), "git commit must be an exact 40-hex SHA")
|
||||
_check(parse_time(_need(git, "git", "committed_at"), "git.committed_at") <= now, "git commit timestamp is in the future")
|
||||
review_url = _need(git, "git", "review_url")
|
||||
_check(review_url == release.get("evidence", {}).get("review_url"), "git observation is for a different review than this release")
|
||||
_check(review_url.startswith(wpolicy["review_url_prefix"]), "review URL is outside the policy review prefix")
|
||||
return {"merge_commit": commit}
|
||||
|
||||
def _verify_built(release, observations, wpolicy, max_age, now):
|
||||
jenkins = _observation(observations, "jenkins", release["workload"], now, max_age)
|
||||
harbor = _observation(observations, "harbor", release["workload"], now, max_age)
|
||||
sha = str(release.get("evidence", {}).get("merge_commit", ""))
|
||||
build_url, job = _need(jenkins, "jenkins", "build_url"), wpolicy["jenkins_job_url"]
|
||||
_check(build_url == job or build_url.startswith(job + "/"), "Jenkins build URL is outside the policy job")
|
||||
_check(_need(jenkins, "jenkins", "result") == "SUCCESS", "Jenkins build result is not SUCCESS")
|
||||
_check(COMMIT.fullmatch(sha) and _need(jenkins, "jenkins", "revision") == sha, "Jenkins build revision does not equal the merged commit")
|
||||
number = _need(jenkins, "jenkins", "build_number", int)
|
||||
_check(number > 0, "Jenkins build number is invalid")
|
||||
tag = f"git-{sha}-build-{number}-release"
|
||||
_check(_need(harbor, "harbor", "tag") == tag, "Harbor tag does not bind the merged commit and Jenkins build")
|
||||
_check(_need(harbor, "harbor", "repository") == wpolicy["image_repository"], "Harbor repository does not match the policy image repository")
|
||||
digest = _need(harbor, "harbor", "digest")
|
||||
_check(SHA.fullmatch(digest), "Harbor digest is malformed")
|
||||
_check(_need(jenkins, "jenkins", "image_digest") == digest, "Harbor digest does not equal the advertised image digest")
|
||||
ref = f"{wpolicy['image_repository']}:{tag}@{digest}"
|
||||
return {"ci_build_url": build_url, "image_ref": ref, "image_digest": digest, "harbor_digest": digest}
|
||||
|
||||
def _verify_verified(release, observations, wpolicy, max_age, now):
|
||||
harbor = _observation(observations, "harbor", release["workload"], now, max_age)
|
||||
evidence = release.get("evidence", {})
|
||||
tag = str(evidence.get("image_ref", "")).rpartition("@")[0].rpartition(":")[2]
|
||||
same = (_need(harbor, "harbor", "repository") == wpolicy["image_repository"]
|
||||
and tag and _need(harbor, "harbor", "tag") == tag
|
||||
and _need(harbor, "harbor", "digest") == evidence.get("image_digest"))
|
||||
_check(same, "Harbor no longer serves the built tag at the recorded digest")
|
||||
return {}
|
||||
|
||||
def _verify_deployed(release, observations, wpolicy, max_age, now):
|
||||
flux = _observation(observations, "flux", release["workload"], now, max_age)
|
||||
_check(_need(flux, "flux", "name") == wpolicy["flux_kustomization"], "Flux kustomization name does not match policy")
|
||||
revision = _need(flux, "flux", "applied_revision")
|
||||
_check(FLUX_REV.fullmatch(revision), "Flux applied revision must look like main@sha1:<40-hex>")
|
||||
_check(flux.get("pin_in_revision") is True, "manifest pin is not proven to be contained in the applied revision")
|
||||
return {"flux_revision": revision}
|
||||
|
||||
def _verify_converged(release, observations, wpolicy, max_age, now):
|
||||
spec = _observation(observations, "workload_spec", release["workload"], now, max_age)
|
||||
pods = _observation(observations, "pods", release["workload"], now, max_age)
|
||||
evidence = release.get("evidence", {})
|
||||
ref, digest = str(evidence.get("image_ref", "")), str(evidence.get("image_digest", ""))
|
||||
_check(ref and _need(spec, "workload_spec", "image") == ref, "desired workload image does not equal the built release reference")
|
||||
entries = _need(pods, "pods", "pods", list)
|
||||
_check(entries, "no Ready pods were observed for the workload")
|
||||
for pod in entries:
|
||||
_check(isinstance(pod, dict) and pod.get("ready") is True, "a workload pod is not Ready")
|
||||
image_id = pod.get("image_id")
|
||||
_check(isinstance(image_id, str) and image_id.endswith("@" + digest), "a Ready pod is not running the release digest")
|
||||
return {"pod_digest": digest}
|
||||
|
||||
def _verify_live(release, observations, wpolicy, max_age, now):
|
||||
health = _observation(observations, "health", release["workload"], now, max_age)
|
||||
_check(_need(health, "health", "url") == wpolicy["health_url"], "health receipt is not for the policy health URL")
|
||||
status = _need(health, "health", "status", int)
|
||||
_check(status == 200, f"health check returned HTTP {status}, not 200")
|
||||
checked = _fresh(health.get("checked_at"), now, max_age, "health.checked_at")
|
||||
return {"health_check": {"url": health["url"], "status": "pass", "at": checked}}
|
||||
|
||||
def _verify_rollback(release, observations, wpolicy, max_age, now):
|
||||
rollback = _observation(observations, "rollback", release["workload"], now, max_age)
|
||||
digest = _need(rollback, "rollback", "digest")
|
||||
_check(SHA.fullmatch(digest), "rollback target digest is malformed")
|
||||
return {"rollback_target": digest}
|
||||
|
||||
_VERIFIERS = {
|
||||
"merged": _verify_merged, "built": _verify_built, "verified": _verify_verified, "deployed": _verify_deployed,
|
||||
"converged": _verify_converged, "live_verified": _verify_live, "rolled_back": _verify_rollback,
|
||||
}
|
||||
|
||||
def next_target(state: str) -> str | None:
|
||||
"""The only legal forward transition from ``state``; None when terminal."""
|
||||
if state in TERMINAL:
|
||||
return None
|
||||
_check(state in ORDER, "release is in an unknown state")
|
||||
return ORDER[ORDER.index(state) + 1]
|
||||
|
||||
def evidence_for(release: Mapping[str, Any], target: str, observations: Mapping[str, Any], wpolicy: Mapping[str, Any], max_age: int, now: datetime) -> dict[str, Any]:
|
||||
"""Verify observations for exactly one legal transition or raise Invalid."""
|
||||
_check(now.tzinfo is not None, "now must be a timezone-aware UTC datetime")
|
||||
state = str(release.get("state", ""))
|
||||
if target == "rolled_back":
|
||||
_check(state in ROLLBACK_STATES, "there is no deployed image to roll back")
|
||||
else:
|
||||
_check(target == next_target(state), f"transition to {target} would skip, repeat or downgrade from {state}")
|
||||
return _VERIFIERS[target](dict(release), observations, wpolicy, int(max_age), now)
|
||||
|
||||
def _default_opener(request: urllib.request.Request, timeout: float, context: ssl.SSLContext | None):
|
||||
return urllib.request.urlopen(request, timeout=timeout, context=context) # noqa: S310
|
||||
|
||||
def _context(ca_file: str) -> ssl.SSLContext:
|
||||
"""A verifying TLS context, trusting the given CA bundle when one is mounted."""
|
||||
try:
|
||||
return ssl.create_default_context(cafile=ca_file or None)
|
||||
except (OSError, ssl.SSLError) as error:
|
||||
raise Invalid("collector CA bundle is unavailable") from error
|
||||
|
||||
def _bounded_timeout(environ: Mapping[str, str]) -> float:
|
||||
try:
|
||||
value = float(environ.get("HUX_PRODUCER_TIMEOUT_SECONDS", 10.0))
|
||||
except (TypeError, ValueError):
|
||||
return 10.0
|
||||
return value if 0.1 <= value <= 60.0 else 10.0
|
||||
|
||||
class Collector:
|
||||
"""Observation gatherer; every field is observed via the transport, never asserted."""
|
||||
|
||||
def __init__(self, environ: Mapping[str, str], workload: str, wpolicy: Mapping[str, Any], opener: Opener | None = None, clock: Callable[[], datetime] | None = None) -> None:
|
||||
self.environ = dict(environ)
|
||||
self.workload = workload
|
||||
self.wpolicy = dict(wpolicy)
|
||||
self.opener = opener or _default_opener
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.timeout = _bounded_timeout(environ)
|
||||
|
||||
def _stamp(self, fields: dict[str, Any]) -> dict[str, Any]:
|
||||
observed = self.clock().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return {"workload": self.workload, "observed_at": observed, **fields}
|
||||
|
||||
def _fetch(self, url: str, label: str, headers: Mapping[str, str] | None = None, context: ssl.SSLContext | None = None) -> tuple[int, bytes]:
|
||||
_check(urlsplit(url).scheme == "https", f"{label} observation URL must be HTTPS")
|
||||
request = urllib.request.Request(url, headers=dict(headers or {}), method="GET") # noqa: S310
|
||||
try:
|
||||
with self.opener(request, self.timeout, context or _context("")) as response:
|
||||
status, data = int(response.status), response.read(MAX_RESPONSE_BYTES + 1)
|
||||
except urllib.error.HTTPError as error:
|
||||
return int(error.code), b""
|
||||
except (OSError, ValueError) as error:
|
||||
raise Invalid(f"{label} observation fetch failed") from error
|
||||
_check(len(data) <= MAX_RESPONSE_BYTES, f"{label} observation response is too large")
|
||||
return status, data
|
||||
|
||||
def _json(self, url: str, label: str, headers: Mapping[str, str] | None = None, context: ssl.SSLContext | None = None) -> dict[str, Any]:
|
||||
status, data = self._fetch(url, label, headers, context)
|
||||
_check(status == 200, f"{label} observation returned HTTP {status}")
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
except ValueError as error:
|
||||
raise Invalid(f"{label} observation is not JSON") from error
|
||||
_check(isinstance(parsed, dict), f"{label} observation is not a JSON object")
|
||||
return parsed
|
||||
|
||||
def git(self, review_url: str) -> dict[str, Any]:
|
||||
"""Observe the merged proposal named by the release's recorded review URL."""
|
||||
prefix = self.wpolicy["review_url_prefix"]
|
||||
number = review_url[len(prefix):] if review_url.startswith(prefix) else ""
|
||||
_check(number.isdigit(), "release review URL is outside the policy review prefix")
|
||||
parsed = urlsplit(prefix)
|
||||
parts = parsed.path.strip("/").split("/")
|
||||
_check(len(parts) == 3 and parts[2] == "pulls", "policy review prefix is not a proposal URL")
|
||||
body = self._json(f"https://{parsed.netloc}/api/v1/repos/{parts[0]}/{parts[1]}/pulls/{number}", "git")
|
||||
fields: dict[str, Any] = {"review_url": review_url}
|
||||
commit, merged_at = body.get("merge_commit_sha"), body.get("merged_at")
|
||||
if body.get("merged") is True and isinstance(commit, str):
|
||||
fields["commit"] = commit.lower()
|
||||
if isinstance(merged_at, str) and TIME.fullmatch(merged_at):
|
||||
fields["committed_at"] = merged_at
|
||||
return self._stamp(fields)
|
||||
|
||||
def jenkins(self) -> dict[str, Any]:
|
||||
"""Observe the last successful build of the policy job."""
|
||||
body = self._json(self.wpolicy["jenkins_job_url"] + "/lastSuccessfulBuild/api/json", "jenkins")
|
||||
fields: dict[str, Any] = {}
|
||||
url, result, number = body.get("url"), body.get("result"), body.get("number")
|
||||
if isinstance(url, str):
|
||||
fields["build_url"] = url.rstrip("/")
|
||||
if isinstance(result, str):
|
||||
fields["result"] = result
|
||||
if isinstance(number, int) and not isinstance(number, bool):
|
||||
fields["build_number"] = number
|
||||
for action in body.get("actions") or []:
|
||||
revision = action.get("lastBuiltRevision") if isinstance(action, dict) else None
|
||||
sha = revision.get("SHA1") if isinstance(revision, dict) else None
|
||||
if isinstance(sha, str) and COMMIT.fullmatch(sha.lower()):
|
||||
fields["revision"] = sha.lower()
|
||||
break
|
||||
advertised = SHA_ANY.search(str(body.get("description") or ""))
|
||||
if advertised:
|
||||
fields["image_digest"] = advertised.group(0)
|
||||
return self._stamp(fields)
|
||||
|
||||
def harbor(self, tag: str) -> dict[str, Any]:
|
||||
"""Observe the immutable Harbor artifact behind one release tag."""
|
||||
repository = self.wpolicy["image_repository"]
|
||||
host, _, path = repository.partition("/")
|
||||
project, _, name = path.partition("/")
|
||||
_check(host and project and name, "policy image repository is not host/project/name")
|
||||
body = self._json(f"https://{host}/api/v2.0/projects/{project}/repositories/{quote(name, safe='')}/artifacts/{quote(tag, safe='')}", "harbor")
|
||||
fields: dict[str, Any] = {"repository": repository}
|
||||
digest = body.get("digest")
|
||||
if isinstance(digest, str) and SHA.fullmatch(digest):
|
||||
fields["digest"] = digest
|
||||
names = [entry.get("name") for entry in body.get("tags") or [] if isinstance(entry, dict)]
|
||||
if tag in names:
|
||||
fields["tag"] = tag
|
||||
return self._stamp(fields)
|
||||
|
||||
def _namespace(self) -> str:
|
||||
namespace = self.environ.get("HUX_PRODUCER_NAMESPACE", "")
|
||||
_check(NAMESPACE.fullmatch(namespace), "HUX_PRODUCER_NAMESPACE is missing or malformed")
|
||||
return namespace
|
||||
|
||||
def _kube(self, path: str) -> dict[str, Any]:
|
||||
base = self.environ.get("HUX_PRODUCER_KUBE_API", DEFAULT_KUBE_API).rstrip("/")
|
||||
token_file = self.environ.get("HUX_PRODUCER_KUBE_TOKEN_FILE", DEFAULT_KUBE_TOKEN)
|
||||
try:
|
||||
token = Path(token_file).read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError) as error:
|
||||
raise Invalid("kubernetes credentials are unavailable") from error
|
||||
_check(token, "kubernetes credentials are unavailable")
|
||||
context = _context(self.environ.get("HUX_PRODUCER_KUBE_CA_FILE", ""))
|
||||
return self._json(base + path, "kubernetes", {"Authorization": "Bearer " + token}, context)
|
||||
|
||||
def _workload_image(self) -> str | None:
|
||||
kind = self.environ.get("HUX_PRODUCER_WORKLOAD_KIND", "statefulset").lower()
|
||||
plural = {"deployment": "deployments", "statefulset": "statefulsets"}.get(kind)
|
||||
_check(plural, "HUX_PRODUCER_WORKLOAD_KIND must be deployment or statefulset")
|
||||
name = self.environ.get("HUX_PRODUCER_WORKLOAD_NAME", self.workload)
|
||||
body = self._kube(f"/apis/apps/v1/namespaces/{self._namespace()}/{plural}/{name}")
|
||||
containers = (((body.get("spec") or {}).get("template") or {}).get("spec") or {}).get("containers") or []
|
||||
prefix = self.wpolicy["image_repository"] + ":"
|
||||
for container in containers:
|
||||
image = container.get("image") if isinstance(container, dict) else None
|
||||
if isinstance(image, str) and image.startswith(prefix):
|
||||
return image
|
||||
return None
|
||||
|
||||
def workload_spec(self) -> dict[str, Any]:
|
||||
"""Observe the live desired image of the policy workload."""
|
||||
image = self._workload_image()
|
||||
return self._stamp({"image": image} if image is not None else {})
|
||||
|
||||
def flux(self, expected_image: str) -> dict[str, Any]:
|
||||
"""Observe the policy Kustomization; pin containment is proven, not asserted."""
|
||||
name = self.wpolicy["flux_kustomization"]
|
||||
body = self._kube(f"/apis/kustomize.toolkit.fluxcd.io/v1/namespaces/{self._namespace()}/kustomizations/{name}")
|
||||
status = body.get("status") or {}
|
||||
applied = status.get("lastAppliedRevision")
|
||||
fields: dict[str, Any] = {"name": (body.get("metadata") or {}).get("name")}
|
||||
if isinstance(applied, str):
|
||||
fields["applied_revision"] = applied
|
||||
ready = any(isinstance(c, dict) and c.get("type") == "Ready" and c.get("status") == "True" for c in status.get("conditions") or [])
|
||||
settled = isinstance(applied, str) and status.get("lastAttemptedRevision") in (None, applied)
|
||||
pinned = bool(expected_image) and self._workload_image() == expected_image
|
||||
fields["pin_in_revision"] = bool(ready and settled and pinned)
|
||||
return self._stamp(fields)
|
||||
|
||||
def pods(self) -> dict[str, Any]:
|
||||
"""Observe readiness and running imageID for every selected pod."""
|
||||
selector = self.environ.get("HUX_PRODUCER_POD_SELECTOR", "")
|
||||
_check(selector, "HUX_PRODUCER_POD_SELECTOR is required to observe pods")
|
||||
body = self._kube(f"/api/v1/namespaces/{self._namespace()}/pods?labelSelector={quote(selector, safe='=,')}")
|
||||
prefix = self.wpolicy["image_repository"] + ":"
|
||||
entries = []
|
||||
for item in body.get("items") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
status = item.get("status") or {}
|
||||
ready = any(isinstance(c, dict) and c.get("type") == "Ready" and c.get("status") == "True" for c in status.get("conditions") or [])
|
||||
image_id = None
|
||||
for container in status.get("containerStatuses") or []:
|
||||
if isinstance(container, dict) and str(container.get("image", "")).startswith(prefix):
|
||||
image_id = container.get("imageID")
|
||||
break
|
||||
entries.append({"name": (item.get("metadata") or {}).get("name"), "ready": ready, "image_id": image_id})
|
||||
return self._stamp({"pods": entries})
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
"""Observe the policy health URL; the receipt carries the real HTTP status."""
|
||||
url = self.wpolicy["health_url"]
|
||||
checked = self.clock().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
status, _ = self._fetch(url, "health")
|
||||
return self._stamp({"url": url, "status": status, "checked_at": checked})
|
||||
|
||||
def rollback_target(self) -> dict[str, Any]:
|
||||
"""Observe the digest currently pinned in the live workload spec."""
|
||||
image = self._workload_image()
|
||||
fields: dict[str, Any] = {}
|
||||
digest = image.rpartition("@")[2] if isinstance(image, str) else ""
|
||||
if SHA.fullmatch(digest):
|
||||
fields["digest"] = digest
|
||||
return self._stamp(fields)
|
||||
|
||||
def collect_for(collector: Collector, target: str, release: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Gather exactly the observations the verifier needs for one target state."""
|
||||
evidence = release.get("evidence", {})
|
||||
if target == "merged":
|
||||
return {"git": collector.git(str(evidence.get("review_url", "")))}
|
||||
if target == "built":
|
||||
jenkins = collector.jenkins()
|
||||
observations = {"jenkins": jenkins}
|
||||
sha, number = jenkins.get("revision"), jenkins.get("build_number")
|
||||
if sha is not None and number is not None:
|
||||
observations["harbor"] = collector.harbor(f"git-{sha}-build-{number}-release")
|
||||
return observations
|
||||
if target == "verified":
|
||||
tag = str(evidence.get("image_ref", "")).rpartition("@")[0].rpartition(":")[2]
|
||||
return {"harbor": collector.harbor(tag)}
|
||||
if target == "deployed":
|
||||
return {"flux": collector.flux(str(evidence.get("image_ref", "")))}
|
||||
if target == "converged":
|
||||
return {"workload_spec": collector.workload_spec(), "pods": collector.pods()}
|
||||
if target == "live_verified":
|
||||
return {"health": collector.health()}
|
||||
_check(target == "rolled_back", "no collector plan exists for the target state")
|
||||
return {"rollback": collector.rollback_target()}
|
||||
|
||||
class ProducerClient:
|
||||
"""Loopback HUX API client for the evidence identity; the key never leaves it."""
|
||||
|
||||
def __init__(self, base_url: str, slot: str, subject: str, key: str, opener: Opener | None = None, timeout: float = 10.0) -> None:
|
||||
base = str(base_url or DEFAULT_BASE_URL).rstrip("/")
|
||||
parts = urlsplit(base)
|
||||
loopback = parts.scheme == "http" and parts.hostname in {"127.0.0.1", "::1", "localhost"}
|
||||
_check(parts.scheme == "https" or loopback, "HUX base URL must be loopback HTTP or HTTPS")
|
||||
self.base = base
|
||||
self.opener = opener or _default_opener
|
||||
self.timeout = timeout
|
||||
self._headers = {
|
||||
"X-Hermes-Tenant-Identity": slot, "X-Hux-Subject": subject, "X-Hux-Surface": "api",
|
||||
"X-Hux-Trust": "evidence", "X-Hux-Relay-Key": key, "Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def request(self, method: str, path: str, body: Any = None, headers: Mapping[str, str] | None = None) -> tuple[int, Any, bool]:
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
request = urllib.request.Request( # noqa: S310
|
||||
self.base + path, data=data, headers={**self._headers, **(headers or {})}, method=method)
|
||||
try:
|
||||
response = self.opener(request, self.timeout, None)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
except OSError as error:
|
||||
raise Invalid("HUX service is unreachable") from error
|
||||
with response:
|
||||
status = int(response.status)
|
||||
raw = response.read(MAX_RESPONSE_BYTES)
|
||||
replayed = str(response.headers.get("HUX-Replayed") or "") == "true"
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
return status, parsed, replayed
|
||||
|
||||
def advance(client: ProducerClient, collector: Collector, wpolicy: Mapping[str, Any], max_age: int, scope_path: str, release_id: str, now: datetime) -> dict[str, Any]:
|
||||
"""Read the head, verify observations, and post at most one transition."""
|
||||
status, view, _ = client.request("GET", f"{scope_path}/{release_id}")
|
||||
_check(status == 200 and isinstance(view, dict), f"release read failed with HTTP {status}")
|
||||
release, revision = view.get("release") or {}, view.get("revision")
|
||||
_check(not isinstance(revision, bool) and isinstance(revision, int), "release view carries no revision")
|
||||
target = next_target(str(release.get("state", "")))
|
||||
if target is None:
|
||||
return {"changed": False, "release_id": release_id, "state": release.get("state"), "reason": "release is terminal"}
|
||||
observations = collect_for(collector, target, release)
|
||||
evidence = evidence_for(release, target, observations, wpolicy, max_age, now)
|
||||
status, view, replayed = client.request(
|
||||
"POST", f"{scope_path}/{release_id}/transitions", {"to": target, "evidence": evidence},
|
||||
{"If-Match": str(revision), "Idempotency-Key": f"producer-{release_id}-{target}-{revision}"[:120]})
|
||||
_check(status == 200 and isinstance(view, dict), f"release transition was rejected with HTTP {status}")
|
||||
head = view.get("release") or {}
|
||||
return {"changed": not replayed, "release_id": release_id, "state": head.get("state"),
|
||||
"revision": view.get("revision"), "replayed": replayed}
|
||||
|
||||
def _require(environ: Mapping[str, str], name: str) -> str:
|
||||
value = str(environ.get(name, "")).strip()
|
||||
_check(value, f"{name} is required")
|
||||
return value
|
||||
|
||||
def _pick_release(client: ProducerClient, scope_path: str, workload: str) -> str | None:
|
||||
status, listing, _ = client.request("GET", scope_path)
|
||||
_check(status == 200 and isinstance(listing, dict), f"release list failed with HTTP {status}")
|
||||
for item in listing.get("items") or []:
|
||||
release = (item.get("release") or {}) if isinstance(item, dict) else {}
|
||||
if release.get("workload") == workload and release.get("state") not in TERMINAL:
|
||||
return str(release.get("id"))
|
||||
return None
|
||||
|
||||
def run_once(environ: Mapping[str, str], opener: Opener | None = None, clock: Callable[[], datetime] | None = None) -> dict[str, Any]:
|
||||
"""One idempotent producer pass wired entirely from the environment."""
|
||||
environ = dict(environ)
|
||||
workload = _require(environ, "HUX_PRODUCER_WORKLOAD")
|
||||
policy = release_security.load_policy(environ)
|
||||
wpolicy = policy["workloads"].get(workload)
|
||||
_check(wpolicy is not None, "workload is not enabled for release evidence")
|
||||
key = release_security.evidence_key(environ)
|
||||
client = ProducerClient(environ.get("HUX_BASE_URL", DEFAULT_BASE_URL), _require(environ, "HUX_TENANT_SLOT"),
|
||||
_require(environ, "HUX_PRODUCER_SUBJECT"), key, opener, _bounded_timeout(environ))
|
||||
project = _require(environ, "HUX_PRODUCER_PROJECT_ID")
|
||||
conversation = _require(environ, "HUX_PRODUCER_CONVERSATION_ID")
|
||||
scope_path = f"/hux/v1/projects/{project}/conversations/{conversation}/releases"
|
||||
collector = Collector(environ, workload, wpolicy, opener, clock)
|
||||
release_id = str(environ.get("HUX_PRODUCER_RELEASE_ID", "")).strip() or _pick_release(client, scope_path, workload)
|
||||
if release_id is None:
|
||||
return {"changed": False, "reason": "no advanceable release"}
|
||||
return advance(client, collector, wpolicy, policy["max_evidence_age_seconds"], scope_path, release_id, collector.clock())
|
||||
377
dockerfiles/hermes-webui-hux-bff-patch.py
Normal file
377
dockerfiles/hermes-webui-hux-bff-patch.py
Normal file
@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the pinned, same-origin HUX BFF into Hermes WebUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import time
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
|
||||
PINNED_UPSTREAM_COMMIT = "7a94e34a6d639576576baa9131acf6765f6d2b98"
|
||||
UPSTREAM_HOST = "127.0.0.1"
|
||||
UPSTREAM_PORT = 8790
|
||||
RELAY_KEY_FILE = Path("/run/hermes-webui-hux/relay-key")
|
||||
CONTEXT_KEY_FILE = Path("/run/hermes-hux-context/context-key")
|
||||
MAX_REQUEST_BYTES = 1024 * 1024
|
||||
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||
MAX_ARTIFACT_REQUEST_BYTES = 34 * 1024 * 1024
|
||||
MAX_ARTIFACT_RESPONSE_BYTES = 52 * 1024 * 1024
|
||||
MAX_STREAM_BYTES = 16 * 1024 * 1024
|
||||
UPSTREAM_TIMEOUT = 10.0
|
||||
STREAM_DEADLINE = 65.0
|
||||
CHUNK_BYTES = 64 * 1024
|
||||
SAFE_PATH = re.compile(r"^/hux/v1(?:/[A-Za-z0-9._:-]+)*$")
|
||||
SAFE_QUERY_KEY = re.compile(r"^[A-Za-z0-9._:@+-]{1,120}$")
|
||||
SAFE_QUERY_VALUE = re.compile(r"^[A-Za-z0-9 ._:@+,-]{0,240}$")
|
||||
SAFE_SLOT = re.compile(r"^slot-[0-9]{1,3}$")
|
||||
SAFE_SUBJECT = re.compile(r"^usr_[0-9a-f]{64}$")
|
||||
SAFE_KEY = re.compile(r"^[A-Za-z0-9._:-]{32,256}$")
|
||||
SAFE_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,120}$")
|
||||
SAFE_REVISION = re.compile(r"^[0-9]{1,10}$")
|
||||
SAFE_EVENT_ID = re.compile(r"^[0-9]{1,20}$")
|
||||
MUTATIONS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
CONTEXT_KEY_BYTES = 32
|
||||
|
||||
|
||||
class BffError(Exception):
|
||||
"""A bounded, user-safe proxy rejection."""
|
||||
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def _header_values(handler, name: str) -> list[str]:
|
||||
headers = getattr(handler, "headers", None)
|
||||
if headers is None:
|
||||
return []
|
||||
if hasattr(headers, "get_all"):
|
||||
return [str(value) for value in (headers.get_all(name) or [])]
|
||||
value = headers.get(name)
|
||||
return [] if value is None else [str(value)]
|
||||
|
||||
|
||||
def _one_header(handler, name: str) -> str:
|
||||
values = _header_values(handler, name)
|
||||
if len(values) > 1:
|
||||
raise BffError(400, f"Duplicate {name} header")
|
||||
return values[0].strip() if values else ""
|
||||
|
||||
|
||||
def _trusted_identity(handler) -> tuple[str, str, str]:
|
||||
from api.auth import ensure_trusted_auth_session, parse_cookie, verify_session
|
||||
|
||||
cookie = parse_cookie(handler)
|
||||
if not cookie or not verify_session(cookie):
|
||||
raise BffError(401, "Trusted WebUI session required")
|
||||
info = ensure_trusted_auth_session(handler)
|
||||
slot = _one_header(handler, "X-Hermes-Tenant-Identity")
|
||||
supplied_subject = _one_header(handler, "X-Hux-Subject")
|
||||
username = str((info or {}).get("username") or "")
|
||||
if (not info or info.get("auth_type") != "trusted" or not SAFE_SLOT.fullmatch(slot)
|
||||
or username != slot):
|
||||
raise BffError(401, "Trusted WebUI session required")
|
||||
subject = _derived_subject(slot)
|
||||
if supplied_subject and supplied_subject != subject:
|
||||
raise BffError(401, "Trusted WebUI session required")
|
||||
return slot, subject, "chat"
|
||||
|
||||
|
||||
def _context_key_path() -> Path:
|
||||
raw = os.environ.get("HUX_CONTEXT_KEY_FILE", "").strip()
|
||||
path = Path(raw) if raw else CONTEXT_KEY_FILE
|
||||
if not path.is_absolute() or ".." in path.parts:
|
||||
raise BffError(503, "HUX identity is unavailable")
|
||||
return path
|
||||
|
||||
|
||||
def _secure_context_key() -> bytes:
|
||||
path = _context_key_path()
|
||||
try:
|
||||
parent = path.parent.stat(follow_symlinks=False)
|
||||
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid():
|
||||
raise BffError(503, "HUX identity is unavailable")
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) |
|
||||
getattr(os, "O_NOFOLLOW", 0))
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or
|
||||
stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1 or
|
||||
info.st_size != CONTEXT_KEY_BYTES):
|
||||
raise BffError(503, "HUX identity is unavailable")
|
||||
key = os.read(descriptor, CONTEXT_KEY_BYTES + 1)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
except BffError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise BffError(503, "HUX identity is unavailable") from exc
|
||||
if len(key) != CONTEXT_KEY_BYTES:
|
||||
raise BffError(503, "HUX identity is unavailable")
|
||||
return key
|
||||
|
||||
|
||||
def _derived_subject(slot: str) -> str:
|
||||
digest = hmac.new(_secure_context_key(), b"hux.subject.id.v1\0" + slot.encode("ascii"),
|
||||
hashlib.sha256).hexdigest()
|
||||
subject = "usr_" + digest
|
||||
if not SAFE_SUBJECT.fullmatch(subject): # defensive parity with the public identity contract
|
||||
raise BffError(503, "HUX identity is unavailable")
|
||||
return subject
|
||||
|
||||
|
||||
def _require_csrf(handler, cookie: str) -> None:
|
||||
from api.auth import CSRF_HEADER_NAME, verify_csrf_token
|
||||
|
||||
supplied = _one_header(handler, CSRF_HEADER_NAME)
|
||||
if not supplied or not verify_csrf_token(cookie, supplied):
|
||||
raise BffError(403, "Valid WebUI CSRF token required")
|
||||
|
||||
|
||||
def _secure_relay_key() -> str:
|
||||
parent = RELAY_KEY_FILE.parent
|
||||
try:
|
||||
parent_stat = parent.stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise BffError(503, "HUX relay is unavailable") from exc
|
||||
if not stat.S_ISDIR(parent_stat.st_mode) or parent_stat.st_uid != os.geteuid() or parent_stat.st_mode & 0o077:
|
||||
raise BffError(503, "HUX relay is unavailable")
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(RELAY_KEY_FILE, flags)
|
||||
try:
|
||||
file_stat = os.fstat(descriptor)
|
||||
if (not stat.S_ISREG(file_stat.st_mode) or file_stat.st_uid != os.geteuid() or
|
||||
stat.S_IMODE(file_stat.st_mode) != 0o400 or file_stat.st_nlink != 1):
|
||||
raise BffError(503, "HUX relay is unavailable")
|
||||
raw = os.read(descriptor, 257)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
except BffError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise BffError(503, "HUX relay is unavailable") from exc
|
||||
try:
|
||||
key = raw.decode("ascii").strip()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise BffError(503, "HUX relay is unavailable") from exc
|
||||
if len(raw) > 256 or not SAFE_KEY.fullmatch(key):
|
||||
raise BffError(503, "HUX relay is unavailable")
|
||||
return key
|
||||
|
||||
|
||||
def _safe_target(parsed) -> str:
|
||||
path = str(getattr(parsed, "path", "") or "")
|
||||
segments = path.split("/")[3:]
|
||||
if not SAFE_PATH.fullmatch(path) or any(segment in {".", ".."} for segment in segments):
|
||||
raise BffError(400, "Invalid HUX path")
|
||||
raw_query = str(getattr(parsed, "query", "") or "")
|
||||
if len(raw_query) > 4096:
|
||||
raise BffError(400, "Invalid HUX query")
|
||||
try:
|
||||
pairs = parse_qsl(raw_query, keep_blank_values=True, strict_parsing=True, max_num_fields=64)
|
||||
except ValueError as exc:
|
||||
raise BffError(400, "Invalid HUX query") from exc
|
||||
if len({key for key, _ in pairs}) != len(pairs) or any(
|
||||
not SAFE_QUERY_KEY.fullmatch(key) or not SAFE_QUERY_VALUE.fullmatch(value)
|
||||
for key, value in pairs
|
||||
):
|
||||
raise BffError(400, "Invalid HUX query")
|
||||
query = urlencode(pairs)
|
||||
return path + ("?" + query if query else "")
|
||||
|
||||
|
||||
def _request_limit(target: str, method: str) -> int:
|
||||
path = target.split("?", 1)[0]
|
||||
artifact_write = re.fullmatch(
|
||||
r"/hux/v1/artifacts(?:/[A-Za-z0-9._:-]+/versions)?", path
|
||||
)
|
||||
return MAX_ARTIFACT_REQUEST_BYTES if method == "POST" and artifact_write else MAX_REQUEST_BYTES
|
||||
|
||||
|
||||
def _response_limit(target: str) -> int:
|
||||
path = target.split("?", 1)[0]
|
||||
artifact_read = re.fullmatch(
|
||||
r"/hux/v1/artifacts/[A-Za-z0-9._:-]+/versions/[0-9]+(?:/diff)?", path
|
||||
)
|
||||
return MAX_ARTIFACT_RESPONSE_BYTES if artifact_read else MAX_RESPONSE_BYTES
|
||||
|
||||
|
||||
def _read_request(handler, method: str, limit: int = MAX_REQUEST_BYTES) -> bytes | None:
|
||||
if _one_header(handler, "Transfer-Encoding"):
|
||||
raise BffError(400, "Chunked HUX requests are not supported")
|
||||
raw_length = _one_header(handler, "Content-Length")
|
||||
if not raw_length:
|
||||
return None
|
||||
if not raw_length.isdigit():
|
||||
raise BffError(400, "Invalid Content-Length")
|
||||
length = int(raw_length)
|
||||
if length > limit:
|
||||
raise BffError(413, "HUX request body is too large")
|
||||
if method == "GET" and length:
|
||||
raise BffError(400, "GET request body is not allowed")
|
||||
if length and _one_header(handler, "Content-Type").lower() != "application/json":
|
||||
raise BffError(415, "HUX mutations require application/json")
|
||||
body = bytearray()
|
||||
while len(body) < length:
|
||||
chunk = handler.rfile.read(length - len(body))
|
||||
if not chunk:
|
||||
raise BffError(400, "Incomplete HUX request body")
|
||||
body.extend(chunk)
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def _validated_passthrough_headers(handler, method: str, body: bytes | None) -> dict[str, str]:
|
||||
headers = {"Accept": "text/event-stream" if str(handler.path).split("?", 1)[0].endswith("/stream")
|
||||
else "application/vnd.hermes.hux+json; version=1"}
|
||||
validators = (("If-Match", SAFE_REVISION), ("Idempotency-Key", SAFE_IDEMPOTENCY),
|
||||
("Last-Event-ID", SAFE_EVENT_ID))
|
||||
for name, pattern in validators:
|
||||
value = _one_header(handler, name)
|
||||
if value:
|
||||
if not pattern.fullmatch(value) or (name == "Last-Event-ID" and method != "GET"):
|
||||
raise BffError(400, f"Invalid {name} header")
|
||||
headers[name] = value
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
|
||||
def _send_headers(handler, status: int, content_type: str, length: int | None = None) -> None:
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", content_type)
|
||||
if length is not None:
|
||||
handler.send_header("Content-Length", str(length))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.send_header("X-Content-Type-Options", "nosniff")
|
||||
handler.send_header("Referrer-Policy", "no-referrer")
|
||||
|
||||
|
||||
def _send_error(handler, error: BffError) -> bool:
|
||||
body = json.dumps({"error": str(error)}, separators=(",", ":")).encode("utf-8")
|
||||
_send_headers(handler, error.status, "application/json", len(body))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
return True
|
||||
|
||||
|
||||
def _response_type(response) -> str:
|
||||
content_type = str(response.getheader("Content-Type") or "application/json").split(";", 1)[0].strip().lower()
|
||||
if content_type not in {"application/json", "application/vnd.hermes.hux+json", "text/event-stream"}:
|
||||
raise BffError(502, "HUX returned an unsupported response")
|
||||
return content_type
|
||||
|
||||
|
||||
def _relay_stream(handler, response, content_type: str) -> bool:
|
||||
_send_headers(handler, response.status, content_type)
|
||||
handler.send_header("Connection", "close")
|
||||
handler.end_headers()
|
||||
handler.close_connection = True
|
||||
total = 0
|
||||
deadline = time.monotonic() + STREAM_DEADLINE
|
||||
while time.monotonic() < deadline:
|
||||
chunk = response.read(min(CHUNK_BYTES, MAX_STREAM_BYTES - total + 1))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_STREAM_BYTES:
|
||||
break
|
||||
handler.wfile.write(chunk)
|
||||
handler.wfile.flush()
|
||||
return True
|
||||
|
||||
|
||||
def _relay_response(handler, response, limit: int = MAX_RESPONSE_BYTES) -> bool:
|
||||
if 300 <= response.status < 400:
|
||||
raise BffError(502, "HUX redirects are refused")
|
||||
content_type = _response_type(response)
|
||||
if content_type == "text/event-stream":
|
||||
return _relay_stream(handler, response, content_type)
|
||||
body = response.read(limit + 1)
|
||||
if len(body) > limit:
|
||||
raise BffError(502, "HUX response is too large")
|
||||
_send_headers(handler, response.status, content_type, len(body))
|
||||
etag = response.getheader("ETag")
|
||||
if etag and re.fullmatch(r'"?[0-9]{1,10}"?', etag):
|
||||
handler.send_header("ETag", etag)
|
||||
if str(response.getheader("HUX-Replayed") or "").lower() == "true":
|
||||
handler.send_header("HUX-Replayed", "true")
|
||||
audit_stale = str(response.getheader("HUX-Audit-Stale") or "").lower()
|
||||
if audit_stale in {"true", "false"}:
|
||||
handler.send_header("HUX-Audit-Stale", audit_stale)
|
||||
if str(response.getheader("Content-Disposition") or "").lower() == "attachment":
|
||||
handler.send_header("Content-Disposition", "attachment")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
return True
|
||||
|
||||
|
||||
def proxy_hux(handler, parsed, method: str) -> bool:
|
||||
"""Proxy one authenticated WebUI request to the fixed loopback HUX service."""
|
||||
connection = None
|
||||
try:
|
||||
method = str(method or "").upper()
|
||||
if method != "GET" and method not in MUTATIONS:
|
||||
raise BffError(405, "HUX method is not allowed")
|
||||
slot, subject, surface = _trusted_identity(handler)
|
||||
from api.auth import parse_cookie
|
||||
if method in MUTATIONS:
|
||||
_require_csrf(handler, parse_cookie(handler) or "")
|
||||
target = _safe_target(parsed)
|
||||
body = _read_request(handler, method, _request_limit(target, method))
|
||||
headers = _validated_passthrough_headers(handler, method, body)
|
||||
headers.update({"X-Hermes-Tenant-Identity": slot, "X-Hux-Subject": subject,
|
||||
"X-Hux-Surface": surface, "X-Hux-Trust": "relay",
|
||||
"X-Hux-Relay-Key": _secure_relay_key()})
|
||||
connection = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=UPSTREAM_TIMEOUT)
|
||||
connection.request(method, target, body=body, headers=headers)
|
||||
return _relay_response(handler, connection.getresponse(), _response_limit(target))
|
||||
except BffError as error:
|
||||
return _send_error(handler, error)
|
||||
except (TimeoutError, socket.timeout, ConnectionError, OSError, http.client.HTTPException):
|
||||
return _send_error(handler, BffError(502, "HUX service is unavailable"))
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _patched_routes(source: str) -> str:
|
||||
if "from api.hux_bff import proxy_hux" in source:
|
||||
raise SystemExit("Hermes HUX BFF patch is already installed")
|
||||
replacements = []
|
||||
for method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||
before = (f"def handle_{method.lower()}(handler, parsed) -> bool:\n"
|
||||
f" \"\"\"Handle all {method} routes. Returns True if handled, False for 404.\"\"\"\n")
|
||||
after = before + (" if parsed.path == \"/hux/v1\" or parsed.path.startswith(\"/hux/v1/\"):\n"
|
||||
" from api.hux_bff import proxy_hux\n"
|
||||
f" return proxy_hux(handler, parsed, \"{method}\")\n")
|
||||
if source.count(before) != 1:
|
||||
raise SystemExit(f"Hermes HUX BFF patch context changed: {before[:80]!r}")
|
||||
replacements.append((before, after))
|
||||
for before, after in replacements:
|
||||
source = source.replace(before, after, 1)
|
||||
return source
|
||||
|
||||
|
||||
def apply(root: Path | None = None) -> None:
|
||||
"""Install this module and patch all five pinned WebUI route dispatchers."""
|
||||
root = root or Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
|
||||
api_dir = root / "api"
|
||||
routes = api_dir / "routes.py"
|
||||
installed = api_dir / "hux_bff.py"
|
||||
patched = _patched_routes(routes.read_text(encoding="utf-8"))
|
||||
installed.write_text(Path(__file__).read_text(encoding="utf-8"), encoding="utf-8")
|
||||
routes.write_text(patched, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
apply()
|
||||
67
dockerfiles/hermes-webui-hux-context-patch.py
Normal file
67
dockerfiles/hermes-webui-hux-context-patch.py
Normal file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install trusted HUX session context into the pinned Hermes WebUI routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PINNED_UPSTREAM_COMMIT = "7a94e34a6d639576576baa9131acf6765f6d2b98"
|
||||
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
|
||||
MODULE_SOURCE = Path(__file__).with_name("hermes-webui-hux-context.py")
|
||||
|
||||
|
||||
def _patched_routes(source: str) -> str:
|
||||
if "from api.hux_context import attach_hux_context" in source:
|
||||
raise SystemExit("Hermes HUX context patch is already installed")
|
||||
replacements = (
|
||||
(
|
||||
" redact = redact_session_data(raw)\n",
|
||||
" redact = redact_session_data(raw)\n"
|
||||
" from api.hux_context import attach_hux_context\n"
|
||||
" redact = attach_hux_context(handler, redact)\n",
|
||||
),
|
||||
(
|
||||
' return j(handler, {"session": redact_session_data(sess)})\n',
|
||||
" from api.hux_context import attach_hux_context\n"
|
||||
' return j(handler, {"session": attach_hux_context(\n'
|
||||
" handler, redact_session_data(sess)\n"
|
||||
" )})\n",
|
||||
),
|
||||
(
|
||||
' payload = {"session": s.compact() | {"messages": s.messages}}\n',
|
||||
" from api.hux_context import attach_hux_context\n"
|
||||
' payload = {"session": attach_hux_context(\n'
|
||||
' handler, s.compact() | {"messages": s.messages}\n'
|
||||
" )}\n",
|
||||
),
|
||||
(
|
||||
" return j(handler, response, status=status)\n",
|
||||
" from api.hux_context import attach_hux_context\n"
|
||||
" response = attach_hux_context(handler, response)\n"
|
||||
" return j(handler, response, status=status)\n",
|
||||
),
|
||||
)
|
||||
for before, after in replacements:
|
||||
if source.count(before) != 1:
|
||||
raise SystemExit(f"Hermes HUX context patch changed: {before.strip()!r}")
|
||||
source = source.replace(before, after, 1)
|
||||
return source
|
||||
|
||||
|
||||
def apply(root: Path | None = None, module_source: Path | None = None) -> None:
|
||||
"""Validate all pinned anchors, then install the module and route calls."""
|
||||
root = root or ROOT
|
||||
routes = root / "api/routes.py"
|
||||
source_path = module_source or MODULE_SOURCE
|
||||
module = source_path.read_text(encoding="utf-8")
|
||||
if "def attach_hux_context(" not in module or "def build_hux_context(" not in module:
|
||||
raise SystemExit("Hermes HUX context module is invalid")
|
||||
patched = _patched_routes(routes.read_text(encoding="utf-8"))
|
||||
(root / "api/hux_context.py").write_text(module, encoding="utf-8")
|
||||
routes.write_text(patched, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - image entry point
|
||||
apply()
|
||||
222
dockerfiles/hermes-webui-hux-context.py
Normal file
222
dockerfiles/hermes-webui-hux-context.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""Build browser HUX scope only from an authenticated server session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
|
||||
_RAW_ID = re.compile(r"^[A-Za-z0-9._:@+-]{1,200}$")
|
||||
_SLOT = re.compile(r"^slot-[0-9]{1,3}$")
|
||||
_SUBJECT = re.compile(r"^usr_[0-9a-f]{64}$")
|
||||
_KEY_BYTES = 32
|
||||
_KEY_NAME = ".hux-context-key"
|
||||
_DEFAULT_PROJECT_SOURCE = "profile:default"
|
||||
_ID_PURPOSES = {
|
||||
"ses": "session",
|
||||
"conv": "conversation",
|
||||
"prj": "project",
|
||||
"run": "run",
|
||||
"msg": "message",
|
||||
}
|
||||
_KEY_LOCK = threading.Lock()
|
||||
_KEY_CACHE: tuple[Path, bytes] | None = None
|
||||
|
||||
|
||||
class HuxContextUnavailable(Exception):
|
||||
"""Signal that trusted context cannot be produced for this response."""
|
||||
|
||||
|
||||
def _safe_value(value: Any, fallback: str | None = None) -> str:
|
||||
candidate = value if isinstance(value, str) else ""
|
||||
if _RAW_ID.fullmatch(candidate):
|
||||
return candidate
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
raise HuxContextUnavailable("WebUI session identity is unavailable")
|
||||
|
||||
|
||||
def _key_path() -> Path:
|
||||
from api.config import STATE_DIR
|
||||
|
||||
configured = os.environ.get("HUX_CONTEXT_KEY_FILE", "").strip()
|
||||
path = Path(configured) if configured else Path(STATE_DIR) / _KEY_NAME
|
||||
if not path.is_absolute() or ".." in path.parts:
|
||||
raise HuxContextUnavailable("HUX context key path is invalid")
|
||||
root = path.parent
|
||||
try:
|
||||
info = root.stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise HuxContextUnavailable("HUX context state is unavailable") from exc
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid():
|
||||
raise HuxContextUnavailable("HUX context state is unavailable")
|
||||
return path
|
||||
|
||||
|
||||
def _read_key(path: Path) -> bytes:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_uid != os.geteuid()
|
||||
or stat.S_IMODE(info.st_mode) != 0o600
|
||||
or info.st_nlink != 1
|
||||
or info.st_size != _KEY_BYTES
|
||||
):
|
||||
raise HuxContextUnavailable("HUX context key is unsafe")
|
||||
value = os.read(descriptor, _KEY_BYTES + 1)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
except HuxContextUnavailable:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise HuxContextUnavailable("HUX context key is unavailable") from exc
|
||||
if len(value) != _KEY_BYTES:
|
||||
raise HuxContextUnavailable("HUX context key is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _create_key(path: Path) -> None:
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
value = secrets.token_bytes(_KEY_BYTES)
|
||||
try:
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
except FileExistsError:
|
||||
return
|
||||
except OSError as exc:
|
||||
raise HuxContextUnavailable("HUX context key cannot be created") from exc
|
||||
try:
|
||||
written = os.write(descriptor, value)
|
||||
if written != len(value):
|
||||
raise HuxContextUnavailable("HUX context key write was incomplete")
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _context_key() -> bytes:
|
||||
global _KEY_CACHE
|
||||
path = _key_path()
|
||||
with _KEY_LOCK:
|
||||
if _KEY_CACHE and _KEY_CACHE[0] == path:
|
||||
return _KEY_CACHE[1]
|
||||
_create_key(path)
|
||||
value = _read_key(path)
|
||||
_KEY_CACHE = (path, value)
|
||||
return value
|
||||
|
||||
|
||||
def _project_source() -> str:
|
||||
"""Return the bounded server-owned source for the default HUX project."""
|
||||
configured = os.environ.get("HUX_PROJECT_SOURCE", _DEFAULT_PROJECT_SOURCE).strip()
|
||||
return _safe_value(configured)
|
||||
|
||||
|
||||
def _authoritative_scalar(value: Any) -> str | None:
|
||||
"""Return one bounded scalar already present in a trusted server payload."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
candidate = str(value) if isinstance(value, (str, int)) else ""
|
||||
return candidate if _RAW_ID.fullmatch(candidate) else None
|
||||
|
||||
|
||||
def _latest_message_source(session: dict[str, Any]) -> str | None:
|
||||
"""Find the newest server-stamped message identifier without inventing one."""
|
||||
messages = session.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for message in reversed(messages):
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
for field in ("id", "message_id"):
|
||||
candidate = _authoritative_scalar(message.get(field))
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def derive_hux_id(key: bytes, prefix: str, purpose: str, slot: str, subject: str, raw: str) -> str:
|
||||
"""Derive one cross-surface opaque ID from the shared server key."""
|
||||
if (
|
||||
not isinstance(key, bytes)
|
||||
or len(key) != _KEY_BYTES
|
||||
or _ID_PURPOSES.get(prefix) != purpose
|
||||
or not _SLOT.fullmatch(slot)
|
||||
or not _SUBJECT.fullmatch(subject)
|
||||
or not _RAW_ID.fullmatch(raw)
|
||||
):
|
||||
raise HuxContextUnavailable("HUX context key is invalid")
|
||||
message = "\0".join(("hux.context.id.v1", purpose, slot, subject, raw))
|
||||
digest = hmac.new(key, message.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
|
||||
return f"{prefix}_{digest}"
|
||||
|
||||
|
||||
def build_hux_context(handler: Any, session: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact context for one already-authorized server session."""
|
||||
from api.hux_bff import _trusted_identity
|
||||
|
||||
if not isinstance(session, dict):
|
||||
raise HuxContextUnavailable("WebUI session payload is unavailable")
|
||||
raw_session = _safe_value(session.get("session_id"))
|
||||
slot, subject, surface = _trusted_identity(handler)
|
||||
raw_project = _project_source()
|
||||
key = _context_key()
|
||||
context = {
|
||||
"schema": "hux.webui_context.v1",
|
||||
"webui_session_id": raw_session,
|
||||
"session_id": derive_hux_id(key, "ses", "session", slot, subject, raw_session),
|
||||
"conversation_id": derive_hux_id(
|
||||
key, "conv", "conversation", slot, subject, raw_session
|
||||
),
|
||||
"project_id": derive_hux_id(key, "prj", "project", slot, subject, raw_project),
|
||||
"project_source": raw_project,
|
||||
"identity": {
|
||||
"tenant_slot": slot,
|
||||
"subject": subject,
|
||||
"surface": surface,
|
||||
"trust": "relay",
|
||||
},
|
||||
}
|
||||
# These optional values exist only when the upstream server has already
|
||||
# minted them. A stream id is deliberately never treated as an agent run.
|
||||
optional_sources = {
|
||||
"run_id": ("run", "run", _authoritative_scalar(session.get("turn_id"))),
|
||||
"message_id": ("msg", "message", _latest_message_source(session)),
|
||||
"branch_point_message_id": (
|
||||
"msg",
|
||||
"message",
|
||||
_authoritative_scalar(session.get("branch_point_message_id")),
|
||||
),
|
||||
}
|
||||
for field, (prefix, purpose, source) in optional_sources.items():
|
||||
if source is not None:
|
||||
context[field] = derive_hux_id(key, prefix, purpose, slot, subject, source)
|
||||
notebook = session.get("notebook_id")
|
||||
if isinstance(notebook, str) and re.fullmatch(r"nb_[A-Za-z0-9._-]{4,80}", notebook):
|
||||
context["notebook_id"] = notebook
|
||||
return context
|
||||
|
||||
|
||||
def attach_hux_context(handler: Any, session: Any) -> Any:
|
||||
"""Attach trusted context or return a clean, feature-disabled payload."""
|
||||
if not isinstance(session, dict):
|
||||
return session
|
||||
clean = dict(session)
|
||||
clean.pop("hux_context", None)
|
||||
try:
|
||||
clean["hux_context"] = build_hux_context(handler, clean)
|
||||
except Exception:
|
||||
# Chat remains available when auth, state, or the optional HUX path fails.
|
||||
clean.pop("hux_context", None)
|
||||
return clean
|
||||
116
dockerfiles/hermes-webui-hux-patch.py
Normal file
116
dockerfiles/hermes-webui-hux-patch.py
Normal file
@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the inert HUX browser bundle into the pinned Hermes WebUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PINNED_UPSTREAM_COMMIT = "7a94e34a6d639576576baa9131acf6765f6d2b98"
|
||||
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
|
||||
|
||||
STYLES = (
|
||||
"hux/foundation.css",
|
||||
"hux/runtime/wave_a_activity_memory.css",
|
||||
"hux/runtime/autonomy-privacy.css",
|
||||
"hux/runtime/wave_b.css",
|
||||
"hux/runtime/wave_c_multimodal_onboarding_release.css",
|
||||
"hux/bootstrap.css",
|
||||
)
|
||||
SCRIPTS = (
|
||||
"hux/foundation.js",
|
||||
"hux/shell.js",
|
||||
"hux/runtime/wave_a_contract.js",
|
||||
"hux/runtime/wave_a_activity_memory.js",
|
||||
"hux/runtime/autonomy-privacy.js",
|
||||
"hux/runtime/wave_b_contract.js",
|
||||
"hux/runtime/wave_b_projects_modes.js",
|
||||
"hux/runtime/wave_b_artifacts_research.js",
|
||||
"hux/runtime/wave_b_runtime.js",
|
||||
"hux/runtime/wave_c_multimodal_onboarding_release.js",
|
||||
"hux/bootstrap.js",
|
||||
)
|
||||
|
||||
|
||||
def _patched_index(source: str) -> str:
|
||||
if "id=\"hermesHuxBootstrapStyles\"" in source or "static/hux/bootstrap.js" in source:
|
||||
raise SystemExit("Hermes HUX browser patch is already installed")
|
||||
if source.count("</head>") != 1 or source.count("</body>") != 1:
|
||||
raise SystemExit("Hermes HUX index patch context changed")
|
||||
if "const S={session:null" not in (ROOT / "static/ui.js").read_text(encoding="utf-8"):
|
||||
raise SystemExit("Hermes HUX trusted session context changed")
|
||||
links = "\n".join(
|
||||
f'<link rel="stylesheet" href="static/{asset}?v=__WEBUI_VERSION__"'
|
||||
+ (' id="hermesHuxBootstrapStyles">' if asset == "hux/bootstrap.css" else ">")
|
||||
for asset in STYLES
|
||||
)
|
||||
scripts = "\n".join(
|
||||
f'<script src="static/{asset}?v=__WEBUI_VERSION__" defer></script>'
|
||||
for asset in SCRIPTS
|
||||
)
|
||||
return source.replace("</head>", f"{links}\n</head>", 1).replace(
|
||||
"</body>", f"{scripts}\n</body>", 1
|
||||
)
|
||||
|
||||
|
||||
def _patched_worker(source: str) -> str:
|
||||
if "'./static/hux/bootstrap.js' + VQ" in source:
|
||||
raise SystemExit("Hermes HUX service-worker patch is already installed")
|
||||
marker = " './static/style.css' + VQ,\n"
|
||||
if source.count(marker) != 1:
|
||||
raise SystemExit("Hermes HUX service-worker patch context changed")
|
||||
entries = "".join(f" './static/{asset}' + VQ,\n" for asset in (*STYLES, *SCRIPTS))
|
||||
return source.replace(marker, marker + entries, 1)
|
||||
|
||||
|
||||
def _patched_cancel_stream(source: str) -> str:
|
||||
"""Make the existing owner-bound cancel report only backend-confirmed cancellation."""
|
||||
replacement = " return Boolean(respOk && respBody && respBody.ok===true && " \
|
||||
"respBody.cancelled===true && respBody.stream_id===streamId);\n"
|
||||
if replacement in source:
|
||||
raise SystemExit("Hermes HUX cancel-result patch is already installed")
|
||||
marker = " return respOk;\n}\n\nasync function cancelSessionStream"
|
||||
if source.count(marker) != 1:
|
||||
raise SystemExit("Hermes HUX cancel-result patch context changed")
|
||||
return source.replace(marker, replacement + "}\n\nasync function cancelSessionStream", 1)
|
||||
|
||||
|
||||
def _patched_start_context(source: str) -> str:
|
||||
"""Accept only the context attached to the authoritative chat-start reply."""
|
||||
marker = " const startData = postStartData || {};\n"
|
||||
if "HermesHuxBootstrap.mergeTrustedContext" in source:
|
||||
raise SystemExit("Hermes HUX chat-start patch is already installed")
|
||||
if source.count(marker) != 1:
|
||||
raise SystemExit("Hermes HUX chat-start patch context changed")
|
||||
replacement = marker + (
|
||||
" if(typeof HermesHuxBootstrap==='object' && S.session){\n"
|
||||
" HermesHuxBootstrap.mergeTrustedContext(S.session, startData);\n"
|
||||
" }\n"
|
||||
)
|
||||
return source.replace(marker, replacement, 1)
|
||||
|
||||
|
||||
def apply(root: Path | None = None) -> None:
|
||||
"""Patch the index and offline shell after validating every input."""
|
||||
global ROOT
|
||||
ROOT = root or ROOT
|
||||
index = ROOT / "static/index.html"
|
||||
worker = ROOT / "static/sw.js"
|
||||
boot = ROOT / "static/boot.js"
|
||||
messages = ROOT / "static/messages.js"
|
||||
for asset in (*STYLES, *SCRIPTS):
|
||||
if not (ROOT / "static" / asset).is_file():
|
||||
raise SystemExit(f"Hermes HUX browser asset is missing: {asset}")
|
||||
patched_index = _patched_index(index.read_text(encoding="utf-8"))
|
||||
patched_worker = _patched_worker(worker.read_text(encoding="utf-8"))
|
||||
patched_boot = _patched_cancel_stream(boot.read_text(encoding="utf-8"))
|
||||
patched_messages = _patched_start_context(messages.read_text(encoding="utf-8"))
|
||||
index.write_text(patched_index, encoding="utf-8")
|
||||
worker.write_text(patched_worker, encoding="utf-8")
|
||||
boot.write_text(patched_boot, encoding="utf-8")
|
||||
messages.write_text(patched_messages, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
apply()
|
||||
68
dockerfiles/hermes-webui-hux/NOTES.md
Normal file
68
dockerfiles/hermes-webui-hux/NOTES.md
Normal file
@ -0,0 +1,68 @@
|
||||
# HUX client foundation integration contract
|
||||
|
||||
The WebUI image packages these files. Its server derives a matching
|
||||
`hux.webui_context.v1` only from the authenticated, persisted WebUI session,
|
||||
the bounded server `HUX_PROJECT_SOURCE`, the trusted tenant slot, and the
|
||||
router-asserted HUX subject. The browser bridge remains inert unless that
|
||||
context is present and the same-origin capability endpoint advertises a
|
||||
complete route set. It is versioned as `hux.v1`, contains no tenant-global
|
||||
browser storage, and defaults to a hidden shell.
|
||||
|
||||
The router negotiation endpoint is `GET /hux/v1/capabilities`. A disabled
|
||||
foundation returns 404. An enabled response is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "hux.capabilities.v1",
|
||||
"contract_version": "1.1.0",
|
||||
"identity": {
|
||||
"tenant_slot": "slot-3",
|
||||
"subject": "usr_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"surface": "chat",
|
||||
"trust": "relay"
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"card": "HUX-11",
|
||||
"flag": "hux.foundation",
|
||||
"enabled": true,
|
||||
"routes": ["/hux/v1/capabilities", "/hux/v1/context/bootstrap"]
|
||||
}
|
||||
],
|
||||
"server": {}
|
||||
}
|
||||
```
|
||||
|
||||
The boot bridge reads opaque identity and scope only from `S.session.hux_context`.
|
||||
That record must bind its `webui_session_id` to the loaded WebUI session. The
|
||||
bridge compares it to the authenticated BFF capability identity before it
|
||||
mounts anything. It must never pass a raw Keycloak subject, email, Telegram
|
||||
identifier, or tenant ordinal. The client rejects an identity or surface
|
||||
mismatch, unknown versions, cross-user objects, and cross-conversation
|
||||
records. It does not use local/session storage.
|
||||
|
||||
Opaque IDs use a server-owned 32-byte key at `HUX_CONTEXT_KEY_FILE`, or
|
||||
`$HERMES_WEBUI_STATE_DIR/.hux-context-key` by default. The file is created
|
||||
before context is emitted and must remain an owner-only regular file. Chat and
|
||||
Worker derive the same IDs with `hux.context.id.v1`; no browser value can
|
||||
select the subject, slot, key, or default project source. The project source
|
||||
defaults to `profile:default` when its server environment override is absent.
|
||||
|
||||
After capability negotiation, the bridge sends the exact server-derived scope
|
||||
to `POST /hux/v1/context/bootstrap` with CSRF and an idempotency key. The HUX
|
||||
service recomputes every ID from the shared key and creates or verifies the
|
||||
project/conversation binding. The drawer is not created unless that response
|
||||
matches the authenticated context exactly.
|
||||
|
||||
Feature extensions register an id, label, HUX flag, display order, and render
|
||||
callback with `HermesHuxShell.createShell()`. The shell is hidden for a 404,
|
||||
for an off foundation, or when no registered extension is enabled. Loading and
|
||||
errors are accessible status regions; errors disclose no backend body. Event
|
||||
adapters omit `detail`, evidence URIs, hashes, and tool payloads. Object
|
||||
adapters return display metadata only. Feature code must fetch sensitive
|
||||
details from its owning tenant-scoped endpoint after an explicit user action.
|
||||
|
||||
Scripts load in dependency order and the image service worker caches the same
|
||||
reviewed assets. No manifest or live HUX flag is changed by this browser
|
||||
bundle. A 404, missing server context, incomplete route advertisement, or
|
||||
session change destroys or suppresses the workspace.
|
||||
260
dockerfiles/hermes-webui-hux/activity/ActivityTimeline.tsx
Normal file
260
dockerfiles/hermes-webui-hux/activity/ActivityTimeline.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
/** Compact, accessible HUX-01 activity timeline. The feature is default-off. */
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
LoaderCircle,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
activityTimelineEnabled,
|
||||
mergeActivityEvents,
|
||||
normalizeCancellationReceipt,
|
||||
surfacePolicy,
|
||||
} from "./model.ts";
|
||||
import type {
|
||||
ActivityItem,
|
||||
CancellationReceipt,
|
||||
HuxSurface,
|
||||
RawActivityEvent,
|
||||
RawCancellationReceipt,
|
||||
} from "./types.ts";
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
conversationId: string;
|
||||
events: readonly RawActivityEvent[];
|
||||
flags?: Iterable<string>;
|
||||
surface: HuxSurface;
|
||||
status?: "connecting" | "live" | "reconnecting" | "complete";
|
||||
cancellationReceipts?: readonly RawCancellationReceipt[];
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Readonly<Record<ActivityItem["phase"], string>> = {
|
||||
intent: "Intent",
|
||||
action: "Action",
|
||||
evidence: "Evidence",
|
||||
delegation: "Delegation",
|
||||
decision: "Decision",
|
||||
completion: "Complete",
|
||||
failure: "Failed",
|
||||
};
|
||||
|
||||
function PhaseIcon({ item }: { item: ActivityItem }) {
|
||||
if (item.phase === "failure")
|
||||
return <AlertTriangle className="h-3.5 w-3.5" aria-hidden />;
|
||||
if (item.phase === "completion")
|
||||
return <Check className="h-3.5 w-3.5" aria-hidden />;
|
||||
if (item.phase === "delegation")
|
||||
return <GitBranch className="h-3.5 w-3.5" aria-hidden />;
|
||||
if (item.phase === "decision")
|
||||
return <ListChecks className="h-3.5 w-3.5" aria-hidden />;
|
||||
return <Activity className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
|
||||
function TimelineRow({
|
||||
item,
|
||||
expanded,
|
||||
}: {
|
||||
item: ActivityItem;
|
||||
expanded: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
className={`hux-activity-row hux-tone-${item.tone}`}
|
||||
data-kind={item.kind}
|
||||
data-seq={item.seq}
|
||||
>
|
||||
<div className="hux-activity-marker">
|
||||
<PhaseIcon item={item} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<span className="text-[0.6875rem] font-semibold uppercase tracking-[0.12em] text-current/65">
|
||||
{PHASE_LABELS[item.phase]}
|
||||
</span>
|
||||
<time
|
||||
className="text-[0.625rem] text-current/45"
|
||||
dateTime={item.timestamp}
|
||||
>
|
||||
{new Date(item.timestamp).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-current/90">
|
||||
{item.summary}
|
||||
</p>
|
||||
{expanded && item.evidence.length > 0 && (
|
||||
<ul
|
||||
className="mt-1.5 flex flex-wrap gap-1"
|
||||
aria-label="Bounded evidence references"
|
||||
>
|
||||
{item.evidence.map((evidence, index) => (
|
||||
<li
|
||||
key={`${evidence.kind}-${index}`}
|
||||
className="hux-evidence-chip"
|
||||
>
|
||||
{evidence.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CancellationCard({ receipt }: { receipt: CancellationReceipt }) {
|
||||
const complete = receipt.outcome !== "failed_to_cancel";
|
||||
return (
|
||||
<aside
|
||||
className="hux-cancellation-receipt"
|
||||
aria-label="Cancellation receipt"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
{complete ? (
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
<span>
|
||||
{complete
|
||||
? "Stop request handled"
|
||||
: "Hermes could not confirm the stop"}
|
||||
</span>
|
||||
</div>
|
||||
{receipt.sideEffects.length > 0 && (
|
||||
<ul className="mt-1 list-inside list-disc text-[0.6875rem] text-current/70">
|
||||
{receipt.sideEffects.map((effect, index) => (
|
||||
<li key={index}>
|
||||
{effect.description} —{" "}
|
||||
{effect.reverted ? "reverted" : "left in place"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{receipt.omittedEffects > 0 && (
|
||||
<p className="mt-1 text-[0.625rem] text-current/55">
|
||||
{receipt.omittedEffects} additional side effect
|
||||
{receipt.omittedEffects === 1 ? "" : "s"} recorded.
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render nothing unless both HUX-11 and HUX-01 have been explicitly enabled. */
|
||||
export function ActivityTimeline({
|
||||
conversationId,
|
||||
events,
|
||||
flags,
|
||||
surface,
|
||||
status = "live",
|
||||
cancellationReceipts = [],
|
||||
onRetry,
|
||||
}: ActivityTimelineProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const enabled = activityTimelineEnabled(flags);
|
||||
const policy = surfacePolicy(surface);
|
||||
const result = useMemo(
|
||||
() => mergeActivityEvents([], events, conversationId, surface),
|
||||
[conversationId, events, surface],
|
||||
);
|
||||
const receipts = useMemo(
|
||||
() =>
|
||||
cancellationReceipts
|
||||
.map((receipt) => normalizeCancellationReceipt(receipt, surface))
|
||||
.filter((receipt): receipt is CancellationReceipt => receipt !== null)
|
||||
.slice(-3),
|
||||
[cancellationReceipts, surface],
|
||||
);
|
||||
if (!enabled) return null;
|
||||
|
||||
const hidden = Math.max(0, result.items.length - policy.initialItems);
|
||||
const visible = showAll
|
||||
? result.items
|
||||
: result.items.slice(-policy.initialItems);
|
||||
const reconnecting = status === "connecting" || status === "reconnecting";
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`hux-activity hux-activity-${surface}`}
|
||||
aria-label="Hermes activity timeline"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">What Hermes is doing</h2>
|
||||
<p className="text-[0.6875rem] text-current/55">
|
||||
{reconnecting
|
||||
? "Reconnecting to durable activity…"
|
||||
: `${result.items.length} recorded update${result.items.length === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
</div>
|
||||
{reconnecting && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" aria-hidden />
|
||||
)}
|
||||
</header>
|
||||
|
||||
{(result.conflicts > 0 || result.invalid > 0) && (
|
||||
<div className="hux-activity-warning" role="status">
|
||||
Some unsafe or conflicting activity was ignored. Visible history was
|
||||
not rewritten.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ol
|
||||
className="mt-3 space-y-1.5"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
aria-busy={reconnecting}
|
||||
>
|
||||
{visible.map((item) => (
|
||||
<TimelineRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={policy.expandRoutineEvents || !item.routine}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{result.items.length === 0 && (
|
||||
<p className="mt-3 text-xs text-current/55">
|
||||
No activity has been recorded yet.
|
||||
</p>
|
||||
)}
|
||||
{hidden > 0 && !showAll && (
|
||||
<button
|
||||
className="hux-activity-more"
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
Show {hidden} earlier update{hidden === 1 ? "" : "s"}
|
||||
</button>
|
||||
)}
|
||||
{showAll && hidden > 0 && (
|
||||
<button
|
||||
className="hux-activity-more"
|
||||
type="button"
|
||||
onClick={() => setShowAll(false)}
|
||||
>
|
||||
Show recent activity only
|
||||
</button>
|
||||
)}
|
||||
{reconnecting && onRetry && (
|
||||
<button className="hux-activity-more" type="button" onClick={onRetry}>
|
||||
Retry now
|
||||
</button>
|
||||
)}
|
||||
{receipts.map((receipt) => (
|
||||
<CancellationCard key={receipt.id} receipt={receipt} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
16
dockerfiles/hermes-webui-hux/activity/index.ts
Normal file
16
dockerfiles/hermes-webui-hux/activity/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export { ActivityTimeline } from "./ActivityTimeline.tsx";
|
||||
export {
|
||||
ACTIVITY_TIMELINE_FLAG,
|
||||
FOUNDATION_FLAG,
|
||||
activityTimelineEnabled,
|
||||
mergeActivityEvents,
|
||||
normalizeCancellationReceipt,
|
||||
surfacePolicy,
|
||||
} from "./model.ts";
|
||||
export type {
|
||||
ActivityItem,
|
||||
CancellationReceipt,
|
||||
HuxSurface,
|
||||
RawActivityEvent,
|
||||
RawCancellationReceipt,
|
||||
} from "./types.ts";
|
||||
306
dockerfiles/hermes-webui-hux/activity/model.ts
Normal file
306
dockerfiles/hermes-webui-hux/activity/model.ts
Normal file
@ -0,0 +1,306 @@
|
||||
/** Normalization, replay safety, and surface policy for HUX-01. */
|
||||
|
||||
import {
|
||||
isOpaqueId,
|
||||
isRfc3339Utc,
|
||||
safeEvidenceLabel,
|
||||
safeText,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
ActivityItem,
|
||||
ActivityPhase,
|
||||
ActivityTone,
|
||||
CancellationReceipt,
|
||||
HuxSurface,
|
||||
MergeResult,
|
||||
RawActivityEvent,
|
||||
RawCancellationReceipt,
|
||||
SurfacePolicy,
|
||||
} from "./types.ts";
|
||||
|
||||
export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline";
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
|
||||
const EVENT_KINDS = new Set([
|
||||
"message.user",
|
||||
"message.assistant",
|
||||
"decision.route",
|
||||
"decision.plan",
|
||||
"tool.call",
|
||||
"tool.result",
|
||||
"approval.requested",
|
||||
"approval.resolved",
|
||||
"memory.proposed",
|
||||
"memory.committed",
|
||||
"memory.forgotten",
|
||||
"artifact.created",
|
||||
"artifact.version",
|
||||
"artifact.promoted",
|
||||
"citation.attached",
|
||||
"mode.changed",
|
||||
"run.started",
|
||||
"run.cancelled",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
"privacy.notice",
|
||||
"suggestion.shown",
|
||||
"suggestion.dismissed",
|
||||
"release.transition",
|
||||
"delegation.started",
|
||||
"delegation.completed",
|
||||
"delegation.failed",
|
||||
"memory.suppressed",
|
||||
"memory.retrieval_removed",
|
||||
"budget.exhausted",
|
||||
"side_effect.blocked",
|
||||
"side_effect.released",
|
||||
]);
|
||||
|
||||
const SURFACE_POLICIES: Readonly<Record<HuxSurface, SurfacePolicy>> = {
|
||||
chat: {
|
||||
maxItems: 80,
|
||||
initialItems: 12,
|
||||
evidenceLimit: 3,
|
||||
cancellationEffectLimit: 4,
|
||||
expandRoutineEvents: false,
|
||||
},
|
||||
worker: {
|
||||
maxItems: 250,
|
||||
initialItems: 40,
|
||||
evidenceLimit: 8,
|
||||
cancellationEffectLimit: 12,
|
||||
expandRoutineEvents: true,
|
||||
},
|
||||
telegram: {
|
||||
maxItems: 40,
|
||||
initialItems: 8,
|
||||
evidenceLimit: 2,
|
||||
cancellationEffectLimit: 3,
|
||||
expandRoutineEvents: false,
|
||||
},
|
||||
voice: {
|
||||
maxItems: 24,
|
||||
initialItems: 6,
|
||||
evidenceLimit: 1,
|
||||
cancellationEffectLimit: 2,
|
||||
expandRoutineEvents: false,
|
||||
},
|
||||
api: {
|
||||
maxItems: 250,
|
||||
initialItems: 40,
|
||||
evidenceLimit: 8,
|
||||
cancellationEffectLimit: 12,
|
||||
expandRoutineEvents: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function surfacePolicy(surface: HuxSurface): SurfacePolicy {
|
||||
return { ...SURFACE_POLICIES[surface] };
|
||||
}
|
||||
|
||||
export function activityTimelineEnabled(flags?: Iterable<string>): boolean {
|
||||
if (!flags) return false;
|
||||
const enabled = new Set(flags);
|
||||
return enabled.has(FOUNDATION_FLAG) && enabled.has(ACTIVITY_TIMELINE_FLAG);
|
||||
}
|
||||
|
||||
function phaseFor(kind: string, hasParent: boolean): ActivityPhase {
|
||||
if (kind === "message.user") return "intent";
|
||||
if (
|
||||
kind === "run.failed" ||
|
||||
kind === "delegation.failed" ||
|
||||
kind === "budget.exhausted"
|
||||
) {
|
||||
return "failure";
|
||||
}
|
||||
if (
|
||||
kind === "run.completed" ||
|
||||
kind === "run.cancelled" ||
|
||||
kind === "message.assistant" ||
|
||||
kind === "delegation.completed"
|
||||
) {
|
||||
return "completion";
|
||||
}
|
||||
if (kind === "delegation.started") return "delegation";
|
||||
if (
|
||||
kind.startsWith("decision.") ||
|
||||
kind.startsWith("approval.") ||
|
||||
kind === "mode.changed" ||
|
||||
kind === "privacy.notice" ||
|
||||
kind === "memory.suppressed" ||
|
||||
kind === "memory.retrieval_removed" ||
|
||||
kind === "side_effect.blocked"
|
||||
)
|
||||
return "decision";
|
||||
if (
|
||||
kind === "tool.result" ||
|
||||
kind === "citation.attached" ||
|
||||
kind === "artifact.version"
|
||||
) {
|
||||
return "evidence";
|
||||
}
|
||||
if (kind === "run.started" && hasParent) return "delegation";
|
||||
return "action";
|
||||
}
|
||||
|
||||
function toneFor(phase: ActivityPhase, kind: string): ActivityTone {
|
||||
if (phase === "failure") return "warning";
|
||||
if (phase === "completion") return "success";
|
||||
if (kind === "approval.requested") return "warning";
|
||||
if (phase === "action" || phase === "delegation") return "active";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function hiddenSummary(kind: string): string {
|
||||
if (kind === "tool.call") return "Hermes used a tool";
|
||||
if (kind === "tool.result") return "Tool evidence was recorded";
|
||||
if (kind === "run.failed")
|
||||
return "The run failed; sensitive detail is hidden";
|
||||
if (kind === "run.completed") return "The run completed";
|
||||
return "Activity recorded; sensitive detail is hidden";
|
||||
}
|
||||
|
||||
function normalizeEvent(
|
||||
raw: RawActivityEvent,
|
||||
conversationId: string,
|
||||
policy: SurfacePolicy,
|
||||
): ActivityItem | null {
|
||||
if (raw.schema !== "hux.event.v1" || !isOpaqueId(raw.id, "evt")) return null;
|
||||
if (!Number.isInteger(raw.seq) || (raw.seq as number) < 0) return null;
|
||||
if (!isRfc3339Utc(raw.ts) || raw.conversation_id !== conversationId)
|
||||
return null;
|
||||
if (typeof raw.kind !== "string" || !EVENT_KINDS.has(raw.kind)) return null;
|
||||
const redaction = raw.redaction as { level?: unknown } | null;
|
||||
const fullyHidden =
|
||||
raw.sensitivity === "restricted" || redaction?.level === "full";
|
||||
const phase = phaseFor(raw.kind, typeof raw.parent_event_id === "string");
|
||||
const references = Array.isArray(raw.evidence) ? raw.evidence : [];
|
||||
const evidence = references
|
||||
.map((item) => safeEvidenceLabel((item as { kind?: unknown } | null)?.kind))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
.slice(0, policy.evidenceLimit);
|
||||
return {
|
||||
id: raw.id,
|
||||
seq: raw.seq as number,
|
||||
timestamp: raw.ts,
|
||||
conversationId,
|
||||
runId:
|
||||
typeof raw.run_id === "string"
|
||||
? safeText(raw.run_id, "", 120) || undefined
|
||||
: undefined,
|
||||
kind: raw.kind,
|
||||
phase,
|
||||
tone: toneFor(phase, raw.kind),
|
||||
summary: fullyHidden
|
||||
? hiddenSummary(raw.kind)
|
||||
: safeText(raw.summary, hiddenSummary(raw.kind)),
|
||||
evidence,
|
||||
routine: phase === "action" || phase === "evidence",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge reconnect pages without duplicates. Existing sequence owners win;
|
||||
* conflicting replay entries are rejected instead of rewriting visible history.
|
||||
*/
|
||||
export function mergeActivityEvents(
|
||||
existing: readonly ActivityItem[],
|
||||
incoming: readonly RawActivityEvent[],
|
||||
conversationId: string,
|
||||
surface: HuxSurface,
|
||||
): MergeResult {
|
||||
const policy = surfacePolicy(surface);
|
||||
const byId = new Map(existing.map((item) => [item.id, item]));
|
||||
const bySeq = new Map(existing.map((item) => [item.seq, item]));
|
||||
let invalid = 0;
|
||||
let replays = 0;
|
||||
let conflicts = 0;
|
||||
const normalized: ActivityItem[] = [];
|
||||
for (const raw of incoming) {
|
||||
const item = normalizeEvent(raw, conversationId, policy);
|
||||
if (!item) {
|
||||
invalid += 1;
|
||||
continue;
|
||||
}
|
||||
normalized.push(item);
|
||||
}
|
||||
// A reconnect page is not required to preserve transport order. Sorting
|
||||
// before conflict resolution makes an initial replay deterministic.
|
||||
normalized.sort(
|
||||
(left, right) => left.seq - right.seq || left.id.localeCompare(right.id),
|
||||
);
|
||||
for (const item of normalized) {
|
||||
const priorId = byId.get(item.id);
|
||||
if (priorId) {
|
||||
if (priorId.seq === item.seq) replays += 1;
|
||||
else conflicts += 1;
|
||||
continue;
|
||||
}
|
||||
if (bySeq.has(item.seq)) {
|
||||
conflicts += 1;
|
||||
continue;
|
||||
}
|
||||
byId.set(item.id, item);
|
||||
bySeq.set(item.seq, item);
|
||||
}
|
||||
const ordered = [...byId.values()].sort(
|
||||
(left, right) => left.seq - right.seq || left.id.localeCompare(right.id),
|
||||
);
|
||||
const afterSeq = ordered.length ? ordered[ordered.length - 1].seq : -1;
|
||||
const truncated = Math.max(0, ordered.length - policy.maxItems);
|
||||
return {
|
||||
items: truncated ? ordered.slice(-policy.maxItems) : ordered,
|
||||
afterSeq,
|
||||
invalid,
|
||||
replays,
|
||||
conflicts,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCancellationReceipt(
|
||||
raw: RawCancellationReceipt,
|
||||
surface: HuxSurface,
|
||||
): CancellationReceipt | null {
|
||||
const outcomes = new Set([
|
||||
"cancelled",
|
||||
"already_complete",
|
||||
"failed_to_cancel",
|
||||
]);
|
||||
if (raw.schema !== "hux.cancel_receipt.v1" || !isOpaqueId(raw.id, "rcpt"))
|
||||
return null;
|
||||
if (typeof raw.run_id !== "string" || !isRfc3339Utc(raw.requested_at))
|
||||
return null;
|
||||
if (typeof raw.outcome !== "string" || !outcomes.has(raw.outcome))
|
||||
return null;
|
||||
const policy = surfacePolicy(surface);
|
||||
const effects = Array.isArray(raw.side_effects) ? raw.side_effects : [];
|
||||
const sideEffects = effects
|
||||
.slice(0, policy.cancellationEffectLimit)
|
||||
.map((value) => {
|
||||
const effect = value as {
|
||||
description?: unknown;
|
||||
reverted?: unknown;
|
||||
} | null;
|
||||
return {
|
||||
description: safeText(
|
||||
effect?.description,
|
||||
"A side effect was recorded",
|
||||
280,
|
||||
),
|
||||
reverted: effect?.reverted === true,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: raw.id,
|
||||
runId: safeText(raw.run_id, "unknown run", 120),
|
||||
requestedAt: raw.requested_at,
|
||||
acknowledgedAt: isRfc3339Utc(raw.acknowledged_at)
|
||||
? raw.acknowledged_at
|
||||
: undefined,
|
||||
completedAt: isRfc3339Utc(raw.completed_at) ? raw.completed_at : undefined,
|
||||
outcome: raw.outcome as CancellationReceipt["outcome"],
|
||||
sideEffects,
|
||||
omittedEffects: Math.max(0, effects.length - sideEffects.length),
|
||||
};
|
||||
}
|
||||
76
dockerfiles/hermes-webui-hux/activity/security.ts
Normal file
76
dockerfiles/hermes-webui-hux/activity/security.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/** Display-only redaction. Raw event payloads must never cross this boundary. */
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
||||
const PEM_BLOCK = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi;
|
||||
const AUTH_VALUE =
|
||||
/\b(password|passwd|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
|
||||
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
|
||||
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
|
||||
const CREDENTIAL_BLOB =
|
||||
/\b(?=[A-Za-z0-9_+/#.-]{40,}\b)(?=[A-Za-z0-9_+/#.-]*[A-Za-z])(?=[A-Za-z0-9_+/#.-]*\d)[A-Za-z0-9_+/#.-]+\b/g;
|
||||
const URI_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
|
||||
|
||||
/** Return bounded plain text or a safe fallback; objects are never stringified. */
|
||||
export function safeText(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
maxLength = 280,
|
||||
): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const limit = Math.max(1, maxLength);
|
||||
const redacted = value
|
||||
.replace(PEM_BLOCK, "[redacted credential]")
|
||||
.replace(AUTH_VALUE, "$1=[redacted]")
|
||||
.replace(BEARER, "Bearer [redacted]")
|
||||
.replace(JWT, "[redacted credential]")
|
||||
.replace(URI_USERINFO, "$1[redacted]@")
|
||||
.replace(CREDENTIAL_BLOB, "[redacted credential]")
|
||||
.replace(CONTROL_CHARACTERS, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!redacted) return fallback;
|
||||
return redacted.length <= limit
|
||||
? redacted
|
||||
: limit === 1
|
||||
? "…"
|
||||
: `${redacted.slice(0, limit - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
const EVIDENCE_LABELS: Readonly<Record<string, string>> = {
|
||||
message: "Message",
|
||||
tool_call: "Tool call",
|
||||
tool_result: "Tool result",
|
||||
artifact_version: "Artifact version",
|
||||
source: "Source",
|
||||
passage: "Supporting passage",
|
||||
memory: "Memory record",
|
||||
approval: "Approval",
|
||||
run: "Run",
|
||||
url: "Website",
|
||||
file: "File",
|
||||
build: "Build",
|
||||
flux: "Flux reconciliation",
|
||||
pod: "Workload",
|
||||
};
|
||||
|
||||
/** Evidence labels are deliberately detached from IDs, URIs, and payloads. */
|
||||
export function safeEvidenceLabel(
|
||||
kind: unknown,
|
||||
): { kind: string; label: string } | null {
|
||||
if (typeof kind !== "string" || !(kind in EVIDENCE_LABELS)) return null;
|
||||
return { kind, label: EVIDENCE_LABELS[kind] };
|
||||
}
|
||||
|
||||
export function isRfc3339Utc(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) &&
|
||||
!Number.isNaN(Date.parse(value))
|
||||
);
|
||||
}
|
||||
|
||||
export function isOpaqueId(value: unknown, prefix?: string): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
if (!/^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/.test(value)) return false;
|
||||
return !prefix || value.startsWith(`${prefix}_`);
|
||||
}
|
||||
85
dockerfiles/hermes-webui-hux/activity/styles.css
Normal file
85
dockerfiles/hermes-webui-hux/activity/styles.css
Normal file
@ -0,0 +1,85 @@
|
||||
/* HUX-01 is opt-in. These scoped styles do not alter the existing WebUI. */
|
||||
|
||||
.hux-activity {
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, Canvas 94%, #12394a 6%);
|
||||
color: CanvasText;
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.hux-activity-chat {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hux-activity-row {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.hux-activity-row:hover,
|
||||
.hux-activity-row:focus-within {
|
||||
background: color-mix(in srgb, currentColor 5%, transparent);
|
||||
}
|
||||
|
||||
.hux-activity-marker {
|
||||
display: grid;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.hux-tone-active .hux-activity-marker {
|
||||
color: #39c6df;
|
||||
}
|
||||
.hux-tone-success .hux-activity-marker {
|
||||
color: #4ade80;
|
||||
}
|
||||
.hux-tone-warning .hux-activity-marker {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.hux-evidence-chip {
|
||||
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
border-radius: 9999px;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
color: color-mix(in srgb, currentColor 65%, transparent);
|
||||
}
|
||||
|
||||
.hux-activity-warning,
|
||||
.hux-cancellation-receipt {
|
||||
margin-top: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, #f59e0b 10%, transparent);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.hux-activity-more {
|
||||
margin-top: 0.625rem;
|
||||
min-height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: color-mix(in srgb, currentColor 70%, transparent);
|
||||
}
|
||||
|
||||
.hux-activity-more:hover,
|
||||
.hux-activity-more:focus-visible {
|
||||
background: color-mix(in srgb, currentColor 7%, transparent);
|
||||
outline: 2px solid color-mix(in srgb, #39c6df 65%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-activity * {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
94
dockerfiles/hermes-webui-hux/activity/types.ts
Normal file
94
dockerfiles/hermes-webui-hux/activity/types.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/** Safe frontend types for the opt-in HUX-01 activity timeline. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
|
||||
export type ActivityPhase =
|
||||
| "intent"
|
||||
| "action"
|
||||
| "evidence"
|
||||
| "delegation"
|
||||
| "decision"
|
||||
| "completion"
|
||||
| "failure";
|
||||
|
||||
export type ActivityTone = "neutral" | "active" | "success" | "warning";
|
||||
|
||||
export interface RawActivityEvent {
|
||||
schema?: unknown;
|
||||
id?: unknown;
|
||||
seq?: unknown;
|
||||
ts?: unknown;
|
||||
conversation_id?: unknown;
|
||||
run_id?: unknown;
|
||||
parent_event_id?: unknown;
|
||||
kind?: unknown;
|
||||
summary?: unknown;
|
||||
detail?: unknown;
|
||||
evidence?: unknown;
|
||||
provenance?: unknown;
|
||||
sensitivity?: unknown;
|
||||
redaction?: unknown;
|
||||
}
|
||||
|
||||
export interface SafeEvidence {
|
||||
kind: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
conversationId: string;
|
||||
runId?: string;
|
||||
kind: string;
|
||||
phase: ActivityPhase;
|
||||
tone: ActivityTone;
|
||||
summary: string;
|
||||
evidence: SafeEvidence[];
|
||||
routine: boolean;
|
||||
}
|
||||
|
||||
export interface SurfacePolicy {
|
||||
maxItems: number;
|
||||
initialItems: number;
|
||||
evidenceLimit: number;
|
||||
cancellationEffectLimit: number;
|
||||
expandRoutineEvents: boolean;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
items: ActivityItem[];
|
||||
afterSeq: number;
|
||||
invalid: number;
|
||||
replays: number;
|
||||
conflicts: number;
|
||||
truncated: number;
|
||||
}
|
||||
|
||||
export interface RawCancellationReceipt {
|
||||
schema?: unknown;
|
||||
id?: unknown;
|
||||
run_id?: unknown;
|
||||
requested_at?: unknown;
|
||||
acknowledged_at?: unknown;
|
||||
completed_at?: unknown;
|
||||
outcome?: unknown;
|
||||
side_effects?: unknown;
|
||||
}
|
||||
|
||||
export interface CancellationEffect {
|
||||
description: string;
|
||||
reverted: boolean;
|
||||
}
|
||||
|
||||
export interface CancellationReceipt {
|
||||
id: string;
|
||||
runId: string;
|
||||
requestedAt: string;
|
||||
acknowledgedAt?: string;
|
||||
completedAt?: string;
|
||||
outcome: "cancelled" | "already_complete" | "failed_to_cancel";
|
||||
sideEffects: CancellationEffect[];
|
||||
omittedEffects: number;
|
||||
}
|
||||
221
dockerfiles/hermes-webui-hux/artifacts/ArtifactWorkspace.tsx
Normal file
221
dockerfiles/hermes-webui-hux/artifacts/ArtifactWorkspace.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
/** Accessible HUX-04 artifact UI. This module never fetches or executes content. */
|
||||
|
||||
import {
|
||||
Code2,
|
||||
Download,
|
||||
FileText,
|
||||
GitCompare,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Pencil,
|
||||
Share2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { artifactsEnabled, normalizeArtifactWorkspace, normalizePreview } from "./model.ts";
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactAuthorization,
|
||||
ArtifactMutationIntent,
|
||||
ArtifactPreview,
|
||||
ArtifactScope,
|
||||
DiffIntent,
|
||||
HuxIdentity,
|
||||
PromoteIntent,
|
||||
RawArtifactEnvelope,
|
||||
RawPreviewPayload,
|
||||
} from "./types.ts";
|
||||
|
||||
interface ArtifactWorkspaceProps {
|
||||
flags?: Iterable<string>;
|
||||
identity: HuxIdentity;
|
||||
scope: ArtifactScope;
|
||||
page: RawArtifactEnvelope;
|
||||
previews?: readonly RawPreviewPayload[];
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onLoadPreview?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
||||
onContinueEdit?: (intent: ArtifactMutationIntent) => void;
|
||||
onDiff?: (intent: DiffIntent) => void;
|
||||
onAuthorize?: (action: "download" | "share", intent: ArtifactMutationIntent) => void;
|
||||
onDownload?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
||||
onShare?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
||||
onPromote?: (intent: PromoteIntent) => void;
|
||||
}
|
||||
|
||||
function intent(artifact: Artifact, scope: ArtifactScope): ArtifactMutationIntent {
|
||||
return {artifactId: artifact.id, expectedVersion: artifact.currentVersion, scope};
|
||||
}
|
||||
|
||||
function RendererIcon({artifact}: {artifact: Artifact}) {
|
||||
if (artifact.renderer === "image") return <ImageIcon aria-hidden />;
|
||||
if (artifact.renderer === "code" || artifact.renderer === "data") return <Code2 aria-hidden />;
|
||||
return <FileText aria-hidden />;
|
||||
}
|
||||
|
||||
function Preview({artifact, preview}: {artifact: Artifact; preview: ArtifactPreview | null}) {
|
||||
if (!preview) return <div className="hux-artifact-preview-empty">
|
||||
<RendererIcon artifact={artifact} />
|
||||
<p>Preview not loaded. Content stays behind its verified content reference.</p>
|
||||
</div>;
|
||||
if (preview.renderer === "image") {
|
||||
return <figure className="hux-artifact-image">
|
||||
<img src={preview.blobUrl!} alt={`Preview of ${artifact.title}`} />
|
||||
<figcaption>{preview.mime}; local authorized preview</figcaption>
|
||||
</figure>;
|
||||
}
|
||||
if (preview.renderer === "audio") {
|
||||
return <div className="hux-artifact-audio">
|
||||
<audio src={preview.blobUrl!} controls preload="metadata">
|
||||
Audio preview is unavailable in this browser.
|
||||
</audio>
|
||||
<span>{preview.mime}; local authorized preview</span>
|
||||
</div>;
|
||||
}
|
||||
const className = `hux-artifact-${preview.renderer}`;
|
||||
return <div className={className} aria-label={`${preview.renderer} preview`}>
|
||||
{preview.renderer === "report" && <h3>Report preview</h3>}
|
||||
<pre tabIndex={0}><code>{preview.text}</code></pre>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AuthorizationAction({action, state, busy, onAuthorize, onAction}: {
|
||||
action: "download" | "share";
|
||||
state: ArtifactAuthorization["download"];
|
||||
busy: boolean;
|
||||
onAuthorize: () => void;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const label = action === "download" ? "Download" : "Share";
|
||||
const Icon = action === "download" ? Download : Share2;
|
||||
if (state === "authorized") return <button type="button" disabled={busy} onClick={onAction}>
|
||||
<Icon aria-hidden /> {label}
|
||||
</button>;
|
||||
if (state === "requires_approval") return <button type="button" disabled={busy} onClick={onAuthorize}>
|
||||
<Icon aria-hidden /> Request {label.toLowerCase()} approval
|
||||
</button>;
|
||||
return <button type="button" disabled aria-label={`${label} unavailable`}>
|
||||
<Icon aria-hidden /> {label} unavailable
|
||||
</button>;
|
||||
}
|
||||
|
||||
function VersionHistory({artifact, selectedVersion, setSelectedVersion, busy, scope, onDiff}: {
|
||||
artifact: Artifact;
|
||||
selectedVersion: number;
|
||||
setSelectedVersion: (version: number) => void;
|
||||
busy: boolean;
|
||||
scope: ArtifactScope;
|
||||
onDiff?: ArtifactWorkspaceProps["onDiff"];
|
||||
}) {
|
||||
return <section className="hux-artifact-history" aria-labelledby="hux-artifact-history-title">
|
||||
<h3 id="hux-artifact-history-title"><History aria-hidden /> Immutable version history</h3>
|
||||
<ol>{[...artifact.versions].reverse().map((version) => <li key={version.version}
|
||||
data-current={version.version === artifact.currentVersion}>
|
||||
<button type="button" aria-pressed={selectedVersion === version.version}
|
||||
onClick={() => setSelectedVersion(version.version)}>
|
||||
<strong>Version {version.version}</strong>
|
||||
<span>{version.note || `Created by ${version.createdBy}`}</span>
|
||||
<time dateTime={version.createdAt}>{new Date(version.createdAt).toLocaleString()}</time>
|
||||
</button>
|
||||
{version.version > 1 && onDiff && <button className="hux-artifact-diff" type="button"
|
||||
disabled={busy} onClick={() => onDiff({
|
||||
...intent(artifact, scope), fromVersion: version.diffFrom || version.version - 1,
|
||||
toVersion: version.version,
|
||||
})} aria-label={`Compare version ${version.version}`}><GitCompare aria-hidden /> Compare</button>}
|
||||
</li>)}</ol>
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** Render nothing unless all negotiated HUX-04 dependencies are active. */
|
||||
export function ArtifactWorkspace(props: ArtifactWorkspaceProps) {
|
||||
const enabled = artifactsEnabled(props.flags);
|
||||
const page = useMemo(() => enabled ?
|
||||
normalizeArtifactWorkspace(props.page, props.identity, props.scope) : null,
|
||||
[enabled, props.identity, props.page, props.scope]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
||||
if (!enabled || !page) return null;
|
||||
const artifact = page.artifacts.find((item) => item.id === selectedId) || page.artifacts[0] || null;
|
||||
const version = artifact ? Math.min(selectedVersion || artifact.currentVersion, artifact.currentVersion) : null;
|
||||
const rawPreview = artifact && version ? props.previews?.find((item) =>
|
||||
item.artifact_id === artifact.id && item.version === version) : undefined;
|
||||
const preview = artifact && rawPreview ?
|
||||
normalizePreview(rawPreview, artifact, props.identity, props.scope) : null;
|
||||
const attachments = artifact ? page.attachments.find((item) => item.artifactId === artifact.id) : null;
|
||||
const authorization = artifact ? page.authorizations.find((item) => item.artifactId === artifact.id) : null;
|
||||
|
||||
function selectArtifact(item: Artifact) {
|
||||
setSelectedId(item.id);
|
||||
setSelectedVersion(item.currentVersion);
|
||||
props.onLoadPreview?.({...intent(item, props.scope), version: item.currentVersion});
|
||||
}
|
||||
|
||||
function selectVersion(next: number) {
|
||||
if (!artifact) return;
|
||||
setSelectedVersion(next);
|
||||
props.onLoadPreview?.({...intent(artifact, props.scope), version: next});
|
||||
}
|
||||
|
||||
return <section className="hux-artifacts" aria-label="Artifact workspace" aria-busy={props.busy}>
|
||||
<nav className="hux-artifact-list" aria-label="Artifacts">
|
||||
<header><h2><Sparkles aria-hidden /> Artifacts</h2>
|
||||
<p>Durable outputs from this conversation.</p></header>
|
||||
<ul>{page.artifacts.map((item) => <li key={item.id}>
|
||||
<button type="button" aria-current={artifact?.id === item.id ? "page" : undefined}
|
||||
onClick={() => selectArtifact(item)}>
|
||||
<RendererIcon artifact={item} />
|
||||
<span><strong>{item.title}</strong><small>{item.renderer} · v{item.currentVersion}</small></span>
|
||||
</button>
|
||||
</li>)}</ul>
|
||||
{!page.artifacts.length && <p className="hux-artifact-empty">No artifacts are attached.</p>}
|
||||
</nav>
|
||||
|
||||
<main className="hux-artifact-main">
|
||||
{props.error && <p className="hux-artifact-error" role="alert">{props.error}</p>}
|
||||
{page.rejected > 0 && <p className="hux-artifact-warning" role="status">
|
||||
Some artifacts were withheld because their owner or workspace binding was invalid.
|
||||
</p>}
|
||||
{artifact && version ? <>
|
||||
<header className="hux-artifact-heading">
|
||||
<div><span>{artifact.renderer}</span><h2>{artifact.title}</h2>
|
||||
<p>Version {version} · {artifact.sensitivity} · {artifact.language || artifact.type}</p></div>
|
||||
<div className="hux-artifact-actions">
|
||||
<button type="button" disabled={props.busy}
|
||||
onClick={() => props.onContinueEdit?.(intent(artifact, props.scope))}>
|
||||
<Pencil aria-hidden /> Continue editing
|
||||
</button>
|
||||
<AuthorizationAction action="download" state={authorization?.download || "unavailable"}
|
||||
busy={Boolean(props.busy)}
|
||||
onAuthorize={() => props.onAuthorize?.("download", intent(artifact, props.scope))}
|
||||
onAction={() => props.onDownload?.({...intent(artifact, props.scope), version})} />
|
||||
<AuthorizationAction action="share" state={authorization?.share || "unavailable"}
|
||||
busy={Boolean(props.busy)}
|
||||
onAuthorize={() => props.onAuthorize?.("share", intent(artifact, props.scope))}
|
||||
onAction={() => props.onShare?.({...intent(artifact, props.scope), version})} />
|
||||
</div>
|
||||
</header>
|
||||
<Preview artifact={artifact} preview={preview} />
|
||||
<section className="hux-artifact-evidence" aria-label="Attached research evidence">
|
||||
<div><strong>{attachments?.sourceCount || 0}</strong><span>Sources attached</span></div>
|
||||
<div><strong>{attachments?.citationCount || 0}</strong><span>Citations attached</span></div>
|
||||
</section>
|
||||
<button className="hux-artifact-promote" type="button" disabled={props.busy ||
|
||||
artifact.promotedVersion === version} onClick={() => props.onPromote?.({
|
||||
...intent(artifact, props.scope), targetProjectId: props.scope.projectId, version,
|
||||
})}>
|
||||
{artifact.promotedVersion === version ? "Promoted to this project" : "Promote version to project"}
|
||||
</button>
|
||||
</> : <p className="hux-artifact-empty">Select an artifact to inspect it.</p>}
|
||||
</main>
|
||||
|
||||
<aside className="hux-artifact-side" aria-label="Artifact versions">
|
||||
{artifact && version ? <VersionHistory artifact={artifact} selectedVersion={version}
|
||||
setSelectedVersion={selectVersion} busy={Boolean(props.busy)} scope={props.scope}
|
||||
onDiff={props.onDiff} /> : <p>Version history will appear here.</p>}
|
||||
</aside>
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{props.busy ? "Artifact workspace is updating" : `${page.artifacts.length} artifacts available`}
|
||||
</p>
|
||||
</section>;
|
||||
}
|
||||
94
dockerfiles/hermes-webui-hux/artifacts/endpoints.ts
Normal file
94
dockerfiles/hermes-webui-hux/artifacts/endpoints.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/** Same-origin endpoint descriptions for a future HUX-04 backend adapter. */
|
||||
|
||||
import { artifactsEnabled } from "./model.ts";
|
||||
import { isOpaqueId, normalizeIdentity, scopedPath } from "./security.ts";
|
||||
import type {
|
||||
ArtifactEndpoint,
|
||||
ArtifactEndpointContract,
|
||||
ArtifactScope,
|
||||
HuxIdentity,
|
||||
} from "./types.ts";
|
||||
|
||||
interface FoundationClient {
|
||||
apiVersion: string;
|
||||
identity: HuxIdentity;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
function endpoint(method: ArtifactEndpoint["method"], path: string, responseSchema: string,
|
||||
requiredBody?: readonly string[]): ArtifactEndpoint {
|
||||
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
|
||||
}
|
||||
|
||||
function versionNumber(value: number): string {
|
||||
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("Artifact version is invalid");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The server derives identity from the authenticated session and verifies the
|
||||
* project/conversation path binding. Mutation bodies MUST carry
|
||||
* expected_current_version and return 409 on stale state. Existing versions
|
||||
* are never PATCHed or deleted; edits append a new content_ref-backed version.
|
||||
*/
|
||||
export function createArtifactEndpointContract(client: FoundationClient, scope: ArtifactScope,
|
||||
flags?: Iterable<string>): ArtifactEndpointContract | null {
|
||||
if (!artifactsEnabled(flags)) return null;
|
||||
if (client.apiVersion !== "hux.v1") throw new TypeError("HUX v1 client required");
|
||||
normalizeIdentity({tenant_ref: client.identity.tenantRef, user_ref: client.identity.userRef,
|
||||
surface: client.identity.surface});
|
||||
const collection = scopedPath("/projects", scope);
|
||||
|
||||
function itemPath(artifactId: string): string {
|
||||
if (!isOpaqueId(artifactId, "art")) throw new TypeError("Opaque artifact id required");
|
||||
return scopedPath("/projects", scope, artifactId);
|
||||
}
|
||||
|
||||
function itemEndpoint(artifactId: string, suffix = ""): string {
|
||||
return client.endpoint(`${itemPath(artifactId)}${suffix}`);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
list: endpoint("GET", client.endpoint(collection), "hux.artifact_workspace.v1"),
|
||||
item(artifactId) {
|
||||
return endpoint("GET", itemEndpoint(artifactId), "hux.artifact_response.v1");
|
||||
},
|
||||
preview(artifactId, version) {
|
||||
return endpoint("GET", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/preview`),
|
||||
"hux.artifact_preview.v1");
|
||||
},
|
||||
diff(artifactId, fromVersion, toVersion) {
|
||||
if (fromVersion >= toVersion) throw new TypeError("Artifact diff range is invalid");
|
||||
return endpoint("POST", itemEndpoint(artifactId, "/diffs"), "hux.artifact_diff.v1",
|
||||
Object.freeze(["from_version", "to_version", "expected_current_version"]));
|
||||
},
|
||||
appendVersion(artifactId) {
|
||||
return endpoint("POST", itemEndpoint(artifactId, "/versions"), "hux.artifact_response.v1",
|
||||
Object.freeze(["content_ref", "expected_current_version"]));
|
||||
},
|
||||
continueEdit(artifactId) {
|
||||
return endpoint("POST", itemEndpoint(artifactId, "/continue"), "hux.artifact_edit_intent.v1",
|
||||
Object.freeze(["version", "expected_current_version"]));
|
||||
},
|
||||
authorize(artifactId, action) {
|
||||
if (action !== "download" && action !== "share") throw new TypeError("Artifact action is invalid");
|
||||
return endpoint("POST", itemEndpoint(artifactId, `/authorizations/${action}`),
|
||||
"hux.artifact_authorization.v1", Object.freeze(["expected_current_version"]));
|
||||
},
|
||||
download(artifactId, version) {
|
||||
return endpoint("GET", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/download`),
|
||||
"hux.artifact_content.v1");
|
||||
},
|
||||
share(artifactId, version) {
|
||||
return endpoint("POST", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/shares`),
|
||||
"hux.artifact_share.v1", Object.freeze(["expected_current_version"]));
|
||||
},
|
||||
promote(artifactId) {
|
||||
return endpoint("POST", itemEndpoint(artifactId, "/promotions"), "hux.artifact_response.v1",
|
||||
Object.freeze(["target_project_id", "version", "expected_current_version"]));
|
||||
},
|
||||
sources(artifactId) {
|
||||
return endpoint("GET", itemEndpoint(artifactId, "/sources"), "hux.artifact_sources.v1");
|
||||
},
|
||||
});
|
||||
}
|
||||
14
dockerfiles/hermes-webui-hux/artifacts/index.ts
Normal file
14
dockerfiles/hermes-webui-hux/artifacts/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export { ArtifactWorkspace } from "./ArtifactWorkspace.tsx";
|
||||
export { createArtifactEndpointContract } from "./endpoints.ts";
|
||||
export {
|
||||
ARTIFACTS_FLAG,
|
||||
FOUNDATION_FLAG,
|
||||
PROJECTS_FLAG,
|
||||
artifactsEnabled,
|
||||
normalizeArtifact,
|
||||
normalizeArtifactWorkspace,
|
||||
normalizePreview,
|
||||
preservesImmutableHistory,
|
||||
} from "./model.ts";
|
||||
export { ArtifactContractError } from "./security.ts";
|
||||
export type * from "./types.ts";
|
||||
239
dockerfiles/hermes-webui-hux/artifacts/model.ts
Normal file
239
dockerfiles/hermes-webui-hux/artifacts/model.ts
Normal file
@ -0,0 +1,239 @@
|
||||
/** Fail-closed normalization and immutable-history invariants for HUX-04. */
|
||||
|
||||
import {
|
||||
ArtifactContractError,
|
||||
isMime,
|
||||
isOpaqueId,
|
||||
isSha256,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
normalizeScope,
|
||||
safeBlobUrl,
|
||||
safePreviewText,
|
||||
safeText,
|
||||
sameIdentity,
|
||||
sameScope,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactAttachments,
|
||||
ArtifactAuthorization,
|
||||
ArtifactPreview,
|
||||
ArtifactRenderer,
|
||||
ArtifactScope,
|
||||
ArtifactType,
|
||||
ArtifactVersion,
|
||||
AuthorizationStatus,
|
||||
HuxIdentity,
|
||||
RawArtifact,
|
||||
RawArtifactEnvelope,
|
||||
RawArtifactVersion,
|
||||
RawPreviewPayload,
|
||||
Sensitivity,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const PROJECTS_FLAG = "hux.projects";
|
||||
export const ARTIFACTS_FLAG = "hux.artifacts";
|
||||
const TYPES = new Set<ArtifactType>([
|
||||
"markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio",
|
||||
]);
|
||||
const SENSITIVITIES = new Set<Sensitivity>(["public", "personal", "sensitive", "restricted"]);
|
||||
const AUTHORIZATION = new Set<AuthorizationStatus>(["unavailable", "requires_approval", "authorized"]);
|
||||
const ACTORS = new Set(["user", "assistant", "tool", "system", "operator"]);
|
||||
const MAX_ARTIFACTS = 200;
|
||||
const MAX_VERSIONS = 200;
|
||||
|
||||
export function artifactsEnabled(flags?: Iterable<string>): boolean {
|
||||
const active = new Set(flags || []);
|
||||
return active.has(FOUNDATION_FLAG) && active.has(PROJECTS_FLAG) && active.has(ARTIFACTS_FLAG);
|
||||
}
|
||||
|
||||
function rendererFor(type: ArtifactType, language: string): ArtifactRenderer {
|
||||
if (type === "image") return "image";
|
||||
if (type === "audio") return "audio";
|
||||
if (["code", "html", "svg"].includes(type)) return "code";
|
||||
if (["json", "csv"].includes(type)) return "data";
|
||||
if (/^(report|research|analysis)$/i.test(language)) return "report";
|
||||
return "document";
|
||||
}
|
||||
|
||||
function mimeAllowed(type: ArtifactType, mime: string): boolean {
|
||||
if (type === "image") return ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(mime);
|
||||
if (type === "audio") return ["audio/mpeg", "audio/ogg", "audio/wav", "audio/webm"].includes(mime);
|
||||
if (type === "html") return mime === "text/html";
|
||||
if (type === "svg") return mime === "image/svg+xml";
|
||||
if (type === "json") return mime === "application/json" || mime === "text/plain";
|
||||
if (type === "csv") return mime === "text/csv" || mime === "text/plain";
|
||||
if (type === "markdown") return mime === "text/markdown" || mime === "text/plain";
|
||||
if (type === "code") return mime.startsWith("text/") || mime === "application/json";
|
||||
return mime === "application/pdf" || mime === "text/plain" || mime === "text/markdown";
|
||||
}
|
||||
|
||||
function normalizeVersion(raw: RawArtifactVersion, type: ArtifactType): ArtifactVersion | null {
|
||||
const actor = raw.created_by as Record<string, unknown> | null;
|
||||
const content = raw.content_ref as Record<string, unknown> | null;
|
||||
if (!Number.isInteger(raw.version) || (raw.version as number) < 1 || !isUtc(raw.created_at) ||
|
||||
!actor || !ACTORS.has(String(actor.type)) || typeof actor.id !== "string" ||
|
||||
actor.id.length < 1 || actor.id.length > 120 || !content ||
|
||||
!isSha256(content.hash) || !Number.isSafeInteger(content.bytes) ||
|
||||
(content.bytes as number) < 0 || !isMime(content.mime) ||
|
||||
!mimeAllowed(type, content.mime) || (raw.diff_from !== undefined &&
|
||||
(!Number.isInteger(raw.diff_from) || (raw.diff_from as number) < 1))) return null;
|
||||
let lineage: ArtifactVersion["lineage"] = null;
|
||||
if (raw.lineage !== undefined) {
|
||||
const value = raw.lineage as Record<string, unknown> | null;
|
||||
if (!value || !isOpaqueId(value.artifact_id, "art") ||
|
||||
!Number.isInteger(value.version) || (value.version as number) < 1) return null;
|
||||
lineage = {artifactId: value.artifact_id, version: value.version as number};
|
||||
}
|
||||
return {
|
||||
version: raw.version as number,
|
||||
createdAt: raw.created_at,
|
||||
createdBy: String(actor.type),
|
||||
contentRef: {hash: content.hash, bytes: content.bytes as number, mime: content.mime},
|
||||
diffFrom: raw.diff_from as number | undefined || null,
|
||||
lineage,
|
||||
note: safeText(raw.note, "", 200),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeArtifact(raw: RawArtifact, expectedOwner: string, scope: ArtifactScope): Artifact | null {
|
||||
if (raw.schema !== "hux.artifact.v1" || !isOpaqueId(raw.id, "art") ||
|
||||
raw.owner !== expectedOwner || raw.project_id !== scope.projectId ||
|
||||
raw.conversation_id !== scope.conversationId || !TYPES.has(raw.type as ArtifactType) ||
|
||||
!Number.isInteger(raw.current_version) || (raw.current_version as number) < 1 ||
|
||||
!Array.isArray(raw.versions) || raw.versions.length < 1 || raw.versions.length > MAX_VERSIONS ||
|
||||
!SENSITIVITIES.has(raw.sensitivity as Sensitivity) || !isUtc(raw.created_at) ||
|
||||
!isUtc(raw.updated_at) || raw.updated_at < raw.created_at) return null;
|
||||
const type = raw.type as ArtifactType;
|
||||
const language = safeText(raw.language, "", 40);
|
||||
const title = safeText(raw.title, "", 200);
|
||||
if (!title) return null;
|
||||
const versions = raw.versions.map((item) => normalizeVersion(item as RawArtifactVersion, type));
|
||||
if (versions.some((item) => item === null)) return null;
|
||||
const ordered = versions as ArtifactVersion[];
|
||||
if (ordered.some((item, index) => item.version !== index + 1 ||
|
||||
(index > 0 && item.createdAt < ordered[index - 1].createdAt) ||
|
||||
(item.diffFrom !== null && item.diffFrom >= item.version) ||
|
||||
(item.lineage?.artifactId === raw.id && item.lineage.version >= item.version)) ||
|
||||
raw.current_version !== ordered.length) return null;
|
||||
let promotedVersion: number | null = null;
|
||||
if (raw.promotion !== undefined) {
|
||||
const promotion = raw.promotion as Record<string, unknown> | null;
|
||||
if (!promotion || promotion.project_id !== scope.projectId ||
|
||||
!Number.isInteger(promotion.version) || (promotion.version as number) < 1 ||
|
||||
(promotion.version as number) > ordered.length || !isUtc(promotion.at)) return null;
|
||||
promotedVersion = promotion.version as number;
|
||||
}
|
||||
return {
|
||||
id: raw.id, owner: expectedOwner, projectId: scope.projectId,
|
||||
conversationId: scope.conversationId, type, renderer: rendererFor(type, language), language,
|
||||
title, currentVersion: raw.current_version as number, versions: ordered,
|
||||
sensitivity: raw.sensitivity as Sensitivity, promotedVersion, updatedAt: raw.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAttachments(raw: unknown, allowed: ReadonlySet<string>): ArtifactAttachments[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result: ArtifactAttachments[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
|
||||
const value = item as Record<string, unknown> | null;
|
||||
const sources = value?.source_ids;
|
||||
const citations = value?.citation_ids;
|
||||
if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) ||
|
||||
seen.has(value.artifact_id) || !Array.isArray(sources) || !Array.isArray(citations) ||
|
||||
sources.length > 500 || citations.length > 500 ||
|
||||
sources.some((id) => !isOpaqueId(id, "src")) ||
|
||||
citations.some((id) => !isOpaqueId(id, "cit")) ||
|
||||
new Set(sources).size !== sources.length || new Set(citations).size !== citations.length) continue;
|
||||
seen.add(value.artifact_id);
|
||||
result.push({artifactId: value.artifact_id, sourceCount: sources.length, citationCount: citations.length});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeAuthorizations(raw: unknown, allowed: ReadonlySet<string>): ArtifactAuthorization[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result: ArtifactAuthorization[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
|
||||
const value = item as Record<string, unknown> | null;
|
||||
if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) ||
|
||||
seen.has(value.artifact_id) || !AUTHORIZATION.has(value.download as AuthorizationStatus) ||
|
||||
!AUTHORIZATION.has(value.share as AuthorizationStatus)) continue;
|
||||
seen.add(value.artifact_id);
|
||||
result.push({artifactId: value.artifact_id, download: value.download as AuthorizationStatus,
|
||||
share: value.share as AuthorizationStatus});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeArtifactWorkspace(raw: RawArtifactEnvelope, expected: HuxIdentity,
|
||||
scope: ArtifactScope) {
|
||||
const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef,
|
||||
user_ref: expected.userRef, surface: expected.surface});
|
||||
if (!raw || typeof raw !== "object" || raw.schema !== "hux.artifact_workspace.v1" ||
|
||||
raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), trustedIdentity) ||
|
||||
!sameScope(normalizeScope(raw.binding), scope)) {
|
||||
throw new ArtifactContractError("Artifact workspace crossed its scope boundary");
|
||||
}
|
||||
const records = Array.isArray(raw.artifacts) ? raw.artifacts : [];
|
||||
const normalized = records.slice(0, MAX_ARTIFACTS)
|
||||
.map((item) => normalizeArtifact(item as RawArtifact, trustedIdentity.userRef, scope))
|
||||
.filter((item): item is Artifact => item !== null);
|
||||
const ids = new Set<string>();
|
||||
const artifacts = normalized.filter((item) => !ids.has(item.id) && Boolean(ids.add(item.id)));
|
||||
const allowed = new Set(artifacts.map((item) => item.id));
|
||||
return {
|
||||
artifacts,
|
||||
attachments: normalizeAttachments(raw.attachments, allowed),
|
||||
authorizations: normalizeAuthorizations(raw.authorizations, allowed),
|
||||
rejected: records.length - artifacts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePreview(raw: RawPreviewPayload, artifact: Artifact, expected: HuxIdentity,
|
||||
scope: ArtifactScope): ArtifactPreview | null {
|
||||
try {
|
||||
const identity = normalizeIdentity(raw.identity);
|
||||
const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef,
|
||||
user_ref: expected.userRef, surface: expected.surface});
|
||||
const binding = normalizeScope(raw.binding);
|
||||
const version = artifact.versions.find((item) => item.version === raw.version);
|
||||
if (raw.schema !== "hux.artifact_preview.v1" || !sameIdentity(identity, trustedIdentity) ||
|
||||
!sameScope(binding, scope) || raw.artifact_id !== artifact.id || !version ||
|
||||
raw.hash !== version.contentRef.hash || raw.mime !== version.contentRef.mime) return null;
|
||||
if (artifact.renderer === "image" || artifact.renderer === "audio") {
|
||||
const blobUrl = safeBlobUrl(raw.blob_url);
|
||||
if (!blobUrl || raw.text !== undefined) return null;
|
||||
return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer,
|
||||
mime: version.contentRef.mime, text: null, blobUrl};
|
||||
}
|
||||
if (raw.blob_url !== undefined) return null;
|
||||
const text = safePreviewText(raw.text);
|
||||
if (text === null) return null;
|
||||
return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer,
|
||||
mime: version.contentRef.mime, text, blobUrl: null};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sameVersion(left: ArtifactVersion, right: ArtifactVersion): boolean {
|
||||
return left.version === right.version && left.createdAt === right.createdAt &&
|
||||
left.createdBy === right.createdBy && left.contentRef.hash === right.contentRef.hash &&
|
||||
left.contentRef.bytes === right.contentRef.bytes && left.contentRef.mime === right.contentRef.mime &&
|
||||
left.diffFrom === right.diffFrom && left.lineage?.artifactId === right.lineage?.artifactId &&
|
||||
left.lineage?.version === right.lineage?.version && left.note === right.note;
|
||||
}
|
||||
|
||||
/** A server mutation may append versions but never rewrite history or detach scope. */
|
||||
export function preservesImmutableHistory(before: Artifact, after: Artifact): boolean {
|
||||
return before.id === after.id && before.owner === after.owner &&
|
||||
before.projectId === after.projectId && before.conversationId === after.conversationId &&
|
||||
before.type === after.type && before.versions.length <= after.versions.length &&
|
||||
before.versions.every((version, index) => sameVersion(version, after.versions[index])) &&
|
||||
after.currentVersion === after.versions.length;
|
||||
}
|
||||
104
dockerfiles/hermes-webui-hux/artifacts/security.ts
Normal file
104
dockerfiles/hermes-webui-hux/artifacts/security.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/** Identity, content-reference, URL, and display guards for HUX-04. */
|
||||
|
||||
import type { ArtifactScope, HuxIdentity } from "./types.ts";
|
||||
|
||||
const OPAQUE_ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
|
||||
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
|
||||
const USER = /^usr_[0-9a-f]{16,64}$/;
|
||||
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
|
||||
const HASH = /^sha256:[0-9a-f]{64}$/;
|
||||
const MIME = /^[a-z0-9][a-z0-9.+-]{0,63}\/[a-z0-9][a-z0-9.+-]{0,63}$/i;
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/g;
|
||||
const PREVIEW_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
||||
const SURFACES = new Set(["chat", "worker", "telegram", "voice", "api"]);
|
||||
const CREDENTIALS = [
|
||||
/\b(?:password|passwd|token|secret|api[_-]?key)\s*[:=]\s*\S+/gi,
|
||||
/\bBearer\s+[A-Za-z0-9._~-]+/gi,
|
||||
/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi,
|
||||
];
|
||||
|
||||
export class ArtifactContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ArtifactContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpaqueId(value: unknown, prefix?: string): value is string {
|
||||
return typeof value === "string" && OPAQUE_ID.test(value) &&
|
||||
(!prefix || value.startsWith(`${prefix}_`));
|
||||
}
|
||||
|
||||
export function isUtc(value: unknown): value is string {
|
||||
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
export function isSha256(value: unknown): value is string {
|
||||
return typeof value === "string" && HASH.test(value);
|
||||
}
|
||||
|
||||
export function isMime(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length <= 120 && MIME.test(value);
|
||||
}
|
||||
|
||||
export function safeText(value: unknown, fallback: string, limit: number): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
let clean = value.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
||||
for (const pattern of CREDENTIALS) clean = clean.replace(pattern, "[redacted]");
|
||||
if (!clean) return fallback;
|
||||
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function safePreviewText(value: unknown, limit = 200_000): string | null {
|
||||
return typeof value === "string" && value.length <= limit && !PREVIEW_CONTROL.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeIdentity(raw: unknown): HuxIdentity {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new ArtifactContractError("Artifact identity is invalid");
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
|
||||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) ||
|
||||
typeof value.surface !== "string" || !SURFACES.has(value.surface)) {
|
||||
throw new ArtifactContractError("Artifact identity is invalid");
|
||||
}
|
||||
return {tenantRef: value.tenant_ref, userRef: value.user_ref,
|
||||
surface: value.surface as HuxIdentity["surface"]};
|
||||
}
|
||||
|
||||
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
|
||||
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
|
||||
left.surface === right.surface;
|
||||
}
|
||||
|
||||
export function normalizeScope(raw: unknown): ArtifactScope {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new ArtifactContractError("Artifact binding is invalid");
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (!isOpaqueId(value.project_id, "prj") || !isOpaqueId(value.conversation_id, "conv")) {
|
||||
throw new ArtifactContractError("Artifact binding is invalid");
|
||||
}
|
||||
return {projectId: value.project_id, conversationId: value.conversation_id};
|
||||
}
|
||||
|
||||
export function sameScope(left: ArtifactScope, right: ArtifactScope): boolean {
|
||||
return left.projectId === right.projectId && left.conversationId === right.conversationId;
|
||||
}
|
||||
|
||||
/** Only local object URLs created from an authorized response may reach img/audio. */
|
||||
export function safeBlobUrl(value: unknown): string | null {
|
||||
return typeof value === "string" && /^blob:[^\s]{1,2000}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export function scopedPath(base: string, scope: ArtifactScope, artifactId?: string): string {
|
||||
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) || base.includes("..") ||
|
||||
!isOpaqueId(scope.projectId, "prj") || !isOpaqueId(scope.conversationId, "conv") ||
|
||||
(artifactId !== undefined && !isOpaqueId(artifactId, "art"))) {
|
||||
throw new TypeError("Artifact endpoint scope is invalid");
|
||||
}
|
||||
const parts = [base.replace(/\/$/, ""), scope.projectId, "conversations", scope.conversationId, "artifacts"];
|
||||
if (artifactId) parts.push(artifactId);
|
||||
return parts.map((part, index) => index ? encodeURIComponent(part) : part).join("/");
|
||||
}
|
||||
294
dockerfiles/hermes-webui-hux/artifacts/styles.css
Normal file
294
dockerfiles/hermes-webui-hux/artifacts/styles.css
Normal file
@ -0,0 +1,294 @@
|
||||
.hux-artifacts {
|
||||
color: var(--hux-text, #dce8f4);
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: minmax(12rem, 0.75fr) minmax(20rem, 2fr) minmax(13rem, 0.85fr);
|
||||
min-height: 30rem;
|
||||
}
|
||||
|
||||
.hux-artifact-list,
|
||||
.hux-artifact-main,
|
||||
.hux-artifact-side {
|
||||
background: color-mix(in srgb, currentColor 2.5%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
|
||||
border-radius: 0.9rem;
|
||||
min-width: 0;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.hux-artifact-list header h2,
|
||||
.hux-artifact-history h3,
|
||||
.hux-artifact-list button,
|
||||
.hux-artifact-actions,
|
||||
.hux-artifact-actions button,
|
||||
.hux-artifact-heading,
|
||||
.hux-artifact-preview-empty,
|
||||
.hux-artifact-evidence,
|
||||
.hux-artifact-promote,
|
||||
.hux-artifact-diff {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hux-artifact-list header h2,
|
||||
.hux-artifact-history h3 {
|
||||
font-size: 0.9rem;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-artifact-list header p,
|
||||
.hux-artifact-heading p,
|
||||
.hux-artifact-empty {
|
||||
color: color-mix(in srgb, currentColor 58%, transparent);
|
||||
font-size: 0.7rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.hux-artifact-list ul,
|
||||
.hux-artifact-history ol {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
list-style: none;
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hux-artifact-list button,
|
||||
.hux-artifact-history button,
|
||||
.hux-artifact-actions button,
|
||||
.hux-artifact-promote,
|
||||
.hux-artifact-diff {
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
|
||||
border-radius: 0.6rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.hux-artifact-list button {
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-artifact-list button[aria-current="page"] {
|
||||
background: color-mix(in srgb, var(--hux-accent, #25d4c3) 11%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hux-accent, #25d4c3) 45%, transparent);
|
||||
}
|
||||
|
||||
.hux-artifact-list button > span {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hux-artifact-list strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hux-artifact-list small {
|
||||
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.hux-artifact-list svg,
|
||||
.hux-artifact-actions svg,
|
||||
.hux-artifact-history svg,
|
||||
.hux-artifact-preview-empty svg,
|
||||
.hux-artifact-promote svg {
|
||||
flex: 0 0 auto;
|
||||
height: 0.95rem;
|
||||
width: 0.95rem;
|
||||
}
|
||||
|
||||
.hux-artifact-heading {
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-artifact-heading > div:first-child > span {
|
||||
color: var(--hux-accent, #25d4c3);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-artifact-heading h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.hux-artifact-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hux-artifact-actions button,
|
||||
.hux-artifact-promote,
|
||||
.hux-artifact-diff {
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.hux-artifacts button:focus-visible,
|
||||
.hux-artifacts pre:focus-visible {
|
||||
outline: 2px solid var(--hux-accent, #25d4c3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hux-artifacts button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.hux-artifact-document,
|
||||
.hux-artifact-code,
|
||||
.hux-artifact-report,
|
||||
.hux-artifact-data,
|
||||
.hux-artifact-image,
|
||||
.hux-artifact-audio,
|
||||
.hux-artifact-preview-empty {
|
||||
background: color-mix(in srgb, #050a12 70%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
margin: 0.85rem 0;
|
||||
min-height: 15rem;
|
||||
overflow: auto;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.hux-artifact-report h3 {
|
||||
color: var(--hux-accent, #25d4c3);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.65rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-artifact-document pre,
|
||||
.hux-artifact-code pre,
|
||||
.hux-artifact-report pre,
|
||||
.hux-artifact-data pre {
|
||||
font: 0.76rem/1.6 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hux-artifact-preview-empty {
|
||||
align-content: center;
|
||||
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hux-artifact-image {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hux-artifact-image img {
|
||||
max-height: 32rem;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hux-artifact-image figcaption,
|
||||
.hux-artifact-audio span {
|
||||
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
font-size: 0.65rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.hux-artifact-audio {
|
||||
align-content: center;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.hux-artifact-audio audio { width: 100%; }
|
||||
|
||||
.hux-artifact-evidence {
|
||||
gap: 0.55rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.hux-artifact-evidence div {
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.hux-artifact-evidence strong { color: var(--hux-accent, #25d4c3); }
|
||||
.hux-artifact-evidence span { font-size: 0.65rem; }
|
||||
|
||||
.hux-artifact-promote { margin-left: auto; }
|
||||
|
||||
.hux-artifact-history ol > li {
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.hux-artifact-history ol > li[data-current="true"] { border-color: var(--hux-accent, #25d4c3); }
|
||||
|
||||
.hux-artifact-history li > button:first-child {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: 0.48rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-artifact-history li > button[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--hux-accent, #25d4c3) 9%, transparent);
|
||||
}
|
||||
|
||||
.hux-artifact-history span,
|
||||
.hux-artifact-history time {
|
||||
color: color-mix(in srgb, currentColor 56%, transparent);
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
|
||||
.hux-artifact-diff { font-size: 0.68rem; justify-self: end; }
|
||||
|
||||
.hux-artifact-error,
|
||||
.hux-artifact-warning {
|
||||
border-left: 2px solid #efb756;
|
||||
color: #efcf96;
|
||||
font-size: 0.72rem;
|
||||
margin: 0 0 0.7rem;
|
||||
padding-left: 0.55rem;
|
||||
}
|
||||
|
||||
.hux-artifact-error { border-color: #e36b7c; color: #f0a4ae; }
|
||||
|
||||
@media (max-width: 66rem) {
|
||||
.hux-artifacts { grid-template-columns: minmax(12rem, 0.7fr) minmax(20rem, 2fr); }
|
||||
.hux-artifact-side { grid-column: 1 / -1; }
|
||||
.hux-artifact-history ol { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.hux-artifacts { grid-template-columns: 1fr; }
|
||||
.hux-artifact-side { grid-column: auto; }
|
||||
.hux-artifact-heading { align-items: stretch; }
|
||||
.hux-artifact-actions { justify-content: stretch; }
|
||||
.hux-artifact-actions button { flex: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-artifacts * { scroll-behavior: auto; transition: none; }
|
||||
}
|
||||
174
dockerfiles/hermes-webui-hux/artifacts/types.ts
Normal file
174
dockerfiles/hermes-webui-hux/artifacts/types.ts
Normal file
@ -0,0 +1,174 @@
|
||||
/** Safe frontend contracts for the default-off HUX-04 artifact workspace. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
export type ArtifactType =
|
||||
| "markdown"
|
||||
| "code"
|
||||
| "html"
|
||||
| "svg"
|
||||
| "image"
|
||||
| "json"
|
||||
| "csv"
|
||||
| "document"
|
||||
| "audio";
|
||||
export type ArtifactRenderer = "document" | "code" | "image" | "report" | "data" | "audio";
|
||||
export type Sensitivity = "public" | "personal" | "sensitive" | "restricted";
|
||||
export type AuthorizationStatus = "unavailable" | "requires_approval" | "authorized";
|
||||
|
||||
export interface HuxIdentity {
|
||||
tenantRef: string;
|
||||
userRef: string;
|
||||
surface: HuxSurface;
|
||||
}
|
||||
|
||||
export interface ArtifactScope {
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export interface RawArtifactVersion {
|
||||
version?: unknown;
|
||||
created_at?: unknown;
|
||||
created_by?: unknown;
|
||||
message_id?: unknown;
|
||||
content_ref?: unknown;
|
||||
diff_from?: unknown;
|
||||
lineage?: unknown;
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
export interface RawArtifact {
|
||||
schema?: unknown;
|
||||
id?: unknown;
|
||||
owner?: unknown;
|
||||
conversation_id?: unknown;
|
||||
project_id?: unknown;
|
||||
type?: unknown;
|
||||
language?: unknown;
|
||||
title?: unknown;
|
||||
current_version?: unknown;
|
||||
versions?: unknown;
|
||||
promotion?: unknown;
|
||||
sensitivity?: unknown;
|
||||
created_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
}
|
||||
|
||||
export interface RawArtifactEnvelope {
|
||||
schema?: unknown;
|
||||
api_version?: unknown;
|
||||
identity?: unknown;
|
||||
binding?: unknown;
|
||||
artifacts?: unknown;
|
||||
attachments?: unknown;
|
||||
authorizations?: unknown;
|
||||
}
|
||||
|
||||
export interface ContentReference {
|
||||
hash: string;
|
||||
bytes: number;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
export interface ArtifactVersion {
|
||||
version: number;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
contentRef: ContentReference;
|
||||
diffFrom: number | null;
|
||||
lineage: {artifactId: string; version: number} | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
owner: string;
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
type: ArtifactType;
|
||||
renderer: ArtifactRenderer;
|
||||
language: string;
|
||||
title: string;
|
||||
currentVersion: number;
|
||||
versions: ArtifactVersion[];
|
||||
sensitivity: Sensitivity;
|
||||
promotedVersion: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ArtifactAttachments {
|
||||
artifactId: string;
|
||||
sourceCount: number;
|
||||
citationCount: number;
|
||||
}
|
||||
|
||||
export interface ArtifactAuthorization {
|
||||
artifactId: string;
|
||||
download: AuthorizationStatus;
|
||||
share: AuthorizationStatus;
|
||||
}
|
||||
|
||||
export interface ArtifactWorkspacePage {
|
||||
artifacts: Artifact[];
|
||||
attachments: ArtifactAttachments[];
|
||||
authorizations: ArtifactAuthorization[];
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface RawPreviewPayload {
|
||||
schema?: unknown;
|
||||
identity?: unknown;
|
||||
binding?: unknown;
|
||||
artifact_id?: unknown;
|
||||
version?: unknown;
|
||||
hash?: unknown;
|
||||
mime?: unknown;
|
||||
text?: unknown;
|
||||
blob_url?: unknown;
|
||||
}
|
||||
|
||||
export interface ArtifactPreview {
|
||||
artifactId: string;
|
||||
version: number;
|
||||
renderer: ArtifactRenderer;
|
||||
mime: string;
|
||||
text: string | null;
|
||||
blobUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ArtifactMutationIntent {
|
||||
artifactId: string;
|
||||
expectedVersion: number;
|
||||
scope: ArtifactScope;
|
||||
}
|
||||
|
||||
export interface PromoteIntent extends ArtifactMutationIntent {
|
||||
targetProjectId: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface DiffIntent extends ArtifactMutationIntent {
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
}
|
||||
|
||||
export interface ArtifactEndpoint {
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
responseSchema: string;
|
||||
requiredBody?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ArtifactEndpointContract {
|
||||
list: ArtifactEndpoint;
|
||||
item(artifactId: string): ArtifactEndpoint;
|
||||
preview(artifactId: string, version: number): ArtifactEndpoint;
|
||||
diff(artifactId: string, fromVersion: number, toVersion: number): ArtifactEndpoint;
|
||||
appendVersion(artifactId: string): ArtifactEndpoint;
|
||||
continueEdit(artifactId: string): ArtifactEndpoint;
|
||||
authorize(artifactId: string, action: "download" | "share"): ArtifactEndpoint;
|
||||
download(artifactId: string, version: number): ArtifactEndpoint;
|
||||
share(artifactId: string, version: number): ArtifactEndpoint;
|
||||
promote(artifactId: string): ArtifactEndpoint;
|
||||
sources(artifactId: string): ArtifactEndpoint;
|
||||
}
|
||||
181
dockerfiles/hermes-webui-hux/autonomy/AutonomyControls.tsx
Normal file
181
dockerfiles/hermes-webui-hux/autonomy/AutonomyControls.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
/** Accessible HUX-05 UI. Parent callbacks own every request and side effect. */
|
||||
|
||||
import { Ban, Hand, Octagon, ShieldCheck } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
AUTONOMY_LEVELS, BUDGET_MAX, CAPABILITIES, GRANT_DECISIONS,
|
||||
autonomyControlsEnabled, buildApprovalDecision, buildPolicyUpdate,
|
||||
buildStopIntent, normalizeAutonomyPage, normalizeStopReceipt,
|
||||
} from "./model.ts";
|
||||
import type {
|
||||
ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel,
|
||||
BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyUpdateIntent,
|
||||
RawAutonomyEnvelope, StopIntent,
|
||||
} from "./types.ts";
|
||||
|
||||
interface AutonomyControlsProps {
|
||||
flags?: Iterable<string>;
|
||||
identity: HuxIdentity;
|
||||
conversationId: string;
|
||||
page: RawAutonomyEnvelope;
|
||||
activeRun?: ActiveRun | null;
|
||||
stopReceipt?: Record<string, unknown> | null;
|
||||
now?: number;
|
||||
busy?: boolean;
|
||||
onUpdatePolicy?: (intent: PolicyUpdateIntent) => void;
|
||||
onDecideApproval?: (intent: ApprovalDecisionIntent) => void;
|
||||
onStop?: (intent: StopIntent) => void;
|
||||
}
|
||||
|
||||
const LABELS: Readonly<Record<Capability, string>> = {
|
||||
read_files: "Read files", write_files: "Write files", shell: "Run commands",
|
||||
network: "Use network", web_search: "Search the web", send_message: "Send messages",
|
||||
memory_write: "Save memory", artifact_write: "Change artifacts",
|
||||
spend_tokens: "Spend model tokens", delegate: "Delegate to agents", deploy: "Deploy changes",
|
||||
};
|
||||
|
||||
const MODES: Readonly<Record<AutonomyLevel, {label: string; help: string}>> = {
|
||||
ask_first: {label: "Ask First", help: "Pause before actions until you approve."},
|
||||
safe: {label: "Safe Actions", help: "Read-only work may proceed; external effects still pause."},
|
||||
autonomous: {label: "Autonomous", help: "Explicitly allowed capabilities may proceed within every budget."},
|
||||
};
|
||||
|
||||
function startingBudget(page: ReturnType<typeof normalizeAutonomyPage>): BudgetDraft {
|
||||
return {...page!.policy.budgets, spendCentsPerRun: 0, scopeLimit: "current_task"};
|
||||
}
|
||||
|
||||
function CapabilityMatrix({grants, setGrant, busy}: {
|
||||
grants: Readonly<Record<Capability, GrantDecision>>;
|
||||
setGrant: (capability: Capability, decision: GrantDecision) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return <fieldset className="hux-autonomy-matrix"><legend>Capability matrix</legend>
|
||||
<p id="hux-autonomy-matrix-help">Missing or invalid grants are denied.</p>
|
||||
<table><thead><tr><th scope="col">Capability</th><th scope="col">Decision</th></tr></thead>
|
||||
<tbody>{CAPABILITIES.map((capability) => <tr key={capability}>
|
||||
<th scope="row">{LABELS[capability]}</th><td><select
|
||||
aria-label={`${LABELS[capability]} decision`} aria-describedby="hux-autonomy-matrix-help"
|
||||
value={grants[capability]} disabled={busy}
|
||||
onChange={(event) => setGrant(capability, event.target.value as GrantDecision)}>
|
||||
{GRANT_DECISIONS.map((decision) => <option key={decision} value={decision}>
|
||||
{decision === "allow" ? "Allow" : decision === "ask" ? "Ask" : "Deny"}
|
||||
</option>)}</select></td>
|
||||
</tr>)}</tbody></table>
|
||||
</fieldset>;
|
||||
}
|
||||
|
||||
function BudgetControls({budget, setBudget, busy}: {
|
||||
budget: BudgetDraft;
|
||||
setBudget: (next: BudgetDraft) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const number = (field: keyof typeof BUDGET_MAX, label: string, step = 1) =>
|
||||
<label>{label}<input type="number" min="0" max={BUDGET_MAX[field]} step={step}
|
||||
value={budget[field]} disabled={busy} onChange={(event) => setBudget({...budget,
|
||||
[field]: Number(event.target.value)})} /></label>;
|
||||
return <fieldset className="hux-autonomy-budgets"><legend>Per-run budgets</legend>
|
||||
<p id="hux-autonomy-budget-help">Zero blocks that resource. Limits are enforced together.</p>
|
||||
<div aria-describedby="hux-autonomy-budget-help">
|
||||
{number("wallClockSeconds", "Time (seconds)")}
|
||||
{number("spendCentsPerRun", "Spend (cents)")}
|
||||
{number("tokensPerRun", "Tokens", 1000)}
|
||||
{number("toolCallsPerRun", "Tool calls")}
|
||||
{number("delegationsPerRun", "Subagents")}
|
||||
<label>Maximum scope<select value={budget.scopeLimit} disabled={busy}
|
||||
onChange={(event) => setBudget({...budget, scopeLimit: event.target.value as BudgetDraft["scopeLimit"]})}>
|
||||
<option value="current_task">Current task</option><option value="conversation">Conversation</option>
|
||||
<option value="project">Project</option></select></label>
|
||||
</div>
|
||||
</fieldset>;
|
||||
}
|
||||
|
||||
function ApprovalQueue({items, busy, decide}: {
|
||||
items: readonly ApprovalView[];
|
||||
busy: boolean;
|
||||
decide: (approval: ApprovalView, choice: ApprovalChoice) => void;
|
||||
}) {
|
||||
return <section className="hux-autonomy-approvals" aria-labelledby="hux-approval-title">
|
||||
<h3 id="hux-approval-title"><Hand aria-hidden /> Review external side effects</h3>
|
||||
{items.length ? <ol>{items.map((approval) => <li key={approval.id} data-risk={approval.risk}>
|
||||
<strong>{LABELS[approval.capability]}</strong><p>{approval.summary}</p>
|
||||
<small>Risk: {approval.risk}. Expires <time dateTime={approval.expiresAt}>
|
||||
{new Date(approval.expiresAt).toLocaleTimeString()}</time>.</small>
|
||||
<div aria-label="Approval choices">
|
||||
<button type="button" disabled={busy} onClick={() => decide(approval, "once")}>Allow once</button>
|
||||
<button type="button" disabled={busy} onClick={() => decide(approval, "session")}>This session</button>
|
||||
<button type="button" disabled={busy} onClick={() => decide(approval, "always")}>Always</button>
|
||||
<button type="button" disabled={busy} onClick={() => decide(approval, "deny")}><Ban aria-hidden /> Deny</button>
|
||||
</div>
|
||||
</li>)}</ol> : <p>No actions are waiting for approval.</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** Render nothing unless foundation, timeline, and autonomy are negotiated. */
|
||||
export function AutonomyControls(props: AutonomyControlsProps) {
|
||||
const enabled = autonomyControlsEnabled(props.flags);
|
||||
const page = useMemo(() => enabled ? normalizeAutonomyPage(props.page, props.identity,
|
||||
props.conversationId, props.now) : null,
|
||||
[enabled, props.page, props.identity, props.conversationId, props.now]);
|
||||
const [autonomy, setAutonomy] = useState<AutonomyLevel>("ask_first");
|
||||
const [grants, setGrants] = useState<Readonly<Record<Capability, GrantDecision>> | null>(null);
|
||||
const [budget, setBudget] = useState<BudgetDraft | null>(null);
|
||||
if (!enabled) return null;
|
||||
if (!page) return <section className="hux-autonomy" role="alert">
|
||||
Autonomy controls are unavailable because identity or policy scope could not be verified.
|
||||
</section>;
|
||||
const selectedAutonomy = grants ? autonomy : page.policy.autonomy;
|
||||
const selectedGrants = grants || page.policy.grants;
|
||||
const selectedBudget = budget || startingBudget(page);
|
||||
const receipt = props.stopReceipt && props.activeRun ?
|
||||
normalizeStopReceipt(props.stopReceipt, props.activeRun, props.identity) : null;
|
||||
|
||||
function decide(approval: ApprovalView, choice: ApprovalChoice) {
|
||||
try { props.onDecideApproval?.(buildApprovalDecision(approval, choice, props.identity,
|
||||
approval.requestedAt, props.now)); } catch { /* a stale approval remains denied */ }
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!props.activeRun) return;
|
||||
try { props.onStop?.(buildStopIntent(props.activeRun, props.identity, props.activeRun.runId)); }
|
||||
catch { /* an unowned run is never stopped */ }
|
||||
}
|
||||
|
||||
return <section className="hux-autonomy" aria-labelledby="hux-autonomy-title" aria-busy={props.busy}>
|
||||
<header><div><h2 id="hux-autonomy-title"><ShieldCheck aria-hidden /> Autonomy</h2>
|
||||
<p>Control what Hermes may do, how far it may go, and what always pauses.</p></div>
|
||||
<button className="hux-autonomy-stop" type="button" disabled={props.busy || !props.activeRun}
|
||||
onClick={stop}><Octagon aria-hidden /> Stop current run</button>
|
||||
</header>
|
||||
<form onSubmit={(event) => { event.preventDefault();
|
||||
try { props.onUpdatePolicy?.(buildPolicyUpdate(page.policy, props.identity,
|
||||
selectedAutonomy, selectedGrants, selectedBudget)); } catch { /* invalid budgets fail closed */ }
|
||||
}}>
|
||||
<fieldset className="hux-autonomy-levels"><legend>Autonomy level</legend>
|
||||
{AUTONOMY_LEVELS.map((level) => <label key={level}>
|
||||
<input type="radio" name="hux-autonomy-level" value={level}
|
||||
checked={selectedAutonomy === level} disabled={props.busy}
|
||||
onChange={() => {setAutonomy(level); setGrants({...selectedGrants});}} />
|
||||
<span><strong>{MODES[level].label}</strong><small>{MODES[level].help}</small></span>
|
||||
</label>)}
|
||||
</fieldset>
|
||||
<CapabilityMatrix grants={selectedGrants} busy={!!props.busy}
|
||||
setGrant={(capability, decision) => setGrants({...selectedGrants, [capability]: decision})} />
|
||||
<BudgetControls budget={selectedBudget} busy={!!props.busy} setBudget={setBudget} />
|
||||
<button className="hux-autonomy-save" type="submit" disabled={props.busy}>Save controls</button>
|
||||
</form>
|
||||
{page.rejectedApprovals > 0 && <p role="status" className="hux-autonomy-warning">
|
||||
Some approval requests were withheld because they were stale or out of scope.</p>}
|
||||
<ApprovalQueue items={page.approvals} busy={!!props.busy} decide={decide} />
|
||||
{receipt && <section className="hux-autonomy-receipt" aria-labelledby="hux-stop-receipt-title">
|
||||
<h3 id="hux-stop-receipt-title">Stop receipt</h3><p>Outcome: {receipt.outcome}.</p>
|
||||
<p>{receipt.stopped} effects reverted; {receipt.remaining} remain.</p>
|
||||
<ul>{receipt.effects.map((effect, index) => <li key={index}>
|
||||
{effect.description} — {effect.reverted ? "reverted" : "remaining"}</li>)}</ul>
|
||||
{receipt.omittedEffects > 0 && <p>{receipt.omittedEffects} additional effects omitted.</p>}
|
||||
</section>}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{props.busy ? "Updating autonomy controls" : `${page.approvals.length} approvals waiting`}
|
||||
</p>
|
||||
</section>;
|
||||
}
|
||||
48
dockerfiles/hermes-webui-hux/autonomy/endpoints.ts
Normal file
48
dockerfiles/hermes-webui-hux/autonomy/endpoints.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/** Same-origin endpoint descriptors for the future HUX-05 backend. */
|
||||
|
||||
import { ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, FOUNDATION_FLAG } from "./model.ts";
|
||||
import { isOpaqueId, normalizeIdentity, sameIdentity } from "./security.ts";
|
||||
import type { AutonomyEndpoint, AutonomyEndpointContract, HuxIdentity } from "./types.ts";
|
||||
|
||||
interface FoundationClient {
|
||||
apiVersion: string;
|
||||
identity: HuxIdentity;
|
||||
enabled(flag: string): boolean;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
function endpoint(method: AutonomyEndpoint["method"], path: string,
|
||||
responseSchema: string, requiredBody?: readonly string[]): AutonomyEndpoint {
|
||||
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
|
||||
}
|
||||
|
||||
/** Describe, but never call, the tenant-scoped HUX-05 interface. */
|
||||
export function createAutonomyEndpointContract(client: FoundationClient): AutonomyEndpointContract | null {
|
||||
if (client.apiVersion !== "hux.v1" ||
|
||||
![FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG].every((flag) => client.enabled(flag))) return null;
|
||||
const identity = normalizeIdentity(client.identity);
|
||||
if (!identity || !sameIdentity(identity, client.identity)) throw new TypeError("Scoped HUX identity required");
|
||||
const scoped = (root: string, id: string, prefix: string): string => {
|
||||
if (!isOpaqueId(id, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
|
||||
return client.endpoint(`${root}/${encodeURIComponent(id)}`);
|
||||
};
|
||||
return Object.freeze({
|
||||
identity: Object.freeze({...identity}),
|
||||
policy: endpoint("GET", client.endpoint("/autonomy/policy"), "hux.policy.v1"),
|
||||
updatePolicy: endpoint("PUT", client.endpoint("/autonomy/policy"), "hux.policy.v1",
|
||||
["expected_policy_id", "expected_updated_at", "autonomy", "grants", "budgets", "guardrails"]),
|
||||
approvals: endpoint("GET", client.endpoint("/approvals?status=pending&limit=100"), "hux.approval_page.v1"),
|
||||
decideApproval(approvalId: string) {
|
||||
return endpoint("POST", `${scoped("/approvals", approvalId, "apr")}/decision`, "hux.approval.v1",
|
||||
["expected_run_id", "expected_conversation_id", "expected_capability", "expected_requested_at", "choice"]);
|
||||
},
|
||||
stop(runId: string) {
|
||||
if (!/^run_[A-Za-z0-9._-]{1,116}$/.test(runId)) throw new TypeError("Exact run id required");
|
||||
return endpoint("POST", client.endpoint(`/runs/${encodeURIComponent(runId)}/stop`),
|
||||
"hux.cancel_receipt.v1", ["conversation_id"]);
|
||||
},
|
||||
release: Object.freeze({schema: "hux.release.v1" as const,
|
||||
requiredState: "live_verified" as const,
|
||||
featureFlags: Object.freeze([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG])}),
|
||||
});
|
||||
}
|
||||
12
dockerfiles/hermes-webui-hux/autonomy/index.ts
Normal file
12
dockerfiles/hermes-webui-hux/autonomy/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export { AutonomyControls } from "./AutonomyControls.tsx";
|
||||
export { createAutonomyEndpointContract } from "./endpoints.ts";
|
||||
export {
|
||||
ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, BUDGET_MAX, CAPABILITIES,
|
||||
FOUNDATION_FLAG, actionAuthorization, autonomyControlsEnabled,
|
||||
buildApprovalDecision, buildPolicyUpdate, buildStopIntent,
|
||||
normalizeAutonomyPage, normalizeStopReceipt,
|
||||
} from "./model.ts";
|
||||
export type {
|
||||
ActiveRun, ApprovalDecisionIntent, ApprovalView, AutonomyPage, BudgetDraft,
|
||||
Capability, HuxIdentity, PolicyUpdateIntent, StopIntent, StopReceiptView,
|
||||
} from "./types.ts";
|
||||
191
dockerfiles/hermes-webui-hux/autonomy/model.ts
Normal file
191
dockerfiles/hermes-webui-hux/autonomy/model.ts
Normal file
@ -0,0 +1,191 @@
|
||||
/** Schema normalization and fail-closed decisions for HUX-05. */
|
||||
|
||||
import { normalizeCancellationReceipt } from "../activity/model.ts";
|
||||
import {
|
||||
boundedInteger, exactKeys, identityKey, isOpaqueId, isUtc, normalizeIdentity,
|
||||
safeText, sameIdentity,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel,
|
||||
AutonomyPage, BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyScope,
|
||||
PolicyUpdateIntent, PolicyView, RawAutonomyEnvelope, StopIntent, StopReceiptView,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline";
|
||||
export const AUTONOMY_FLAG = "hux.autonomy";
|
||||
export const CAPABILITIES: readonly Capability[] = [
|
||||
"read_files", "write_files", "shell", "network", "web_search", "send_message",
|
||||
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy",
|
||||
"external_side_effect",
|
||||
];
|
||||
export const AUTONOMY_LEVELS: readonly AutonomyLevel[] = ["ask_first", "safe", "autonomous"];
|
||||
export const GRANT_DECISIONS: readonly GrantDecision[] = ["allow", "ask", "deny"];
|
||||
export const BUDGET_MAX = Object.freeze({
|
||||
tokensPerRun: 2_000_000, toolCallsPerRun: 500, wallClockSeconds: 86_400,
|
||||
delegationsPerRun: 32, spendCentsPerRun: 100_000,
|
||||
});
|
||||
|
||||
const EXTERNAL = new Set<Capability>([
|
||||
"write_files", "shell", "network", "send_message", "memory_write",
|
||||
"artifact_write", "spend_tokens", "delegate", "deploy",
|
||||
"external_side_effect",
|
||||
]);
|
||||
|
||||
export function autonomyControlsEnabled(flags?: Iterable<string>): boolean {
|
||||
if (!flags) return false;
|
||||
const values = new Set(flags);
|
||||
return [FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]
|
||||
.every((flag) => values.has(flag));
|
||||
}
|
||||
|
||||
function normalizeScope(raw: unknown): PolicyScope | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (!exactKeys(value, value.level === "global" ? ["level"] : ["level", "scope_id"])) return null;
|
||||
if (!["global", "project", "conversation"].includes(String(value.level))) return null;
|
||||
if (value.level !== "global" && !isOpaqueId(value.scope_id)) return null;
|
||||
return {level: value.level as PolicyScope["level"],
|
||||
...(value.level === "global" ? {} : {scopeId: value.scope_id as string})};
|
||||
}
|
||||
|
||||
function normalizePolicy(raw: unknown, expected: HuxIdentity): PolicyView | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const required = ["schema", "id", "owner", "scope", "autonomy", "grants", "budgets", "provenance", "updated_at"];
|
||||
if (!exactKeys(value, required) || value.schema !== "hux.policy.v1" ||
|
||||
!isOpaqueId(value.id, "pol") || value.owner !== expected.userRef ||
|
||||
!AUTONOMY_LEVELS.includes(value.autonomy as AutonomyLevel) || !isUtc(value.updated_at)) return null;
|
||||
const scope = normalizeScope(value.scope);
|
||||
if (!scope || !Array.isArray(value.grants) || value.grants.length > 64 ||
|
||||
!value.budgets || typeof value.budgets !== "object" || Array.isArray(value.budgets)) return null;
|
||||
const grants = Object.fromEntries(CAPABILITIES.map((capability) => [capability, "deny"])) as Record<Capability, GrantDecision>;
|
||||
const seen = new Set<Capability>();
|
||||
for (const rawGrant of value.grants) {
|
||||
if (!rawGrant || typeof rawGrant !== "object" || Array.isArray(rawGrant)) return null;
|
||||
const grant = rawGrant as Record<string, unknown>;
|
||||
if (!CAPABILITIES.includes(grant.capability as Capability) ||
|
||||
!GRANT_DECISIONS.includes(grant.decision as GrantDecision) || seen.has(grant.capability as Capability)) return null;
|
||||
seen.add(grant.capability as Capability);
|
||||
grants[grant.capability as Capability] = grant.decision as GrantDecision;
|
||||
}
|
||||
const budget = value.budgets as Record<string, unknown>;
|
||||
const budgetKeys = ["tokens_per_run", "tool_calls_per_run", "wall_clock_seconds", "delegations_per_run"];
|
||||
if (Object.keys(budget).some((key) => !budgetKeys.includes(key))) return null;
|
||||
const normalized = {
|
||||
tokensPerRun: boundedInteger(budget.tokens_per_run ?? 0, BUDGET_MAX.tokensPerRun),
|
||||
toolCallsPerRun: boundedInteger(budget.tool_calls_per_run ?? 0, BUDGET_MAX.toolCallsPerRun),
|
||||
wallClockSeconds: boundedInteger(budget.wall_clock_seconds ?? 0, BUDGET_MAX.wallClockSeconds),
|
||||
delegationsPerRun: boundedInteger(budget.delegations_per_run ?? 0, BUDGET_MAX.delegationsPerRun),
|
||||
};
|
||||
if (Object.values(normalized).some((item) => item === null)) return null;
|
||||
return {id: value.id, owner: value.owner as string, scope,
|
||||
autonomy: value.autonomy as AutonomyLevel, grants: Object.freeze({...grants}),
|
||||
budgets: normalized as PolicyView["budgets"], updatedAt: value.updated_at};
|
||||
}
|
||||
|
||||
function normalizeApproval(raw: unknown, expected: HuxIdentity, conversationId: string,
|
||||
now: number): ApprovalView | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const keys = ["schema", "id", "run_id", "conversation_id", "capability", "request",
|
||||
"status", "requested_at", "expires_at"];
|
||||
if (!exactKeys(value, keys) || value.schema !== "hux.approval.v1" ||
|
||||
!isOpaqueId(value.id, "apr") || typeof value.run_id !== "string" || !value.run_id ||
|
||||
value.run_id.length > 120 || value.conversation_id !== conversationId ||
|
||||
!CAPABILITIES.includes(value.capability as Capability) || value.status !== "pending" ||
|
||||
!isUtc(value.requested_at) || !isUtc(value.expires_at) ||
|
||||
Date.parse(value.requested_at) > Date.parse(value.expires_at) || Date.parse(value.expires_at) <= now) return null;
|
||||
if (!value.request || typeof value.request !== "object" || Array.isArray(value.request)) return null;
|
||||
const request = value.request as Record<string, unknown>;
|
||||
if (Object.keys(request).some((key) => !["summary", "detail", "risk", "evidence"].includes(key)) ||
|
||||
!["low", "medium", "high"].includes(String(request.risk))) return null;
|
||||
return {id: value.id, identityKey: identityKey(expected), runId: value.run_id,
|
||||
conversationId, capability: value.capability as Capability,
|
||||
summary: safeText(request.summary, "Hermes requests permission"),
|
||||
risk: request.risk as ApprovalView["risk"], requestedAt: value.requested_at,
|
||||
expiresAt: value.expires_at, status: "pending"};
|
||||
}
|
||||
|
||||
export function normalizeAutonomyPage(raw: RawAutonomyEnvelope, expected: HuxIdentity,
|
||||
conversationId: string, now = Date.now()): AutonomyPage | null {
|
||||
if (!raw || typeof raw !== "object" || raw.schema !== "hux.autonomy_page.v1" ||
|
||||
raw.api_version !== "hux.v1" || raw.conversation_id !== conversationId || !isOpaqueId(conversationId, "conv")) return null;
|
||||
const actual = normalizeIdentity(raw.identity);
|
||||
if (!actual || !sameIdentity(actual, expected)) return null;
|
||||
const policy = normalizePolicy(raw.policy, expected);
|
||||
if (!policy || !Array.isArray(raw.approvals) || raw.approvals.length > 100) return null;
|
||||
const approvals = raw.approvals.map((item) => normalizeApproval(item, expected, conversationId, now));
|
||||
return {policy, approvals: approvals.filter((item): item is ApprovalView => item !== null),
|
||||
rejectedApprovals: approvals.filter((item) => item === null).length};
|
||||
}
|
||||
|
||||
function boundedDraft(draft: BudgetDraft): boolean {
|
||||
return boundedInteger(draft.tokensPerRun, BUDGET_MAX.tokensPerRun) !== null &&
|
||||
boundedInteger(draft.toolCallsPerRun, BUDGET_MAX.toolCallsPerRun) !== null &&
|
||||
boundedInteger(draft.wallClockSeconds, BUDGET_MAX.wallClockSeconds) !== null &&
|
||||
boundedInteger(draft.delegationsPerRun, BUDGET_MAX.delegationsPerRun) !== null &&
|
||||
boundedInteger(draft.spendCentsPerRun, BUDGET_MAX.spendCentsPerRun) !== null &&
|
||||
["current_task", "conversation", "project"].includes(draft.scopeLimit);
|
||||
}
|
||||
|
||||
export function buildPolicyUpdate(policy: PolicyView, identity: HuxIdentity,
|
||||
autonomy: AutonomyLevel, grants: Readonly<Record<Capability, GrantDecision>>,
|
||||
draft: BudgetDraft): PolicyUpdateIntent {
|
||||
if (policy.owner !== identity.userRef || !AUTONOMY_LEVELS.includes(autonomy) || !boundedDraft(draft) ||
|
||||
CAPABILITIES.some((capability) => !GRANT_DECISIONS.includes(grants[capability]))) {
|
||||
throw new TypeError("Autonomy policy update is outside its bounds");
|
||||
}
|
||||
return {expectedPolicyId: policy.id, expectedUpdatedAt: policy.updatedAt,
|
||||
identity: {...identity}, scope: {...policy.scope}, autonomy, grants: Object.freeze({...grants}),
|
||||
budgets: {tokensPerRun: draft.tokensPerRun, toolCallsPerRun: draft.toolCallsPerRun,
|
||||
wallClockSeconds: draft.wallClockSeconds, delegationsPerRun: draft.delegationsPerRun},
|
||||
guardrails: {spendCentsPerRun: draft.spendCentsPerRun, scopeLimit: draft.scopeLimit}};
|
||||
}
|
||||
|
||||
export function buildApprovalDecision(approval: ApprovalView, choice: ApprovalChoice,
|
||||
identity: HuxIdentity, expectedRequestedAt: string, now = Date.now()): ApprovalDecisionIntent {
|
||||
if (approval.identityKey !== identityKey(identity) || approval.requestedAt !== expectedRequestedAt ||
|
||||
approval.status !== "pending" || Date.parse(approval.expiresAt) <= now ||
|
||||
!["once", "session", "always", "deny"].includes(choice)) {
|
||||
throw new TypeError("Stale or mismatched approval denied");
|
||||
}
|
||||
return {approvalId: approval.id, expectedRunId: approval.runId,
|
||||
expectedConversationId: approval.conversationId, expectedCapability: approval.capability,
|
||||
expectedRequestedAt, choice, identity: {...identity}};
|
||||
}
|
||||
|
||||
export function actionAuthorization(policy: PolicyView | null, capability: Capability,
|
||||
_approval: ApprovalView | null, identity: HuxIdentity, _now = Date.now()): "allow" | "approval_required" | "deny" {
|
||||
if (!policy || policy.owner !== identity.userRef || !CAPABILITIES.includes(capability)) return "deny";
|
||||
const decision = policy.grants[capability];
|
||||
if (decision === "deny") return "deny";
|
||||
const mustAsk = decision === "ask" || policy.autonomy === "ask_first" ||
|
||||
(policy.autonomy === "safe" && EXTERNAL.has(capability));
|
||||
if (!mustAsk) return "allow";
|
||||
// A pending UI record is never execution authority. The server may perform
|
||||
// the action only after its decision endpoint atomically validates and
|
||||
// consumes the exact approval proof built by buildApprovalDecision().
|
||||
return "approval_required";
|
||||
}
|
||||
|
||||
export function buildStopIntent(run: ActiveRun, identity: HuxIdentity,
|
||||
expectedRunId: string): StopIntent {
|
||||
if (run.runId !== expectedRunId || !run.runId || run.runId.length > 120 ||
|
||||
run.owner !== identity.userRef || run.tenantRef !== identity.tenantRef ||
|
||||
!isOpaqueId(run.conversationId, "conv")) throw new TypeError("Run ownership could not be verified");
|
||||
return {runId: run.runId, conversationId: run.conversationId, identity: {...identity}};
|
||||
}
|
||||
|
||||
export function normalizeStopReceipt(raw: Record<string, unknown>, run: ActiveRun,
|
||||
identity: HuxIdentity): StopReceiptView | null {
|
||||
const requestedBy = raw?.requested_by as Record<string, unknown> | null;
|
||||
if (!requestedBy || requestedBy.type !== "user" || requestedBy.id !== identity.userRef ||
|
||||
run.owner !== identity.userRef || run.tenantRef !== identity.tenantRef || raw.run_id !== run.runId) return null;
|
||||
const receipt = normalizeCancellationReceipt(raw, identity.surface);
|
||||
if (!receipt || receipt.runId !== run.runId) return null;
|
||||
return {id: receipt.id, runId: receipt.runId, outcome: receipt.outcome,
|
||||
stopped: receipt.sideEffects.filter((item) => item.reverted).length,
|
||||
remaining: receipt.sideEffects.filter((item) => !item.reverted).length,
|
||||
effects: receipt.sideEffects, omittedEffects: receipt.omittedEffects};
|
||||
}
|
||||
59
dockerfiles/hermes-webui-hux/autonomy/security.ts
Normal file
59
dockerfiles/hermes-webui-hux/autonomy/security.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/** Small, fail-closed validation helpers for HUX-05. */
|
||||
|
||||
import type { HuxIdentity } from "./types.ts";
|
||||
|
||||
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
|
||||
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
|
||||
const USER = /^usr_[0-9a-f]{16,64}$/;
|
||||
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
|
||||
const CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
||||
const SECRET = /\b(password|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
|
||||
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
|
||||
|
||||
export function isOpaqueId(value: unknown, prefix?: string): value is string {
|
||||
return typeof value === "string" && ID.test(value) &&
|
||||
(!prefix || value.startsWith(`${prefix}_`));
|
||||
}
|
||||
|
||||
export function isUtc(value: unknown): value is string {
|
||||
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
export function safeText(value: unknown, fallback: string, limit = 280): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const clean = value.replace(SECRET, "$1=[redacted]").replace(BEARER, "Bearer [redacted]")
|
||||
.replace(CONTROLS, " ").replace(/\s+/g, " ").trim();
|
||||
if (!clean) return fallback;
|
||||
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function normalizeIdentity(raw: unknown): HuxIdentity | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const tenantRef = value.tenant_ref ?? value.tenantRef;
|
||||
const userRef = value.user_ref ?? value.userRef;
|
||||
const surfaces = new Set(["chat", "worker", "telegram", "voice", "api"]);
|
||||
if (typeof tenantRef !== "string" || !TENANT.test(tenantRef) ||
|
||||
typeof userRef !== "string" || !USER.test(userRef) ||
|
||||
typeof value.surface !== "string" || !surfaces.has(value.surface)) return null;
|
||||
return {tenantRef, userRef, surface: value.surface as HuxIdentity["surface"]};
|
||||
}
|
||||
|
||||
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
|
||||
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
|
||||
left.surface === right.surface;
|
||||
}
|
||||
|
||||
export function identityKey(value: HuxIdentity): string {
|
||||
return `${value.tenantRef}:${value.userRef}:${value.surface}`;
|
||||
}
|
||||
|
||||
export function exactKeys(value: object, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
return actual.length === keys.length && actual.every((key, index) => key === [...keys].sort()[index]);
|
||||
}
|
||||
|
||||
export function boundedInteger(value: unknown, maximum: number): number | null {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0 &&
|
||||
(value as number) <= maximum ? value as number : null;
|
||||
}
|
||||
165
dockerfiles/hermes-webui-hux/autonomy/styles.css
Normal file
165
dockerfiles/hermes-webui-hux/autonomy/styles.css
Normal file
@ -0,0 +1,165 @@
|
||||
.hux-autonomy {
|
||||
color: var(--hermes-text, #e8edf7);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hux-autonomy > header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-autonomy h2,
|
||||
.hux-autonomy h3 {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-autonomy form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hux-autonomy fieldset,
|
||||
.hux-autonomy-approvals,
|
||||
.hux-autonomy-receipt {
|
||||
border: 1px solid color-mix(in srgb, var(--hermes-accent, #32d6d2) 30%, transparent);
|
||||
border-radius: 0.8rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hux-autonomy-levels {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
.hux-autonomy-levels label {
|
||||
align-items: start;
|
||||
background: color-mix(in srgb, var(--hermes-panel, #121a27) 90%, transparent);
|
||||
border-radius: 0.6rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.hux-autonomy-levels small,
|
||||
.hux-autonomy-approvals small {
|
||||
display: block;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.hux-autonomy-matrix table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-autonomy-matrix th,
|
||||
.hux-autonomy-matrix td {
|
||||
border-block-start: 1px solid rgb(255 255 255 / 8%);
|
||||
padding: 0.5rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.hux-autonomy-budgets > div {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
}
|
||||
|
||||
.hux-autonomy-budgets label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.hux-autonomy input,
|
||||
.hux-autonomy select,
|
||||
.hux-autonomy button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.hux-autonomy input,
|
||||
.hux-autonomy select {
|
||||
background: var(--hermes-input, #0d1420);
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 0.4rem;
|
||||
color: inherit;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.hux-autonomy button {
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.55rem 0.8rem;
|
||||
}
|
||||
|
||||
.hux-autonomy-stop {
|
||||
align-items: center;
|
||||
background: #9e2638;
|
||||
border: 2px solid #ff6c7d;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.hux-autonomy-save {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.hux-autonomy-approvals ol {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hux-autonomy-approvals li {
|
||||
background: rgb(255 255 255 / 4%);
|
||||
border-inline-start: 3px solid var(--hermes-accent, #32d6d2);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.hux-autonomy-approvals li[data-risk="high"] {
|
||||
border-inline-start-color: #ff6c7d;
|
||||
}
|
||||
|
||||
.hux-autonomy-approvals li > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-block-start: 0.65rem;
|
||||
}
|
||||
|
||||
.hux-autonomy-warning {
|
||||
color: #ffd479;
|
||||
}
|
||||
|
||||
.hux-autonomy :focus-visible {
|
||||
outline: 3px solid var(--hermes-focus, #71f7ef);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hux-autonomy button:disabled,
|
||||
.hux-autonomy input:disabled,
|
||||
.hux-autonomy select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.hux-autonomy > header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-autonomy * {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
140
dockerfiles/hermes-webui-hux/autonomy/types.ts
Normal file
140
dockerfiles/hermes-webui-hux/autonomy/types.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/** Frontend-only contracts for default-off HUX-05 autonomy controls. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
export type AutonomyLevel = "ask_first" | "safe" | "autonomous";
|
||||
export type GrantDecision = "allow" | "ask" | "deny";
|
||||
export type ApprovalChoice = "once" | "session" | "always" | "deny";
|
||||
export type Risk = "low" | "medium" | "high";
|
||||
export type ScopeLevel = "global" | "project" | "conversation";
|
||||
export type ScopeLimit = "current_task" | "conversation" | "project";
|
||||
export type Capability =
|
||||
| "read_files" | "write_files" | "shell" | "network" | "web_search"
|
||||
| "send_message" | "memory_write" | "artifact_write" | "spend_tokens"
|
||||
| "delegate" | "deploy" | "external_side_effect";
|
||||
|
||||
export interface HuxIdentity {
|
||||
tenantRef: string;
|
||||
userRef: string;
|
||||
surface: HuxSurface;
|
||||
}
|
||||
|
||||
export interface PolicyScope {
|
||||
level: ScopeLevel;
|
||||
scopeId?: string;
|
||||
}
|
||||
|
||||
export interface PolicyBudgets {
|
||||
tokensPerRun: number;
|
||||
toolCallsPerRun: number;
|
||||
wallClockSeconds: number;
|
||||
delegationsPerRun: number;
|
||||
}
|
||||
|
||||
export interface PolicyView {
|
||||
id: string;
|
||||
owner: string;
|
||||
scope: PolicyScope;
|
||||
autonomy: AutonomyLevel;
|
||||
grants: Readonly<Record<Capability, GrantDecision>>;
|
||||
budgets: PolicyBudgets;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BudgetDraft extends PolicyBudgets {
|
||||
/** Request guardrails; the backend must enforce these atomically. */
|
||||
spendCentsPerRun: number;
|
||||
scopeLimit: ScopeLimit;
|
||||
}
|
||||
|
||||
export interface ApprovalView {
|
||||
id: string;
|
||||
identityKey: string;
|
||||
runId: string;
|
||||
conversationId: string;
|
||||
capability: Capability;
|
||||
summary: string;
|
||||
risk: Risk;
|
||||
requestedAt: string;
|
||||
expiresAt: string;
|
||||
status: "pending";
|
||||
}
|
||||
|
||||
export interface RawAutonomyEnvelope {
|
||||
schema?: unknown;
|
||||
api_version?: unknown;
|
||||
identity?: unknown;
|
||||
conversation_id?: unknown;
|
||||
policy?: unknown;
|
||||
approvals?: unknown;
|
||||
}
|
||||
|
||||
export interface AutonomyPage {
|
||||
policy: PolicyView;
|
||||
approvals: ApprovalView[];
|
||||
rejectedApprovals: number;
|
||||
}
|
||||
|
||||
export interface PolicyUpdateIntent {
|
||||
expectedPolicyId: string;
|
||||
expectedUpdatedAt: string;
|
||||
identity: HuxIdentity;
|
||||
scope: PolicyScope;
|
||||
autonomy: AutonomyLevel;
|
||||
grants: Readonly<Record<Capability, GrantDecision>>;
|
||||
budgets: PolicyBudgets;
|
||||
guardrails: {spendCentsPerRun: number; scopeLimit: ScopeLimit};
|
||||
}
|
||||
|
||||
export interface ApprovalDecisionIntent {
|
||||
approvalId: string;
|
||||
expectedRunId: string;
|
||||
expectedConversationId: string;
|
||||
expectedCapability: Capability;
|
||||
expectedRequestedAt: string;
|
||||
choice: ApprovalChoice;
|
||||
identity: HuxIdentity;
|
||||
}
|
||||
|
||||
export interface ActiveRun {
|
||||
runId: string;
|
||||
conversationId: string;
|
||||
tenantRef: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export interface StopIntent {
|
||||
runId: string;
|
||||
conversationId: string;
|
||||
identity: HuxIdentity;
|
||||
}
|
||||
|
||||
export interface StopReceiptView {
|
||||
id: string;
|
||||
runId: string;
|
||||
outcome: "cancelled" | "already_complete" | "failed_to_cancel";
|
||||
stopped: number;
|
||||
remaining: number;
|
||||
effects: {description: string; reverted: boolean}[];
|
||||
omittedEffects: number;
|
||||
}
|
||||
|
||||
export interface AutonomyEndpoint {
|
||||
method: "GET" | "PUT" | "POST";
|
||||
path: string;
|
||||
responseSchema: string;
|
||||
requiredBody?: readonly string[];
|
||||
}
|
||||
|
||||
export interface AutonomyEndpointContract {
|
||||
identity: HuxIdentity;
|
||||
policy: AutonomyEndpoint;
|
||||
updatePolicy: AutonomyEndpoint;
|
||||
approvals: AutonomyEndpoint;
|
||||
decideApproval(approvalId: string): AutonomyEndpoint;
|
||||
stop(runId: string): AutonomyEndpoint;
|
||||
release: {
|
||||
schema: "hux.release.v1";
|
||||
requiredState: "live_verified";
|
||||
featureFlags: readonly string[];
|
||||
};
|
||||
}
|
||||
96
dockerfiles/hermes-webui-hux/bootstrap.css
vendored
Normal file
96
dockerfiles/hermes-webui-hux/bootstrap.css
vendored
Normal file
@ -0,0 +1,96 @@
|
||||
.hux-workspace-toggle {
|
||||
background: color-mix(in srgb, var(--bg-primary, #0a101b) 88%, #174653);
|
||||
border: 1px solid rgb(94 231 215 / 45%);
|
||||
border-radius: 999px;
|
||||
bottom: max(5.8rem, calc(env(safe-area-inset-bottom) + 4.5rem));
|
||||
box-shadow: 0 10px 32px rgb(0 0 0 / 32%);
|
||||
color: var(--text-primary, #e7eef6);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0.55rem 0.9rem;
|
||||
position: fixed;
|
||||
right: max(1rem, env(safe-area-inset-right));
|
||||
z-index: 115;
|
||||
}
|
||||
|
||||
.hux-workspace-drawer {
|
||||
background: color-mix(in srgb, var(--bg-primary, #0a101b) 97%, #102f3a);
|
||||
border-left: 1px solid rgb(118 181 193 / 30%);
|
||||
bottom: 0;
|
||||
box-shadow: -18px 0 48px rgb(0 0 0 / 34%);
|
||||
color: var(--text-primary, #e7eef6);
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: min(42rem, 94vw);
|
||||
z-index: 160;
|
||||
}
|
||||
|
||||
.hux-workspace-drawer[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hux-workspace-drawer__header {
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgb(10 16 27 / 88%);
|
||||
border-bottom: 1px solid rgb(118 181 193 / 24%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.8rem 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hux-workspace-drawer__header h2 {
|
||||
font-size: 0.92rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-workspace-drawer__header button {
|
||||
background: transparent;
|
||||
border: 1px solid rgb(118 181 193 / 30%);
|
||||
border-radius: 999px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.hux-workspace-drawer__content {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.hux-workspace-wave {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hux-workspace-toggle:focus-visible,
|
||||
.hux-workspace-drawer button:focus-visible {
|
||||
outline: 2px solid #5ee7d7;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hux-workspace-drawer {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hux-workspace-toggle {
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.hux-workspace-drawer,
|
||||
.hux-workspace-toggle {
|
||||
border-color: CanvasText;
|
||||
}
|
||||
}
|
||||
423
dockerfiles/hermes-webui-hux/bootstrap.js
vendored
Normal file
423
dockerfiles/hermes-webui-hux/bootstrap.js
vendored
Normal file
@ -0,0 +1,423 @@
|
||||
/* node:coverage disable */
|
||||
(function (root, factory) {
|
||||
'use strict';
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else {
|
||||
root.HermesHuxBootstrap = api;
|
||||
api.autoStart(root);
|
||||
}
|
||||
}(typeof globalThis === 'object' ? globalThis : this, function () {
|
||||
/* node:coverage enable */
|
||||
'use strict';
|
||||
|
||||
const ACCEPT = 'application/vnd.hermes.hux+json; version=1';
|
||||
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
|
||||
const SLOT = /^slot-[0-9]{1,3}$/;
|
||||
const USER = /^usr_[0-9a-f]{16,64}$/;
|
||||
const WEBUI_SESSION = /^[A-Za-z0-9._:-]{4,160}$/;
|
||||
const PROJECT_SOURCE = /^[A-Za-z0-9._:@+-]{1,200}$/;
|
||||
const STREAM = /^[A-Za-z0-9._:-]{1,120}$/;
|
||||
const TRUST = new Set(['router', 'relay', 'worker']);
|
||||
const SURFACE = new Set(['chat', 'worker', 'telegram', 'voice', 'api']);
|
||||
const CARD_FLAGS = Object.freeze({
|
||||
'HUX-11': 'hux.foundation', 'HUX-01': 'hux.activity_timeline',
|
||||
'HUX-02': 'hux.memory_control', 'HUX-03': 'hux.projects',
|
||||
'HUX-04': 'hux.artifacts', 'HUX-05': 'hux.autonomy',
|
||||
'HUX-06': 'hux.friendly_modes', 'HUX-07': 'hux.multimodal',
|
||||
'HUX-08': 'hux.research', 'HUX-09': 'hux.onboarding',
|
||||
'HUX-10': 'hux.privacy', 'HUX-12': 'hux.release_followthrough',
|
||||
});
|
||||
const REQUIRED_ROUTES = Object.freeze({
|
||||
'HUX-11': [['HUX-11', '/hux/v1/capabilities'],
|
||||
['HUX-11', '/hux/v1/context/bootstrap']],
|
||||
'HUX-01': [['HUX-01', '/hux/v1/conversations/{id}/events']],
|
||||
'HUX-02': [['HUX-02', '/hux/v1/memory'], ['HUX-02', '/hux/v1/memory/{id}/{action}']],
|
||||
'HUX-03': [['HUX-03', '/hux/v1/projects/{id}'], ['HUX-03', '/hux/v1/conversations'],
|
||||
['HUX-03', '/hux/v1/conversations/{id}'], ['HUX-03', '/hux/v1/conversations/{id}/lineage']],
|
||||
'HUX-04': [['HUX-04', '/hux/v1/artifacts'], ['HUX-04', '/hux/v1/artifacts/{id}/versions'],
|
||||
['HUX-04', '/hux/v1/artifacts/{id}/promote']],
|
||||
'HUX-05': [['HUX-05', '/hux/v1/policy'], ['HUX-05', '/hux/v1/approvals'],
|
||||
['HUX-05', '/hux/v1/approvals/{id}'], ['HUX-05', '/hux/v1/runs/{id}/stop'],
|
||||
['HUX-05', '/hux/v1/runs/{id}/budget'], ['HUX-05', '/hux/v1/runs/{id}/gate']],
|
||||
'HUX-06': [['HUX-06', '/hux/v1/modes'],
|
||||
['HUX-06', '/hux/v1/projects/{project_id}/conversations/{id}/mode']],
|
||||
'HUX-07': [['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items'],
|
||||
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}'],
|
||||
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections'],
|
||||
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/capture-intents']],
|
||||
'HUX-08': [['HUX-08', '/hux/v1/messages/{id}/citations'], ['HUX-08', '/hux/v1/notebooks'],
|
||||
['HUX-08', '/hux/v1/notebooks/{id}']],
|
||||
'HUX-09': [['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate'],
|
||||
['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions'],
|
||||
['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states']],
|
||||
'HUX-10': [['HUX-10', '/hux/v1/privacy/policy'], ['HUX-10', '/hux/v1/privacy/notices'],
|
||||
['HUX-10', '/hux/v1/conversations/{id}/forget'], ['HUX-10', '/hux/v1/privacy/audit']],
|
||||
'HUX-12': [['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases'],
|
||||
['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}'],
|
||||
['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions']],
|
||||
});
|
||||
const WAVES = Object.freeze({
|
||||
a: ['HUX-01', 'HUX-02'], b: ['HUX-03', 'HUX-04', 'HUX-06', 'HUX-08'],
|
||||
governance: ['HUX-05', 'HUX-10'], c: ['HUX-07', 'HUX-09', 'HUX-12'],
|
||||
});
|
||||
|
||||
function record(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function immutable(value) {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
||||
Object.values(value).forEach(immutable);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function opaque(value, prefix) {
|
||||
return typeof value === 'string' && ID.test(value) && value.startsWith(`${prefix}_`);
|
||||
}
|
||||
|
||||
function normalizeIdentity(value) {
|
||||
if (!exact(value, ['tenant_slot', 'subject', 'surface', 'trust']) ||
|
||||
!SLOT.test(value.tenant_slot) || !USER.test(value.subject) ||
|
||||
!SURFACE.has(value.surface) || !TRUST.has(value.trust)) return null;
|
||||
return immutable({tenantSlot: value.tenant_slot, userRef: value.subject,
|
||||
surface: value.surface, trust: value.trust});
|
||||
}
|
||||
|
||||
function normalizeContext(session) {
|
||||
const value = record(session);
|
||||
const context = value && record(value.hux_context);
|
||||
const contextKeys = new Set(['schema', 'webui_session_id', 'session_id', 'conversation_id',
|
||||
'project_id', 'project_source', 'identity', 'run_id', 'message_id',
|
||||
'branch_point_message_id', 'notebook_id']);
|
||||
if (!context || context.schema !== 'hux.webui_context.v1' ||
|
||||
Object.keys(context).some((key) => !contextKeys.has(key)) ||
|
||||
!WEBUI_SESSION.test(String(value.session_id || '')) ||
|
||||
context.webui_session_id !== value.session_id || !opaque(context.session_id, 'ses') ||
|
||||
!opaque(context.conversation_id, 'conv') || !opaque(context.project_id, 'prj') ||
|
||||
!PROJECT_SOURCE.test(String(context.project_source || ''))) return null;
|
||||
const identity = normalizeIdentity(context.identity);
|
||||
if (!identity) return null;
|
||||
const optional = {runId: context.run_id, messageId: context.message_id,
|
||||
branchPointMessageId: context.branch_point_message_id, notebookId: context.notebook_id};
|
||||
if (optional.runId !== undefined && !opaque(optional.runId, 'run')) return null;
|
||||
for (const name of ['messageId', 'branchPointMessageId']) {
|
||||
if (optional[name] !== undefined && !opaque(optional[name], 'msg')) return null;
|
||||
}
|
||||
if (optional.notebookId !== undefined && !opaque(optional.notebookId, 'nb')) return null;
|
||||
return immutable({webuiSessionId: value.session_id, sessionId: context.session_id,
|
||||
conversationId: context.conversation_id, projectId: context.project_id,
|
||||
projectSource: context.project_source, identity, ...optional});
|
||||
}
|
||||
|
||||
function sameIdentity(raw, expected) {
|
||||
return Boolean(exact(raw, ['tenant_slot', 'subject', 'surface', 'trust']) &&
|
||||
raw.tenant_slot === expected.tenantSlot &&
|
||||
raw.subject === expected.userRef && raw.surface === expected.surface && raw.trust === expected.trust);
|
||||
}
|
||||
|
||||
function exact(value, keys) {
|
||||
return Boolean(record(value) && Object.keys(value).length === keys.length &&
|
||||
keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)));
|
||||
}
|
||||
|
||||
function normalizeBootstrap(payload, context, status) {
|
||||
const keys = ['schema', 'contract_version', 'identity', 'session_id', 'project_id',
|
||||
'conversation_id', 'revisions', 'created'];
|
||||
if (!exact(payload, keys) || payload.schema !== 'hux.context_bootstrap.v1' ||
|
||||
payload.contract_version !== '1.1.0' || !sameIdentity(payload.identity, context.identity) ||
|
||||
payload.session_id !== context.sessionId || payload.project_id !== context.projectId ||
|
||||
payload.conversation_id !== context.conversationId ||
|
||||
!exact(payload.revisions, ['project', 'conversation']) ||
|
||||
!exact(payload.created, ['project', 'conversation'])) return null;
|
||||
const revisions = payload.revisions; const created = payload.created;
|
||||
if (!Number.isSafeInteger(revisions.project) || revisions.project < 1 ||
|
||||
!Number.isSafeInteger(revisions.conversation) || revisions.conversation < 1 ||
|
||||
typeof created.project !== 'boolean' || typeof created.conversation !== 'boolean' ||
|
||||
(status === 201) !== (created.project || created.conversation)) return null;
|
||||
return immutable({revisions: {...revisions}, created: {...created}});
|
||||
}
|
||||
|
||||
function normalizeCapabilities(payload, expected) {
|
||||
if (!exact(payload, ['schema', 'contract_version', 'identity', 'cards', 'server']) ||
|
||||
payload.schema !== 'hux.capabilities.v1' ||
|
||||
!/^1\.\d+\.\d+$/.test(String(payload.contract_version)) ||
|
||||
!sameIdentity(payload.identity, expected) || !Array.isArray(payload.cards) ||
|
||||
payload.cards.length > Object.keys(CARD_FLAGS).length || !record(payload.server) ||
|
||||
Object.keys(payload.server).some((key) => !['commit', 'image_digest'].includes(key)) ||
|
||||
Object.values(payload.server).some((item) => typeof item !== 'string' || item.length > 160)) return null;
|
||||
const cards = {};
|
||||
for (const item of payload.cards) {
|
||||
if (!exact(item, ['card', 'flag', 'enabled', 'routes']) ||
|
||||
!Object.prototype.hasOwnProperty.call(CARD_FLAGS, item.card) ||
|
||||
CARD_FLAGS[item.card] !== item.flag || typeof item.enabled !== 'boolean' ||
|
||||
!Array.isArray(item.routes) || cards[item.card]) return null;
|
||||
const routes = item.routes.filter((route) => typeof route === 'string' &&
|
||||
/^\/hux\/v1(?:\/[A-Za-z0-9._:{}-]+)*$/.test(route));
|
||||
if (routes.length !== item.routes.length || new Set(routes).size !== routes.length) return null;
|
||||
cards[item.card] = immutable({enabled: item.enabled, routes});
|
||||
}
|
||||
if (!cardComplete(cards, 'HUX-11')) return null;
|
||||
return immutable(cards);
|
||||
}
|
||||
|
||||
function cardComplete(cards, card) {
|
||||
const current = cards[card];
|
||||
if (!current || !current.enabled) return false;
|
||||
return REQUIRED_ROUTES[card].every(([owner, route]) =>
|
||||
cards[owner] && cards[owner].enabled && cards[owner].routes.includes(route));
|
||||
}
|
||||
|
||||
function enabledWaves(cards) {
|
||||
const result = [];
|
||||
Object.entries(WAVES).forEach(([wave, members]) => {
|
||||
const enabled = members.filter((card) => cards[card] && cards[card].enabled);
|
||||
if (enabled.length && enabled.every((card) => cardComplete(cards, card))) result.push(wave);
|
||||
});
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
function safeCsrf(config) {
|
||||
const value = record(config) && config.csrfToken;
|
||||
return typeof value === 'string' && value.length >= 16 && value.length <= 512 &&
|
||||
!/[\u0000-\u001f\u007f]/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function scopedFetcher(fetcher, csrfToken) {
|
||||
return function huxFetch(url, init) {
|
||||
const target = String(url || '');
|
||||
if (!/^\/hux\/v1(?:[/?]|$)/.test(target) || target.startsWith('//') || target.includes('..')) {
|
||||
return Promise.reject(new TypeError('HUX browser requests must remain same-origin'));
|
||||
}
|
||||
const options = init || {};
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
const headers = {...(options.headers || {})};
|
||||
if (!['GET', 'HEAD'].includes(method)) headers['X-Hermes-CSRF-Token'] = csrfToken;
|
||||
return fetcher(target, {...options, cache: 'no-store', credentials: 'same-origin', headers});
|
||||
};
|
||||
}
|
||||
|
||||
async function preflight(fetcher, context) {
|
||||
try {
|
||||
const response = await fetcher('/hux/v1/capabilities', {method: 'GET',
|
||||
headers: {Accept: ACCEPT}});
|
||||
if (!response || response.status === 404 || !response.ok) return null;
|
||||
const cards = normalizeCapabilities(await response.json(), context.identity);
|
||||
const waves = cards && enabledWaves(cards);
|
||||
return waves && waves.length ? immutable({cards, waves}) : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapContext(fetcher, context) {
|
||||
try {
|
||||
const response = await fetcher('/hux/v1/context/bootstrap', {method: 'POST', headers: {
|
||||
Accept: ACCEPT, 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': `hux:context:${context.sessionId}`,
|
||||
}, body: JSON.stringify({raw_session_id: context.webuiSessionId,
|
||||
project_source: context.projectSource, session_id: context.sessionId,
|
||||
conversation_id: context.conversationId, project_id: context.projectId})});
|
||||
if (!response || ![200, 201].includes(response.status) || !response.ok) return null;
|
||||
return normalizeBootstrap(await response.json(), context, response.status);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function node(doc, tag, attributes, text) {
|
||||
const value = doc.createElement(tag);
|
||||
Object.entries(attributes).forEach(([name, item]) => value.setAttribute(name, item));
|
||||
if (text !== undefined) value.textContent = String(text);
|
||||
return value;
|
||||
}
|
||||
|
||||
function createChrome(doc) {
|
||||
const open = node(doc, 'button', {'class': 'hux-workspace-toggle', type: 'button',
|
||||
'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false'}, 'Workspace');
|
||||
const drawer = node(doc, 'aside', {id: 'huxWorkspaceDrawer', 'class': 'hux-workspace-drawer',
|
||||
'aria-labelledby': 'huxWorkspaceTitle'});
|
||||
const header = node(doc, 'header', {'class': 'hux-workspace-drawer__header'});
|
||||
const title = node(doc, 'h2', {id: 'huxWorkspaceTitle'}, 'Hermes workspace');
|
||||
const close = node(doc, 'button', {type: 'button', 'aria-label': 'Close Hermes workspace'}, 'Close');
|
||||
const content = node(doc, 'div', {'class': 'hux-workspace-drawer__content'});
|
||||
drawer.hidden = true;
|
||||
header.appendChild(title); header.appendChild(close); drawer.appendChild(header); drawer.appendChild(content);
|
||||
doc.body.appendChild(open); doc.body.appendChild(drawer);
|
||||
function setOpen(value) {
|
||||
drawer.hidden = !value; open.setAttribute('aria-expanded', value ? 'true' : 'false');
|
||||
if (value && typeof close.focus === 'function') close.focus();
|
||||
}
|
||||
const onOpen = () => setOpen(true);
|
||||
const onClose = () => setOpen(false);
|
||||
const onKey = (event) => { if (event.key === 'Escape' && !drawer.hidden) setOpen(false); };
|
||||
open.addEventListener('click', onOpen); close.addEventListener('click', onClose);
|
||||
doc.addEventListener('keydown', onKey);
|
||||
return Object.freeze({content, drawer, open, setOpen, destroy() {
|
||||
doc.removeEventListener('keydown', onKey);
|
||||
if (open.parentNode) open.parentNode.removeChild(open);
|
||||
if (drawer.parentNode) drawer.parentNode.removeChild(drawer);
|
||||
}});
|
||||
}
|
||||
|
||||
async function defaultRuntimeFactory(root, target, context, fetcher, waves, controls) {
|
||||
const runtimes = [];
|
||||
const common = {document: root.document, fetcher, expectedIdentity: context.identity,
|
||||
projectId: context.projectId, conversationId: context.conversationId,
|
||||
sessionId: context.sessionId, runId: context.runId, messageId: context.messageId,
|
||||
branchPointMessageId: context.branchPointMessageId, notebookId: context.notebookId,
|
||||
stopModelResponse: controls && controls.stopModelResponse,
|
||||
canStopModelResponse: controls && controls.canStopModelResponse};
|
||||
const constructors = {
|
||||
a: root.HermesHuxWaveA && root.HermesHuxWaveA.createWaveARuntime,
|
||||
b: root.HermesHuxWaveBRuntime && root.HermesHuxWaveBRuntime.createWaveBRuntime,
|
||||
governance: root.HermesHuxAutonomyPrivacy &&
|
||||
root.HermesHuxAutonomyPrivacy.createAutonomyPrivacyRuntime,
|
||||
c: root.HermesHuxWaveC && root.HermesHuxWaveC.createWaveCRuntime,
|
||||
};
|
||||
try {
|
||||
for (const wave of waves) {
|
||||
if (typeof constructors[wave] !== 'function') throw new Error('HUX runtime is incomplete');
|
||||
const host = node(root.document, 'section', {'class': `hux-workspace-wave hux-workspace-wave--${wave}`});
|
||||
target.appendChild(host);
|
||||
const runtime = constructors[wave](common);
|
||||
runtimes.push(runtime);
|
||||
await runtime.mount(host);
|
||||
}
|
||||
} catch (error) {
|
||||
runtimes.reverse().forEach((runtime) => runtime.destroy());
|
||||
throw error;
|
||||
}
|
||||
return Object.freeze({destroy() { runtimes.reverse().forEach((runtime) => runtime.destroy()); }});
|
||||
}
|
||||
|
||||
function ownedActiveStream(session, activeStreamId, expected) {
|
||||
const current = normalizeContext(session);
|
||||
const raw = record(session);
|
||||
return current && expected && current.webuiSessionId === expected.webuiSessionId &&
|
||||
current.conversationId === expected.conversationId &&
|
||||
current.identity.userRef === expected.identity.userRef && STREAM.test(String(activeStreamId || '')) &&
|
||||
raw.active_stream_id === activeStreamId ? activeStreamId : null;
|
||||
}
|
||||
|
||||
function createOwnedStop(root, sessionReader, activeStreamReader, context, timeoutMs) {
|
||||
const bounded = Number.isSafeInteger(timeoutMs) && timeoutMs >= 250 && timeoutMs <= 10000 ? timeoutMs : 3000;
|
||||
const canStopModelResponse = () => Boolean(ownedActiveStream(sessionReader(), activeStreamReader(), context) &&
|
||||
typeof root.cancelStream === 'function');
|
||||
async function stopModelResponse() {
|
||||
const streamId = ownedActiveStream(sessionReader(), activeStreamReader(), context);
|
||||
if (!streamId || typeof root.cancelStream !== 'function') return Object.freeze({accepted: false});
|
||||
let timer = null;
|
||||
try {
|
||||
const timeout = new Promise((resolve) => { timer = root.setTimeout(() => resolve(false), bounded); });
|
||||
const accepted = await Promise.race([Promise.resolve(root.cancelStream('hux-stop')), timeout]);
|
||||
if (timer !== null) root.clearTimeout(timer);
|
||||
return Object.freeze({accepted: accepted === true, sessionId: context.webuiSessionId, streamId});
|
||||
} catch (_) {
|
||||
if (timer !== null) root.clearTimeout(timer);
|
||||
return Object.freeze({accepted: false});
|
||||
}
|
||||
}
|
||||
return Object.freeze({canStopModelResponse, stopModelResponse});
|
||||
}
|
||||
|
||||
function createCoordinator(options) {
|
||||
const settings = options || {};
|
||||
const root = settings.root;
|
||||
const doc = settings.document || (root && root.document);
|
||||
const rawFetch = settings.fetcher || (root && root.fetch && root.fetch.bind(root));
|
||||
const sessionReader = settings.sessionReader;
|
||||
const activeStreamReader = settings.activeStreamReader || (() => browserActiveStreamReader());
|
||||
const configReader = settings.configReader || (() => root.__HERMES_CONFIG__);
|
||||
const runtimeFactory = settings.runtimeFactory || ((target, context, fetcher, waves, controls) =>
|
||||
defaultRuntimeFactory(root, target, context, fetcher, waves, controls));
|
||||
if (!root || !doc || !rawFetch || typeof sessionReader !== 'function') {
|
||||
throw new TypeError('HUX coordinator requires browser, fetch, and trusted session reader');
|
||||
}
|
||||
let fingerprint = null; let pendingFingerprint = null; let generation = 0;
|
||||
let chrome = null; let runtime = null; let timer = null;
|
||||
|
||||
function clear() {
|
||||
if (runtime) runtime.destroy();
|
||||
if (chrome) chrome.destroy();
|
||||
runtime = null; chrome = null;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const context = normalizeContext(sessionReader());
|
||||
const activeStream = context ? ownedActiveStream(sessionReader(), activeStreamReader(), context) : null;
|
||||
const next = context ? `${context.webuiSessionId}\0${context.conversationId}\0${context.identity.userRef}` +
|
||||
`\0${context.runId || ''}\0${activeStream || ''}` : '';
|
||||
if (next === fingerprint || next === pendingFingerprint) return false;
|
||||
pendingFingerprint = next; const current = ++generation; clear();
|
||||
if (!context) { fingerprint = ''; pendingFingerprint = null; return false; }
|
||||
const csrf = safeCsrf(configReader());
|
||||
if (!csrf) { pendingFingerprint = null; return false; }
|
||||
const fetcher = scopedFetcher(rawFetch, csrf);
|
||||
const capability = await preflight(fetcher, context);
|
||||
if (current !== generation) return false;
|
||||
if (!capability) { pendingFingerprint = null; return false; }
|
||||
const binding = await bootstrapContext(fetcher, context);
|
||||
if (current !== generation) return false;
|
||||
if (!binding) { pendingFingerprint = null; return false; }
|
||||
const nextChrome = createChrome(doc);
|
||||
try {
|
||||
const controls = createOwnedStop(root, sessionReader, activeStreamReader, context, settings.stopTimeoutMs);
|
||||
const nextRuntime = await runtimeFactory(nextChrome.content, context, fetcher, capability.waves, controls);
|
||||
if (current !== generation) { nextRuntime.destroy(); nextChrome.destroy(); return false; }
|
||||
chrome = nextChrome; runtime = nextRuntime; fingerprint = next;
|
||||
pendingFingerprint = null; return true;
|
||||
} catch (_) {
|
||||
nextChrome.destroy(); pendingFingerprint = null; return false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer !== null) return;
|
||||
void refresh();
|
||||
timer = root.setInterval(() => { void refresh(); }, settings.pollMs || 750);
|
||||
root.addEventListener('pageshow', refresh);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
generation += 1; fingerprint = null; pendingFingerprint = null;
|
||||
if (timer !== null) root.clearInterval(timer);
|
||||
timer = null; root.removeEventListener('pageshow', refresh); clear();
|
||||
}
|
||||
|
||||
return Object.freeze({destroy, refresh, start, getState: () => ({active: Boolean(runtime), fingerprint})});
|
||||
}
|
||||
|
||||
function browserSessionReader() {
|
||||
return typeof S === 'object' ? S.session : null;
|
||||
}
|
||||
|
||||
function browserActiveStreamReader() {
|
||||
return typeof S === 'object' ? S.activeStreamId : null;
|
||||
}
|
||||
|
||||
function mergeTrustedContext(session, payload) {
|
||||
const current = record(session); const response = record(payload);
|
||||
if (!current || !response || response.session_id !== current.session_id ||
|
||||
!record(response.hux_context)) return false;
|
||||
const next = normalizeContext({...current, hux_context: response.hux_context});
|
||||
if (!next || next.webuiSessionId !== current.session_id) return false;
|
||||
current.hux_context = response.hux_context;
|
||||
return true;
|
||||
}
|
||||
|
||||
function autoStart(root) {
|
||||
if (!root || !root.document || typeof root.fetch !== 'function') return null;
|
||||
const coordinator = createCoordinator({root, sessionReader: browserSessionReader});
|
||||
if (root.document.readyState === 'loading') {
|
||||
root.document.addEventListener('DOMContentLoaded', () => coordinator.start(), {once: true});
|
||||
} else coordinator.start();
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
return Object.freeze({ACCEPT, autoStart, browserActiveStreamReader, cardComplete, createChrome,
|
||||
createCoordinator, createOwnedStop, ownedActiveStream,
|
||||
bootstrapContext, defaultRuntimeFactory, enabledWaves, normalizeBootstrap,
|
||||
normalizeCapabilities, normalizeContext, normalizeIdentity, mergeTrustedContext,
|
||||
preflight, safeCsrf, scopedFetcher});
|
||||
}));
|
||||
75
dockerfiles/hermes-webui-hux/foundation.css
vendored
Normal file
75
dockerfiles/hermes-webui-hux/foundation.css
vendored
Normal file
@ -0,0 +1,75 @@
|
||||
.hux-shell {
|
||||
--hux-accent: #5ee7d7;
|
||||
--hux-border: rgb(118 181 193 / 24%);
|
||||
background: color-mix(in srgb, var(--bg-primary, #0a101b) 94%, #14323c);
|
||||
border: 1px solid var(--hux-border);
|
||||
border-radius: 14px;
|
||||
color: var(--text-primary, #e7eef6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hux-shell[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hux-shell__title {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.09em;
|
||||
margin: 0;
|
||||
padding: 0.9rem 1rem 0.55rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-shell__tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
padding: 0 0.75rem 0.7rem;
|
||||
}
|
||||
|
||||
.hux-shell__tab,
|
||||
.hux-shell__retry {
|
||||
background: transparent;
|
||||
border: 1px solid var(--hux-border);
|
||||
border-radius: 999px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0.42rem 0.7rem;
|
||||
}
|
||||
|
||||
.hux-shell__tab[aria-selected='true'] {
|
||||
background: rgb(94 231 215 / 12%);
|
||||
border-color: var(--hux-accent);
|
||||
}
|
||||
|
||||
.hux-shell__tab:focus-visible,
|
||||
.hux-shell__retry:focus-visible {
|
||||
outline: 2px solid var(--hux-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hux-shell__body {
|
||||
border-top: 1px solid var(--hux-border);
|
||||
}
|
||||
|
||||
.hux-shell__panel {
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hux-shell__status {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
min-height: 3rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hux-shell__tab,
|
||||
.hux-shell__retry {
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
}
|
||||
271
dockerfiles/hermes-webui-hux/foundation.js
vendored
Normal file
271
dockerfiles/hermes-webui-hux/foundation.js
vendored
Normal file
@ -0,0 +1,271 @@
|
||||
(function (root, factory) {
|
||||
'use strict';
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.HermesHuxFoundation = api;
|
||||
}(typeof globalThis === 'object' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const API_VERSION = 'hux.v1';
|
||||
const CAPABILITY_SCHEMA = 'hux.capabilities.v1';
|
||||
const FLAG_DEPENDENCIES = Object.freeze({
|
||||
'hux.activity_timeline': ['hux.foundation'],
|
||||
'hux.memory_control': ['hux.foundation', 'hux.privacy'],
|
||||
'hux.projects': ['hux.foundation'],
|
||||
'hux.artifacts': ['hux.foundation', 'hux.projects'],
|
||||
'hux.autonomy': ['hux.foundation', 'hux.activity_timeline'],
|
||||
'hux.friendly_modes': ['hux.foundation'],
|
||||
'hux.multimodal': ['hux.foundation', 'hux.artifacts', 'hux.autonomy'],
|
||||
'hux.research': ['hux.foundation', 'hux.friendly_modes'],
|
||||
'hux.onboarding': ['hux.foundation', 'hux.projects', 'hux.friendly_modes'],
|
||||
'hux.privacy': ['hux.foundation'],
|
||||
'hux.foundation': [],
|
||||
'hux.release_followthrough': [],
|
||||
});
|
||||
const FLAGS = Object.freeze(Object.keys(FLAG_DEPENDENCIES));
|
||||
const SURFACES = new Set(['chat', 'worker', 'telegram', 'voice', 'api']);
|
||||
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
|
||||
const TENANT_REF = /^tnt_[0-9a-f]{16,64}$/;
|
||||
const USER_REF = /^usr_[0-9a-f]{16,64}$/;
|
||||
const TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?Z$/;
|
||||
const EVENT_KINDS = new Set([
|
||||
'message.user', 'message.assistant', 'decision.route', 'decision.plan',
|
||||
'tool.call', 'tool.result', 'approval.requested', 'approval.resolved',
|
||||
'memory.proposed', 'memory.committed', 'memory.forgotten', 'artifact.created',
|
||||
'artifact.version', 'artifact.promoted', 'citation.attached', 'mode.changed',
|
||||
'run.started', 'run.cancelled', 'run.completed', 'run.failed', 'privacy.notice',
|
||||
'suggestion.shown', 'suggestion.dismissed', 'release.transition',
|
||||
]);
|
||||
const EVIDENCE_KINDS = new Set([
|
||||
'message', 'tool_call', 'tool_result', 'artifact_version', 'source', 'passage',
|
||||
'memory', 'approval', 'run', 'url', 'file', 'build', 'flux', 'pod',
|
||||
]);
|
||||
const SENSITIVITY = new Set(['public', 'personal', 'sensitive', 'restricted']);
|
||||
|
||||
class HuxContractError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'HuxContractError';
|
||||
}
|
||||
}
|
||||
|
||||
function immutable(value) {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
||||
Object.values(value).forEach(immutable);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function requiredString(value, pattern, field) {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
throw new HuxContractError(`Invalid ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIdentity(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new HuxContractError('Missing scoped identity');
|
||||
}
|
||||
if (!SURFACES.has(value.surface)) throw new HuxContractError('Invalid identity surface');
|
||||
return immutable({
|
||||
tenantRef: requiredString(value.tenant_ref || value.tenantRef, TENANT_REF, 'tenant reference'),
|
||||
userRef: requiredString(value.user_ref || value.userRef, USER_REF, 'user reference'),
|
||||
surface: value.surface,
|
||||
});
|
||||
}
|
||||
|
||||
function sameIdentity(left, right) {
|
||||
return left.tenantRef === right.tenantRef &&
|
||||
left.userRef === right.userRef && left.surface === right.surface;
|
||||
}
|
||||
|
||||
function dependenciesEnabled(flag, declared, seen) {
|
||||
if (!declared.has(flag) || seen.has(flag)) return declared.has(flag);
|
||||
const next = new Set(seen).add(flag);
|
||||
return FLAG_DEPENDENCIES[flag].every((dependency) =>
|
||||
dependenciesEnabled(dependency, declared, next));
|
||||
}
|
||||
|
||||
function normalizeCapabilities(payload, expectedIdentity) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new HuxContractError('Capability response is not an object');
|
||||
}
|
||||
if (payload.schema !== CAPABILITY_SCHEMA || payload.api_version !== API_VERSION) {
|
||||
throw new HuxContractError('Unsupported HUX capability version');
|
||||
}
|
||||
const actualIdentity = normalizeIdentity(payload.identity);
|
||||
if (!sameIdentity(actualIdentity, expectedIdentity)) {
|
||||
throw new HuxContractError('Capability identity does not match this surface');
|
||||
}
|
||||
if (!Array.isArray(payload.flags)) throw new HuxContractError('Capability flags are missing');
|
||||
if (payload.flags.length > FLAGS.length * 2) throw new HuxContractError('Too many capability flags');
|
||||
const declared = new Set(payload.flags.filter((flag) => FLAGS.includes(flag)));
|
||||
const enabled = FLAGS.filter((flag) => dependenciesEnabled(flag, declared, new Set()));
|
||||
return immutable({
|
||||
schema: CAPABILITY_SCHEMA,
|
||||
apiVersion: API_VERSION,
|
||||
identity: actualIdentity,
|
||||
flags: enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function createClient(options) {
|
||||
const settings = options || {};
|
||||
const fetcher = Object.prototype.hasOwnProperty.call(settings, 'fetcher') ?
|
||||
settings.fetcher : (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
|
||||
if (!fetcher) throw new TypeError('A fetch implementation is required');
|
||||
const expectedIdentity = normalizeIdentity(settings.expectedIdentity);
|
||||
const baseUrl = String(settings.baseUrl || '/hux/v1').replace(/\/$/, '');
|
||||
if (!baseUrl.startsWith('/') || baseUrl.startsWith('//') || /[?#\\]/.test(baseUrl) || baseUrl.includes('..')) {
|
||||
throw new TypeError('HUX baseUrl must be same-origin');
|
||||
}
|
||||
let state = immutable({phase: 'disabled', capabilities: null, error: null});
|
||||
let generation = 0;
|
||||
const listeners = new Set();
|
||||
|
||||
function publish(next) {
|
||||
state = immutable(next);
|
||||
listeners.forEach((listener) => listener(state));
|
||||
return state;
|
||||
}
|
||||
|
||||
async function negotiate() {
|
||||
const current = ++generation;
|
||||
publish({phase: 'loading', capabilities: null, error: null});
|
||||
try {
|
||||
const response = await fetcher(`${baseUrl}/capabilities`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
headers: {Accept: 'application/vnd.hermes.hux+json; version=1'},
|
||||
});
|
||||
if (current !== generation) return state;
|
||||
if (response.status === 404) {
|
||||
return publish({phase: 'disabled', capabilities: null, error: null});
|
||||
}
|
||||
if (!response.ok) throw new Error(`Capability request failed (${response.status})`);
|
||||
const capabilities = normalizeCapabilities(await response.json(), expectedIdentity);
|
||||
if (!capabilities.flags.includes('hux.foundation')) {
|
||||
return publish({phase: 'disabled', capabilities: null, error: null});
|
||||
}
|
||||
return publish({phase: 'ready', capabilities, error: null});
|
||||
} catch (error) {
|
||||
if (current !== generation) return state;
|
||||
const message = error instanceof HuxContractError ?
|
||||
'Hermes workspace capabilities could not be verified.' :
|
||||
'Hermes workspace features are temporarily unavailable.';
|
||||
return publish({phase: 'error', capabilities: null, error: message});
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
if (typeof listener !== 'function') throw new TypeError('Listener must be a function');
|
||||
listeners.add(listener);
|
||||
listener(state);
|
||||
return function unsubscribe() { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
apiVersion: API_VERSION,
|
||||
identity: expectedIdentity,
|
||||
getState: () => state,
|
||||
negotiate,
|
||||
subscribe,
|
||||
enabled: (flag) => state.phase === 'ready' &&
|
||||
FLAGS.includes(flag) && state.capabilities.flags.includes(flag),
|
||||
endpoint: (path) => {
|
||||
const safe = String(path || '');
|
||||
if (!safe.startsWith('/') || safe.startsWith('//') || safe.includes('..') ||
|
||||
/\\|%(?:2e|2f|5c)/i.test(safe)) {
|
||||
throw new TypeError('HUX endpoint must be a scoped relative path');
|
||||
}
|
||||
return baseUrl + safe;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createAdapters(scope) {
|
||||
const identity = normalizeIdentity(scope && scope.identity);
|
||||
const conversationId = requiredString(scope && scope.conversationId, ID, 'conversation id');
|
||||
|
||||
function assertRecord(record) {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
throw new HuxContractError('HUX record is not an object');
|
||||
}
|
||||
if (record.owner && record.owner !== identity.userRef) {
|
||||
throw new HuxContractError('HUX record owner crossed the user boundary');
|
||||
}
|
||||
if (record.conversation_id && record.conversation_id !== conversationId) {
|
||||
throw new HuxContractError('HUX record crossed the conversation boundary');
|
||||
}
|
||||
}
|
||||
|
||||
function adaptEvent(record) {
|
||||
assertRecord(record);
|
||||
if (record.schema !== 'hux.event.v1' || !Number.isSafeInteger(record.seq) || record.seq < 0) {
|
||||
throw new HuxContractError('Invalid activity event');
|
||||
}
|
||||
requiredString(record.id, ID, 'event id');
|
||||
requiredString(record.ts, TIMESTAMP, 'event timestamp');
|
||||
if (!EVENT_KINDS.has(record.kind) || !SENSITIVITY.has(record.sensitivity)) {
|
||||
throw new HuxContractError('Invalid activity event classification');
|
||||
}
|
||||
if (typeof record.summary !== 'string' || !record.summary.length || record.summary.length > 280) {
|
||||
throw new HuxContractError('Invalid activity event summary');
|
||||
}
|
||||
if (!record.redaction || !['none', 'partial', 'full'].includes(record.redaction.level)) {
|
||||
throw new HuxContractError('Invalid activity event redaction');
|
||||
}
|
||||
if (record.conversation_id !== conversationId) {
|
||||
throw new HuxContractError('Activity event has no matching conversation');
|
||||
}
|
||||
const hidden = record.redaction && record.redaction.level === 'full';
|
||||
const evidence = Array.isArray(record.evidence) ? record.evidence.slice(0, 64).map((item) => ({
|
||||
kind: String(item && item.kind || ''),
|
||||
id: String(item && item.id || ''),
|
||||
})).filter((item) => EVIDENCE_KINDS.has(item.kind) && item.id.length <= 200) : [];
|
||||
return immutable({
|
||||
schema: record.schema,
|
||||
id: record.id,
|
||||
seq: record.seq,
|
||||
timestamp: String(record.ts || ''),
|
||||
kind: String(record.kind || ''),
|
||||
summary: hidden ? 'Details hidden by privacy controls.' : String(record.summary || '').slice(0, 280),
|
||||
sensitivity: String(record.sensitivity || 'personal'),
|
||||
redacted: hidden,
|
||||
evidence,
|
||||
});
|
||||
}
|
||||
|
||||
function adaptObject(record) {
|
||||
assertRecord(record);
|
||||
if (typeof record.schema !== 'string' || !/^hux\.[a-z_]+\.v1$/.test(record.schema)) {
|
||||
throw new HuxContractError('Unsupported HUX object version');
|
||||
}
|
||||
requiredString(record.id, ID, 'object id');
|
||||
return immutable({
|
||||
schema: record.schema,
|
||||
id: record.id,
|
||||
title: String(record.title || record.question || record.kind || 'Hermes item').slice(0, 300),
|
||||
kind: String(record.kind || record.type || ''),
|
||||
status: String(record.status || ''),
|
||||
sensitivity: String(record.sensitivity || 'personal'),
|
||||
conversationId: record.conversation_id || null,
|
||||
projectId: record.project_id || null,
|
||||
updatedAt: record.updated_at || record.created_at || null,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({identity, conversationId, adaptEvent, adaptObject});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
API_VERSION,
|
||||
CAPABILITY_SCHEMA,
|
||||
FLAGS,
|
||||
HuxContractError,
|
||||
createAdapters,
|
||||
createClient,
|
||||
normalizeCapabilities,
|
||||
normalizeIdentity,
|
||||
});
|
||||
}));
|
||||
346
dockerfiles/hermes-webui-hux/memory/MemoryControlCenter.tsx
Normal file
346
dockerfiles/hermes-webui-hux/memory/MemoryControlCenter.tsx
Normal file
@ -0,0 +1,346 @@
|
||||
/** Accessible HUX-02 ledger UI. No network request is made by this module. */
|
||||
|
||||
import { EyeOff, History, Pencil, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
MEMORY_TOPICS,
|
||||
memoryControlEnabled,
|
||||
normalizeMemoryPage,
|
||||
} from "./model.ts";
|
||||
import { isControlReference } from "./security.ts";
|
||||
import type {
|
||||
DoNotRememberTarget,
|
||||
HuxIdentity,
|
||||
MemoryItem,
|
||||
MemoryTopic,
|
||||
RawMemoryPage,
|
||||
} from "./types.ts";
|
||||
|
||||
interface MemoryControlCenterProps {
|
||||
flags?: Iterable<string>;
|
||||
identity: HuxIdentity;
|
||||
page: RawMemoryPage;
|
||||
conversationId?: string;
|
||||
messageId?: string;
|
||||
busyId?: string;
|
||||
onApprove?: (memoryId: string) => void;
|
||||
onReject?: (memoryId: string) => void;
|
||||
onEditAsNew?: (memoryId: string, content: string) => void;
|
||||
onForget?: (memoryId: string) => void;
|
||||
onOpenSourceMessage?: (memoryId: string) => void;
|
||||
onDoNotRemember?: (target: DoNotRememberTarget) => void;
|
||||
onRequireSuggestOnly?: () => void;
|
||||
}
|
||||
|
||||
function LedgerActions({
|
||||
item,
|
||||
busy,
|
||||
onApprove,
|
||||
onReject,
|
||||
onEdit,
|
||||
onForget,
|
||||
onOpenSource,
|
||||
}: {
|
||||
item: MemoryItem;
|
||||
busy: boolean;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onEdit?: () => void;
|
||||
onForget?: () => void;
|
||||
onOpenSource?: () => void;
|
||||
}) {
|
||||
if (item.status === "forgotten" || item.status === "expired") return null;
|
||||
if (item.status === "proposed") {
|
||||
return (
|
||||
<div className="hux-memory-actions" aria-label="Approval choices">
|
||||
{item.sourceIsMessage && onOpenSource && (
|
||||
<button type="button" disabled={busy} onClick={onOpenSource}>
|
||||
View source message
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={busy} onClick={onApprove}>
|
||||
Remember
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={onReject}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="hux-memory-actions">
|
||||
{item.sourceIsMessage && onOpenSource && (
|
||||
<button type="button" disabled={busy} onClick={onOpenSource}>
|
||||
View source message
|
||||
</button>
|
||||
)}
|
||||
{item.content !== null && (
|
||||
<button type="button" disabled={busy} onClick={onEdit}>
|
||||
<Pencil aria-hidden /> Edit as new entry
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={busy} onClick={onForget}>
|
||||
<Trash2 aria-hidden /> Delete & forget
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryCard({
|
||||
item,
|
||||
busy,
|
||||
editing,
|
||||
draft,
|
||||
setDraft,
|
||||
beginEdit,
|
||||
cancelEdit,
|
||||
saveEdit,
|
||||
approve,
|
||||
reject,
|
||||
forget,
|
||||
openSource,
|
||||
}: {
|
||||
item: MemoryItem;
|
||||
busy: boolean;
|
||||
editing: boolean;
|
||||
draft: string;
|
||||
setDraft: (value: string) => void;
|
||||
beginEdit: () => void;
|
||||
cancelEdit: () => void;
|
||||
saveEdit: () => void;
|
||||
approve: () => void;
|
||||
reject: () => void;
|
||||
forget: () => void;
|
||||
openSource?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li className="hux-memory-card" data-status={item.status}>
|
||||
<header>
|
||||
<span className="hux-memory-kind">{item.kind}</span>
|
||||
<span className="hux-memory-status">{item.status}</span>
|
||||
</header>
|
||||
{item.content === null ? (
|
||||
<p className="hux-memory-hidden">
|
||||
<EyeOff aria-hidden /> {item.contentNotice}
|
||||
</p>
|
||||
) : (
|
||||
<p className="hux-memory-content">{item.content}</p>
|
||||
)}
|
||||
<dl className="hux-memory-meta">
|
||||
<div>
|
||||
<dt>Why</dt><dd>{item.reason}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt><dd>{item.sourceLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Recorded by</dt><dd>{item.provenanceLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scope</dt><dd>{item.scopeLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Sensitivity</dt><dd>{item.sensitivity}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Approval</dt>
|
||||
<dd>{item.approvalMode === "ask" ? "Ask first" : "Automatic (historical)"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Retention</dt><dd>{item.ttlLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Saved</dt>
|
||||
<dd>
|
||||
<time dateTime={item.savedAt}>
|
||||
{new Date(item.savedAt).toLocaleString()}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{editing && (
|
||||
<form
|
||||
className="hux-memory-edit"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveEdit();
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`hux-memory-edit-${item.id}`}>Proposed replacement</label>
|
||||
<textarea
|
||||
id={`hux-memory-edit-${item.id}`}
|
||||
value={draft}
|
||||
maxLength={2000}
|
||||
required
|
||||
autoFocus
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<p>
|
||||
The original stays in the audit ledger. This replacement needs
|
||||
approval.
|
||||
</p>
|
||||
<div className="hux-memory-actions">
|
||||
<button type="submit" disabled={busy || !draft.trim()}>
|
||||
Propose change
|
||||
</button>
|
||||
<button type="button" onClick={cancelEdit}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{!editing && (
|
||||
<LedgerActions
|
||||
item={item}
|
||||
busy={busy}
|
||||
onApprove={approve}
|
||||
onReject={reject}
|
||||
onEdit={beginEdit}
|
||||
onForget={forget}
|
||||
onOpenSource={openSource}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render nothing unless foundation, privacy, and memory-control are enabled. */
|
||||
export function MemoryControlCenter(props: MemoryControlCenterProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [topic, setTopic] = useState<MemoryTopic>("general");
|
||||
const enabled = memoryControlEnabled(props.flags);
|
||||
const page = useMemo(
|
||||
() => {
|
||||
if (!enabled) return null;
|
||||
try {
|
||||
return normalizeMemoryPage(props.page, props.identity);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[enabled, props.identity, props.page],
|
||||
);
|
||||
if (!enabled) return null;
|
||||
if (!page) {
|
||||
return (
|
||||
<section className="hux-memory" role="alert">
|
||||
Memory is unavailable because its workspace scope could not be verified.
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function beginEdit(item: MemoryItem) {
|
||||
setEditingId(item.id);
|
||||
setDraft(item.content || "");
|
||||
}
|
||||
|
||||
function saveEdit(item: MemoryItem) {
|
||||
const value = draft.trim();
|
||||
if (value) props.onEditAsNew?.(item.id, value);
|
||||
setEditingId(null);
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="hux-memory" aria-labelledby="hux-memory-title">
|
||||
<header className="hux-memory-heading">
|
||||
<div>
|
||||
<h2 id="hux-memory-title">
|
||||
<History aria-hidden /> Memory
|
||||
</h2>
|
||||
<p>
|
||||
An append-only record of what Hermes may use in future
|
||||
conversations.
|
||||
</p>
|
||||
</div>
|
||||
<div className="hux-memory-approval" role="status">
|
||||
<ShieldCheck aria-hidden />
|
||||
<span>Suggest only — nothing new is saved without your approval.</span>
|
||||
<button type="button" onClick={props.onRequireSuggestOnly}>Enforce</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<fieldset className="hux-memory-blocks">
|
||||
<legend>Do not remember</legend>
|
||||
{isControlReference(props.messageId) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
props.onDoNotRemember?.({
|
||||
kind: "message",
|
||||
messageId: props.messageId as string,
|
||||
})
|
||||
}
|
||||
>
|
||||
This message
|
||||
</button>
|
||||
)}
|
||||
{isControlReference(props.conversationId, "conv") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
props.onDoNotRemember?.({
|
||||
kind: "conversation",
|
||||
conversationId: props.conversationId as string,
|
||||
})
|
||||
}
|
||||
>
|
||||
This conversation
|
||||
</button>
|
||||
)}
|
||||
<label htmlFor="hux-memory-topic">Topic</label>
|
||||
<select
|
||||
id="hux-memory-topic"
|
||||
value={topic}
|
||||
onChange={(event) => setTopic(event.target.value as MemoryTopic)}
|
||||
>
|
||||
{MEMORY_TOPICS.map((value) => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onDoNotRemember?.({ kind: "topic", topic })}
|
||||
>
|
||||
Block topic
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
{(page.invalid > 0 || page.truncated > 0) && (
|
||||
<p className="hux-memory-warning" role="status">
|
||||
Some entries were withheld because they were unsafe, out of scope, or
|
||||
beyond this page.
|
||||
</p>
|
||||
)}
|
||||
<ol className="hux-memory-ledger" aria-label="Memory ledger">
|
||||
{page.entries.map((item) => (
|
||||
<MemoryCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
busy={props.busyId === item.id}
|
||||
editing={editingId === item.id}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
beginEdit={() => beginEdit(item)}
|
||||
cancelEdit={() => {
|
||||
setEditingId(null);
|
||||
setDraft("");
|
||||
}}
|
||||
saveEdit={() => saveEdit(item)}
|
||||
approve={() => props.onApprove?.(item.id)}
|
||||
reject={() => props.onReject?.(item.id)}
|
||||
forget={() => props.onForget?.(item.id)}
|
||||
openSource={
|
||||
props.onOpenSourceMessage
|
||||
? () => props.onOpenSourceMessage?.(item.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
{page.entries.length === 0 && (
|
||||
<p className="hux-memory-empty">Hermes has no memory entries to show.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
79
dockerfiles/hermes-webui-hux/memory/endpoints.ts
Normal file
79
dockerfiles/hermes-webui-hux/memory/endpoints.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Same-origin endpoint descriptions for a future HUX-02 backend adapter.
|
||||
* The server must derive identity from the authenticated session, compare it
|
||||
* with each request's expectedIdentity, and echo it in list responses. A
|
||||
* revision appends a proposed ledger entry; forget purges content while
|
||||
* retaining audit metadata. These descriptors never perform a request.
|
||||
*/
|
||||
|
||||
import { memoryControlEnabled } from "./model.ts";
|
||||
import { isOpaqueMemoryId, normalizeIdentity } from "./security.ts";
|
||||
import type {
|
||||
HuxIdentity,
|
||||
MemoryAction,
|
||||
MemoryEndpoint,
|
||||
MemoryEndpointContract,
|
||||
} from "./types.ts";
|
||||
|
||||
interface FoundationClient {
|
||||
apiVersion: string;
|
||||
identity: HuxIdentity;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
function endpoint(
|
||||
method: MemoryEndpoint["method"],
|
||||
path: string,
|
||||
responseSchema: string,
|
||||
): MemoryEndpoint {
|
||||
return Object.freeze({ method, path, responseSchema });
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe, but do not call, the backend interface. Returning null while any
|
||||
* dependency flag is absent keeps HUX-02 inert and default-off.
|
||||
*/
|
||||
export function createMemoryEndpointContract(
|
||||
client: FoundationClient,
|
||||
flags?: Iterable<string>,
|
||||
): MemoryEndpointContract | null {
|
||||
if (!memoryControlEnabled(flags)) return null;
|
||||
if (client.apiVersion !== "hux.v1") throw new TypeError("HUX v1 client required");
|
||||
const identity = Object.freeze({ ...normalizeIdentity(client.identity) });
|
||||
|
||||
function itemPath(memoryId: string): string {
|
||||
if (!isOpaqueMemoryId(memoryId)) throw new TypeError("Opaque memory id required");
|
||||
return client.endpoint(`/memories/${memoryId}`);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
identity,
|
||||
list: endpoint("GET", client.endpoint("/memories"), "hux.memory_page.v1"),
|
||||
suggestOnly: endpoint(
|
||||
"PUT",
|
||||
client.endpoint("/memory-settings/suggest-only"),
|
||||
"hux.memory_settings.v1",
|
||||
),
|
||||
doNotRemember: endpoint(
|
||||
"POST",
|
||||
client.endpoint("/memory-blocks"),
|
||||
"hux.memory_block.v1",
|
||||
),
|
||||
action(memoryId: string, action: MemoryAction) {
|
||||
if (!["approve", "reject", "forget"].includes(action))
|
||||
throw new TypeError("Unsupported memory action");
|
||||
return endpoint(
|
||||
"POST",
|
||||
`${itemPath(memoryId)}/${action}`,
|
||||
"hux.memory.v1",
|
||||
);
|
||||
},
|
||||
revise(memoryId: string) {
|
||||
return endpoint(
|
||||
"POST",
|
||||
`${itemPath(memoryId)}/revisions`,
|
||||
"hux.memory.v1",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
24
dockerfiles/hermes-webui-hux/memory/index.ts
Normal file
24
dockerfiles/hermes-webui-hux/memory/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export { MemoryControlCenter } from "./MemoryControlCenter.tsx";
|
||||
export { createMemoryEndpointContract } from "./endpoints.ts";
|
||||
export {
|
||||
FOUNDATION_FLAG,
|
||||
MEMORY_CONTROL_FLAG,
|
||||
MEMORY_TOPICS,
|
||||
PRIVACY_FLAG,
|
||||
memoryControlEnabled,
|
||||
normalizeMemoryPage,
|
||||
} from "./model.ts";
|
||||
export { MemoryContractError } from "./security.ts";
|
||||
export type {
|
||||
DoNotRememberTarget,
|
||||
HuxIdentity,
|
||||
MemoryBlockRequest,
|
||||
MemoryEndpointContract,
|
||||
MemoryItem,
|
||||
MemoryMutationRequest,
|
||||
MemoryPage,
|
||||
MemoryRevisionRequest,
|
||||
RawMemoryEntry,
|
||||
RawMemoryPage,
|
||||
SuggestOnlyRequest,
|
||||
} from "./types.ts";
|
||||
239
dockerfiles/hermes-webui-hux/memory/model.ts
Normal file
239
dockerfiles/hermes-webui-hux/memory/model.ts
Normal file
@ -0,0 +1,239 @@
|
||||
/** Fail-closed normalization for the append-only HUX-02 memory ledger. */
|
||||
|
||||
import {
|
||||
MemoryContractError,
|
||||
isOpaqueMemoryId,
|
||||
isUtcTimestamp,
|
||||
normalizeIdentity,
|
||||
safeMemoryText,
|
||||
sameIdentity,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
HuxIdentity,
|
||||
MemoryItem,
|
||||
MemoryKind,
|
||||
MemoryPage,
|
||||
MemoryStatus,
|
||||
MemoryTopic,
|
||||
RawMemoryEntry,
|
||||
RawMemoryPage,
|
||||
Sensitivity,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const PRIVACY_FLAG = "hux.privacy";
|
||||
export const MEMORY_CONTROL_FLAG = "hux.memory_control";
|
||||
|
||||
const KINDS = new Set<MemoryKind>([
|
||||
"preference",
|
||||
"fact",
|
||||
"instruction",
|
||||
"context",
|
||||
]);
|
||||
const STATUSES = new Set<MemoryStatus>([
|
||||
"proposed",
|
||||
"active",
|
||||
"rejected",
|
||||
"expired",
|
||||
"forgotten",
|
||||
]);
|
||||
const SENSITIVITIES = new Set<Sensitivity>([
|
||||
"public",
|
||||
"personal",
|
||||
"sensitive",
|
||||
"restricted",
|
||||
]);
|
||||
export const MEMORY_TOPICS = [
|
||||
"general",
|
||||
"health",
|
||||
"finance",
|
||||
"legal",
|
||||
"relationships",
|
||||
"credentials",
|
||||
"minors",
|
||||
"location",
|
||||
"biometric",
|
||||
] as const satisfies readonly MemoryTopic[];
|
||||
const TOPICS = new Set<MemoryTopic>(MEMORY_TOPICS);
|
||||
const SOURCE_LABELS: Readonly<Record<string, string>> = {
|
||||
message: "Source message",
|
||||
tool_call: "Tool request",
|
||||
tool_result: "Tool evidence",
|
||||
artifact_version: "Artifact version",
|
||||
source: "Research source",
|
||||
passage: "Supporting passage",
|
||||
memory: "Earlier memory entry",
|
||||
approval: "Approval decision",
|
||||
run: "Agent run",
|
||||
url: "Website",
|
||||
file: "Workspace file",
|
||||
build: "Build evidence",
|
||||
flux: "Flux reconciliation",
|
||||
pod: "Workload evidence",
|
||||
};
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
export function memoryControlEnabled(flags?: Iterable<string>): boolean {
|
||||
if (!flags) return false;
|
||||
const active = new Set(flags);
|
||||
return (
|
||||
active.has(FOUNDATION_FLAG) &&
|
||||
active.has(PRIVACY_FLAG) &&
|
||||
active.has(MEMORY_CONTROL_FLAG)
|
||||
);
|
||||
}
|
||||
|
||||
function ttlLabel(raw: unknown): string | null {
|
||||
const ttl = raw as Record<string, unknown> | null;
|
||||
if (!ttl || Array.isArray(ttl)) return null;
|
||||
if (ttl.policy === "never") return "Kept until you forget it";
|
||||
if (ttl.policy === "expires_at" && isUtcTimestamp(ttl.expires_at)) {
|
||||
return `Expires ${ttl.expires_at}`;
|
||||
}
|
||||
if (
|
||||
ttl.policy === "decay" &&
|
||||
Number.isInteger(ttl.decay_days) &&
|
||||
(ttl.decay_days as number) >= 1 &&
|
||||
(ttl.decay_days as number) <= 3650
|
||||
) {
|
||||
return `Reviewed after ${ttl.decay_days} days`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scopeLabel(raw: unknown): string | null {
|
||||
const scope = raw as Record<string, unknown> | null;
|
||||
if (!scope || Array.isArray(scope)) return null;
|
||||
if (scope.level === "global" && scope.scope_id === undefined)
|
||||
return "All conversations";
|
||||
if (scope.level === "project" && typeof scope.scope_id === "string")
|
||||
return "This project";
|
||||
if (scope.level === "conversation" && typeof scope.scope_id === "string")
|
||||
return "This conversation";
|
||||
return null;
|
||||
}
|
||||
|
||||
function reasonFor(raw: RawMemoryEntry, hidden: boolean): string {
|
||||
if (hidden) return "Reason hidden by privacy controls";
|
||||
const audit = Array.isArray(raw.audit) ? raw.audit : [];
|
||||
for (let index = audit.length - 1; index >= 0; index -= 1) {
|
||||
const record = audit[index] as Record<string, unknown> | null;
|
||||
if (record?.note) return safeMemoryText(record.note, "Saved for future help", 200);
|
||||
}
|
||||
return raw.status === "proposed"
|
||||
? "Hermes suggested this for your approval"
|
||||
: "Saved for future help";
|
||||
}
|
||||
|
||||
function normalizeEntry(
|
||||
raw: RawMemoryEntry,
|
||||
expected: HuxIdentity,
|
||||
): MemoryItem | null {
|
||||
if (
|
||||
raw.schema !== "hux.memory.v1" ||
|
||||
!isOpaqueMemoryId(raw.id) ||
|
||||
raw.owner !== expected.userRef ||
|
||||
!KINDS.has(raw.kind as MemoryKind) ||
|
||||
!STATUSES.has(raw.status as MemoryStatus) ||
|
||||
!SENSITIVITIES.has(raw.sensitivity as Sensitivity) ||
|
||||
!TOPICS.has((raw.topic ?? "general") as MemoryTopic) ||
|
||||
!["automatic", "ask"].includes(String(raw.approval_mode)) ||
|
||||
typeof raw.content !== "string" ||
|
||||
raw.content.length > 2000 ||
|
||||
!Array.isArray(raw.audit) ||
|
||||
raw.audit.length < 1 ||
|
||||
!isUtcTimestamp(raw.created_at) ||
|
||||
!isUtcTimestamp(raw.updated_at)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const provenance = raw.provenance as Record<string, unknown> | null;
|
||||
const source = raw.source as Record<string, unknown> | null;
|
||||
const ttl = ttlLabel(raw.ttl);
|
||||
const scope = scopeLabel(raw.scope);
|
||||
if (
|
||||
!provenance ||
|
||||
provenance.surface !== expected.surface ||
|
||||
!isUtcTimestamp(provenance.recorded_at) ||
|
||||
!source ||
|
||||
typeof source.id !== "string" ||
|
||||
source.id.length < 1 ||
|
||||
source.id.length > 200 ||
|
||||
typeof source.kind !== "string" ||
|
||||
!(source.kind in SOURCE_LABELS) ||
|
||||
!ttl ||
|
||||
!scope
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const actor = provenance.actor as Record<string, unknown> | null;
|
||||
if (
|
||||
!actor ||
|
||||
!["user", "assistant", "tool", "system", "operator"].includes(String(actor.type)) ||
|
||||
typeof actor.id !== "string" ||
|
||||
actor.id.length < 1 ||
|
||||
actor.id.length > 120
|
||||
)
|
||||
return null;
|
||||
const status = raw.status as MemoryStatus;
|
||||
const sensitivity = raw.sensitivity as Sensitivity;
|
||||
const hidden =
|
||||
status === "forgotten" ||
|
||||
sensitivity === "sensitive" ||
|
||||
sensitivity === "restricted";
|
||||
return {
|
||||
id: raw.id,
|
||||
kind: raw.kind as MemoryKind,
|
||||
content: hidden ? null : safeMemoryText(raw.content, "Memory content unavailable", 2000),
|
||||
contentNotice:
|
||||
status === "forgotten"
|
||||
? "Content permanently removed"
|
||||
: hidden
|
||||
? "Sensitive value hidden by privacy controls"
|
||||
: null,
|
||||
status,
|
||||
sensitivity,
|
||||
topic: (raw.topic ?? "general") as MemoryTopic,
|
||||
approvalMode: raw.approval_mode as "automatic" | "ask",
|
||||
scopeLabel: scope,
|
||||
sourceLabel: SOURCE_LABELS[source.kind],
|
||||
sourceIsMessage: source.kind === "message",
|
||||
provenanceLabel: `${String(actor.type)} via ${expected.surface}`,
|
||||
reason: reasonFor(raw, hidden),
|
||||
savedAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
ttlLabel: ttl,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMemoryPage(
|
||||
raw: RawMemoryPage,
|
||||
expectedIdentity: HuxIdentity,
|
||||
): MemoryPage {
|
||||
const expected = normalizeIdentity(expectedIdentity);
|
||||
if (!raw || typeof raw !== "object" || raw.schema !== "hux.memory_page.v1") {
|
||||
throw new MemoryContractError("Memory response version is unsupported");
|
||||
}
|
||||
const actual = normalizeIdentity(raw.identity);
|
||||
if (!sameIdentity(actual, expected)) {
|
||||
throw new MemoryContractError("Memory response crossed a scope boundary");
|
||||
}
|
||||
if (!Array.isArray(raw.entries)) {
|
||||
throw new MemoryContractError("Memory ledger entries are missing");
|
||||
}
|
||||
const records = raw.entries;
|
||||
const normalized = records
|
||||
.map((entry) => normalizeEntry(entry as RawMemoryEntry, expected))
|
||||
.filter((entry): entry is MemoryItem => entry !== null)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
const truncated = Math.max(0, normalized.length - MAX_ENTRIES);
|
||||
return {
|
||||
entries: truncated ? normalized.slice(0, MAX_ENTRIES) : normalized,
|
||||
invalid: records.length - normalized.length,
|
||||
truncated,
|
||||
nextCursor:
|
||||
typeof raw.next_cursor === "string" && raw.next_cursor.length <= 200
|
||||
? raw.next_cursor
|
||||
: null,
|
||||
};
|
||||
}
|
||||
96
dockerfiles/hermes-webui-hux/memory/security.ts
Normal file
96
dockerfiles/hermes-webui-hux/memory/security.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/** HUX-02 display and scope validation. Raw payloads never cross this boundary. */
|
||||
|
||||
import type { HuxIdentity, HuxSurface } from "./types.ts";
|
||||
|
||||
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
||||
const AUTH =
|
||||
/\b(password|passwd|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
|
||||
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
|
||||
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
|
||||
const PEM = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi;
|
||||
const BLOB =
|
||||
/\b(?=[A-Za-z0-9_+/#.-]{40,}\b)(?=[A-Za-z0-9_+/#.-]*[A-Za-z])(?=[A-Za-z0-9_+/#.-]*\d)[A-Za-z0-9_+/#.-]+\b/g;
|
||||
const URI_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
|
||||
const SURFACES = new Set<HuxSurface>([
|
||||
"chat",
|
||||
"worker",
|
||||
"telegram",
|
||||
"voice",
|
||||
"api",
|
||||
]);
|
||||
|
||||
export class MemoryContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "MemoryContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export function safeMemoryText(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
maxLength = 280,
|
||||
): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const limit = Math.max(1, maxLength);
|
||||
const safe = value
|
||||
.replace(PEM, "[redacted credential]")
|
||||
.replace(AUTH, "$1=[redacted]")
|
||||
.replace(BEARER, "Bearer [redacted]")
|
||||
.replace(JWT, "[redacted credential]")
|
||||
.replace(URI_USERINFO, "$1[redacted]@")
|
||||
.replace(BLOB, "[redacted credential]")
|
||||
.replace(CONTROL, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!safe) return fallback;
|
||||
if (safe.length <= limit) return safe;
|
||||
return limit === 1 ? "…" : `${safe.slice(0, limit - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function isOpaqueMemoryId(value: unknown): value is string {
|
||||
return typeof value === "string" && /^mem_[A-Za-z0-9._-]{4,80}$/.test(value);
|
||||
}
|
||||
|
||||
export function isControlReference(
|
||||
value: unknown,
|
||||
prefix?: "conv",
|
||||
): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(value)) return false;
|
||||
return !prefix || value.startsWith(`${prefix}_`);
|
||||
}
|
||||
|
||||
export function isUtcTimestamp(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) &&
|
||||
!Number.isNaN(Date.parse(value))
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeIdentity(value: unknown): HuxIdentity {
|
||||
const raw = value as Record<string, unknown> | null;
|
||||
const tenantRef = raw?.tenant_ref ?? raw?.tenantRef;
|
||||
const userRef = raw?.user_ref ?? raw?.userRef;
|
||||
if (
|
||||
!raw ||
|
||||
Array.isArray(raw) ||
|
||||
typeof tenantRef !== "string" ||
|
||||
!/^tnt_[0-9a-f]{16,64}$/.test(tenantRef) ||
|
||||
typeof userRef !== "string" ||
|
||||
!/^usr_[0-9a-f]{16,64}$/.test(userRef) ||
|
||||
!SURFACES.has(raw.surface as HuxSurface)
|
||||
) {
|
||||
throw new MemoryContractError("Memory identity could not be verified");
|
||||
}
|
||||
return { tenantRef, userRef, surface: raw.surface as HuxSurface };
|
||||
}
|
||||
|
||||
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
|
||||
return (
|
||||
left.tenantRef === right.tenantRef &&
|
||||
left.userRef === right.userRef &&
|
||||
left.surface === right.surface
|
||||
);
|
||||
}
|
||||
197
dockerfiles/hermes-webui-hux/memory/styles.css
Normal file
197
dockerfiles/hermes-webui-hux/memory/styles.css
Normal file
@ -0,0 +1,197 @@
|
||||
.hux-memory {
|
||||
color: var(--hux-text, #dce8f4);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hux-memory-heading {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-memory-heading h2,
|
||||
.hux-memory-heading h2 svg,
|
||||
.hux-memory-hidden,
|
||||
.hux-memory-actions,
|
||||
.hux-memory-approval {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hux-memory-heading h2 {
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-memory-heading p,
|
||||
.hux-memory-edit p {
|
||||
color: color-mix(in srgb, currentColor 62%, transparent);
|
||||
font-size: 0.72rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.hux-memory-approval {
|
||||
background: color-mix(in srgb, #25d4c3 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #25d4c3 35%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
gap: 0.4rem;
|
||||
max-width: 24rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.hux-memory-blocks {
|
||||
align-items: center;
|
||||
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.hux-memory-blocks legend {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.hux-memory button,
|
||||
.hux-memory select,
|
||||
.hux-memory textarea {
|
||||
background: color-mix(in srgb, currentColor 7%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 0.5rem;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.hux-memory button {
|
||||
cursor: pointer;
|
||||
padding: 0.38rem 0.55rem;
|
||||
}
|
||||
|
||||
.hux-memory button:focus-visible,
|
||||
.hux-memory select:focus-visible,
|
||||
.hux-memory textarea:focus-visible {
|
||||
outline: 2px solid var(--hux-accent, #25d4c3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hux-memory button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.hux-memory-ledger {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hux-memory-card {
|
||||
background: color-mix(in srgb, currentColor 3.5%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.hux-memory-card > header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-memory-kind,
|
||||
.hux-memory-status {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-memory-status {
|
||||
color: var(--hux-accent, #25d4c3);
|
||||
}
|
||||
|
||||
.hux-memory-content {
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
margin: 0.65rem 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hux-memory-hidden {
|
||||
color: color-mix(in srgb, currentColor 62%, transparent);
|
||||
font-size: 0.78rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.hux-memory-meta {
|
||||
display: grid;
|
||||
font-size: 0.68rem;
|
||||
gap: 0.35rem 0.7rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.hux-memory-meta div { min-width: 0; }
|
||||
.hux-memory-meta dt { color: color-mix(in srgb, currentColor 52%, transparent); }
|
||||
.hux-memory-meta dd { margin: 0.12rem 0 0; overflow-wrap: anywhere; }
|
||||
|
||||
.hux-memory-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hux-memory-actions svg,
|
||||
.hux-memory-heading svg,
|
||||
.hux-memory-hidden svg,
|
||||
.hux-memory-approval svg {
|
||||
height: 0.9rem;
|
||||
width: 0.9rem;
|
||||
}
|
||||
|
||||
.hux-memory-edit {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.hux-memory-edit textarea {
|
||||
min-height: 6rem;
|
||||
padding: 0.55rem;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-memory-warning {
|
||||
border-left: 2px solid #efb756;
|
||||
color: #efcf96;
|
||||
font-size: 0.72rem;
|
||||
margin: 0;
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
|
||||
.hux-memory-empty {
|
||||
color: color-mix(in srgb, currentColor 58%, transparent);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.hux-memory-heading,
|
||||
.hux-memory-approval { align-items: stretch; }
|
||||
.hux-memory-approval { max-width: none; width: 100%; }
|
||||
.hux-memory-meta { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-memory * { scroll-behavior: auto; transition: none; }
|
||||
}
|
||||
|
||||
126
dockerfiles/hermes-webui-hux/memory/types.ts
Normal file
126
dockerfiles/hermes-webui-hux/memory/types.ts
Normal file
@ -0,0 +1,126 @@
|
||||
/** Safe frontend contracts for the default-off HUX-02 memory ledger. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
export type MemoryKind = "preference" | "fact" | "instruction" | "context";
|
||||
export type MemoryStatus =
|
||||
| "proposed"
|
||||
| "active"
|
||||
| "rejected"
|
||||
| "expired"
|
||||
| "forgotten";
|
||||
export type Sensitivity = "public" | "personal" | "sensitive" | "restricted";
|
||||
export type MemoryTopic =
|
||||
| "general"
|
||||
| "health"
|
||||
| "finance"
|
||||
| "legal"
|
||||
| "relationships"
|
||||
| "credentials"
|
||||
| "minors"
|
||||
| "location"
|
||||
| "biometric";
|
||||
|
||||
export interface HuxIdentity {
|
||||
tenantRef: string;
|
||||
userRef: string;
|
||||
surface: HuxSurface;
|
||||
}
|
||||
|
||||
export interface RawMemoryEntry {
|
||||
schema?: unknown;
|
||||
id?: unknown;
|
||||
owner?: unknown;
|
||||
scope?: unknown;
|
||||
kind?: unknown;
|
||||
content?: unknown;
|
||||
status?: unknown;
|
||||
approval_mode?: unknown;
|
||||
sensitivity?: unknown;
|
||||
topic?: unknown;
|
||||
ttl?: unknown;
|
||||
source?: unknown;
|
||||
provenance?: unknown;
|
||||
created_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
audit?: unknown;
|
||||
}
|
||||
|
||||
export interface RawMemoryPage {
|
||||
schema?: unknown;
|
||||
identity?: unknown;
|
||||
entries?: unknown;
|
||||
next_cursor?: unknown;
|
||||
}
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string;
|
||||
kind: MemoryKind;
|
||||
content: string | null;
|
||||
contentNotice: string | null;
|
||||
status: MemoryStatus;
|
||||
sensitivity: Sensitivity;
|
||||
topic: MemoryTopic;
|
||||
approvalMode: "automatic" | "ask";
|
||||
scopeLabel: string;
|
||||
sourceLabel: string;
|
||||
sourceIsMessage: boolean;
|
||||
provenanceLabel: string;
|
||||
reason: string;
|
||||
savedAt: string;
|
||||
updatedAt: string;
|
||||
ttlLabel: string;
|
||||
}
|
||||
|
||||
export interface MemoryPage {
|
||||
entries: MemoryItem[];
|
||||
invalid: number;
|
||||
truncated: number;
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export type DoNotRememberTarget =
|
||||
| { kind: "message"; messageId: string }
|
||||
| { kind: "conversation"; conversationId: string }
|
||||
| { kind: "topic"; topic: MemoryTopic };
|
||||
|
||||
export type MemoryAction = "approve" | "reject" | "forget";
|
||||
|
||||
export interface MemoryEndpoint {
|
||||
method: "GET" | "POST" | "PUT";
|
||||
path: string;
|
||||
responseSchema: string;
|
||||
}
|
||||
|
||||
export interface MemoryEndpointContract {
|
||||
identity: HuxIdentity;
|
||||
list: MemoryEndpoint;
|
||||
suggestOnly: MemoryEndpoint;
|
||||
doNotRemember: MemoryEndpoint;
|
||||
action(memoryId: string, action: MemoryAction): MemoryEndpoint;
|
||||
revise(memoryId: string): MemoryEndpoint;
|
||||
}
|
||||
|
||||
/** The server compares expectedIdentity with the authenticated HUX capability scope. */
|
||||
export interface MemoryMutationRequest {
|
||||
expectedIdentity: HuxIdentity;
|
||||
expectedUpdatedAt: string;
|
||||
action: MemoryAction;
|
||||
}
|
||||
|
||||
/** A revision appends a proposed entry; it never overwrites the referenced entry. */
|
||||
export interface MemoryRevisionRequest {
|
||||
expectedIdentity: HuxIdentity;
|
||||
expectedUpdatedAt: string;
|
||||
content: string;
|
||||
approvalMode: "ask";
|
||||
}
|
||||
|
||||
export interface MemoryBlockRequest {
|
||||
expectedIdentity: HuxIdentity;
|
||||
target: DoNotRememberTarget;
|
||||
}
|
||||
|
||||
export interface SuggestOnlyRequest {
|
||||
expectedIdentity: HuxIdentity;
|
||||
approvalMode: "ask";
|
||||
}
|
||||
187
dockerfiles/hermes-webui-hux/modes/FriendlyModeSelector.tsx
Normal file
187
dockerfiles/hermes-webui-hux/modes/FriendlyModeSelector.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
/** Accessible HUX-06 mode intent selector. The feature is default-off. */
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
buildModeRequest,
|
||||
friendlyModesEnabled,
|
||||
normalizeAdvancedRoutes,
|
||||
normalizeModeCatalog,
|
||||
normalizeResolvedRoute,
|
||||
} from "./model.ts";
|
||||
import type {
|
||||
FriendlyModeIntent,
|
||||
FriendlyModeName,
|
||||
RawAdvancedRouteChoice,
|
||||
RawModeIntent,
|
||||
RawResolvedRoute,
|
||||
} from "./types.ts";
|
||||
|
||||
interface FriendlyModeSelectorProps {
|
||||
flags?: Iterable<string>;
|
||||
modes: readonly RawModeIntent[];
|
||||
selectedMode: FriendlyModeName;
|
||||
advancedRoutes?: readonly RawAdvancedRouteChoice[];
|
||||
resolvedRoute?: RawResolvedRoute;
|
||||
disabled?: boolean;
|
||||
onChange(intent: FriendlyModeIntent): void;
|
||||
}
|
||||
|
||||
const MODE_HELP: Readonly<Record<FriendlyModeName, string>> = {
|
||||
fast: "Quick, direct answers with light reasoning.",
|
||||
thoughtful: "More reasoning for careful decisions.",
|
||||
research: "Source-backed investigation with citations.",
|
||||
create: "Generative work with artifact support.",
|
||||
private: "Local-only processing with ephemeral retention.",
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS = {
|
||||
codex: "OpenAI",
|
||||
claude: "Anthropic",
|
||||
local: "Local",
|
||||
} as const;
|
||||
|
||||
/** Render nothing unless HUX-11 and HUX-06 are both explicitly enabled. */
|
||||
export function FriendlyModeSelector({
|
||||
flags,
|
||||
modes,
|
||||
selectedMode,
|
||||
advancedRoutes = [],
|
||||
resolvedRoute,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: FriendlyModeSelectorProps) {
|
||||
const enabled = friendlyModesEnabled(flags);
|
||||
const catalog = useMemo(() => normalizeModeCatalog(modes), [modes]);
|
||||
const choices = useMemo(
|
||||
() => normalizeAdvancedRoutes(advancedRoutes),
|
||||
[advancedRoutes],
|
||||
);
|
||||
const resolved = useMemo(
|
||||
() => normalizeResolvedRoute(resolvedRoute),
|
||||
[resolvedRoute],
|
||||
);
|
||||
if (!enabled) return null;
|
||||
|
||||
if (!catalog) {
|
||||
return (
|
||||
<p className="hux-modes-error" role="alert">
|
||||
Mode choices are unavailable because their safety contract could not be
|
||||
verified.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const selected = catalog.find((mode) => mode.mode === selectedMode);
|
||||
if (!selected) {
|
||||
return (
|
||||
<p className="hux-modes-error" role="alert">
|
||||
The selected mode could not be verified. No routing change was made.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const compatibleChoices = choices.filter((choice) =>
|
||||
selected.mode === "private"
|
||||
? choice.localOnly && choice.provider === "local"
|
||||
: !choice.localOnly && selected.constraints.providers.includes(choice.provider),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="hux-modes" aria-labelledby="hux-modes-heading">
|
||||
<div className="hux-modes-heading">
|
||||
<div>
|
||||
<h2 id="hux-modes-heading">Response mode</h2>
|
||||
<p>Choose the intent; Switchyard selects from the allowed pool.</p>
|
||||
</div>
|
||||
<span className="hux-modes-active" aria-live="polite">
|
||||
{selected.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={disabled} className="hux-mode-grid">
|
||||
<legend className="sr-only">Choose a response mode</legend>
|
||||
{catalog.map((mode) => (
|
||||
<label className="hux-mode-card" key={mode.mode}>
|
||||
<input
|
||||
type="radio"
|
||||
name="hermes-friendly-mode"
|
||||
value={mode.mode}
|
||||
checked={mode.mode === selected.mode}
|
||||
aria-describedby={`hux-mode-help-${mode.mode}`}
|
||||
onChange={() => {
|
||||
const request = buildModeRequest(mode);
|
||||
if (request) onChange(request);
|
||||
}}
|
||||
/>
|
||||
<span className="hux-mode-card-copy">
|
||||
<strong>{mode.label}</strong>
|
||||
<span id={`hux-mode-help-${mode.mode}`}>{MODE_HELP[mode.mode]}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<details className="hux-modes-advanced">
|
||||
<summary>Advanced routing details</summary>
|
||||
<div className="hux-modes-advanced-panel">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Allowed pools</dt>
|
||||
<dd>
|
||||
{selected.constraints.providers
|
||||
.map((provider) => PROVIDER_LABELS[provider])
|
||||
.join(" + ")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Requested route</dt>
|
||||
<dd>{selected.switchyard.route_id}</dd>
|
||||
</div>
|
||||
{resolved?.target && (
|
||||
<div>
|
||||
<dt>Resolved route</dt>
|
||||
<dd>{resolved.target}</dd>
|
||||
</div>
|
||||
)}
|
||||
{resolved?.provider && (
|
||||
<div>
|
||||
<dt>Resolved provider</dt>
|
||||
<dd>{PROVIDER_LABELS[resolved.provider]}</dd>
|
||||
</div>
|
||||
)}
|
||||
{resolved?.effort && (
|
||||
<div>
|
||||
<dt>Resolved effort</dt>
|
||||
<dd>{resolved.effort}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<label className="hux-route-pin">
|
||||
<span>Exact route pin</span>
|
||||
<select
|
||||
disabled={disabled}
|
||||
defaultValue=""
|
||||
onChange={(event) => {
|
||||
const request = event.currentTarget.value
|
||||
? buildModeRequest(selected, event.currentTarget.value, choices)
|
||||
: buildModeRequest(selected);
|
||||
if (request) onChange(request);
|
||||
}}
|
||||
>
|
||||
<option value="">Automatic — use all allowed pools</option>
|
||||
{compatibleChoices.map((choice) => (
|
||||
<option key={choice.routeId} value={choice.routeId}>
|
||||
{choice.label} — {PROVIDER_LABELS[choice.provider]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="hux-route-pin-help">
|
||||
Pinning bypasses automatic selection for this choice. Automatic
|
||||
modes otherwise keep both OpenAI and Anthropic eligible.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
26
dockerfiles/hermes-webui-hux/modes/index.ts
Normal file
26
dockerfiles/hermes-webui-hux/modes/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/** Public integration surface for the inert HUX-06 frontend module. */
|
||||
|
||||
export { FriendlyModeSelector } from "./FriendlyModeSelector.tsx";
|
||||
export {
|
||||
FOUNDATION_FLAG,
|
||||
FRIENDLY_MODES_FLAG,
|
||||
MODE_ORDER,
|
||||
buildModeRequest,
|
||||
createFriendlyModesAdapter,
|
||||
friendlyModesEnabled,
|
||||
normalizeAdvancedRoutes,
|
||||
normalizeMode,
|
||||
normalizeModeCatalog,
|
||||
normalizeResolvedRoute,
|
||||
} from "./model.ts";
|
||||
export type {
|
||||
AdvancedRouteChoice,
|
||||
FriendlyModeIntent,
|
||||
FriendlyModeName,
|
||||
FriendlyModesAdapter,
|
||||
HuxFoundationClient,
|
||||
RawAdvancedRouteChoice,
|
||||
RawModeIntent,
|
||||
RawResolvedRoute,
|
||||
ResolvedRouteDisclosure,
|
||||
} from "./types.ts";
|
||||
312
dockerfiles/hermes-webui-hux/modes/model.ts
Normal file
312
dockerfiles/hermes-webui-hux/modes/model.ts
Normal file
@ -0,0 +1,312 @@
|
||||
/** Fail-closed HUX-06 intent validation and HUX foundation adapter. */
|
||||
|
||||
import type {
|
||||
AdvancedRouteChoice,
|
||||
FriendlyModeIntent,
|
||||
FriendlyModeName,
|
||||
FriendlyModesAdapter,
|
||||
HuxFoundationClient,
|
||||
ModeConstraints,
|
||||
ModeEffort,
|
||||
ModeProvider,
|
||||
RawAdvancedRouteChoice,
|
||||
RawModeIntent,
|
||||
RawResolvedRoute,
|
||||
ResolvedRouteDisclosure,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const FRIENDLY_MODES_FLAG = "hux.friendly_modes";
|
||||
export const MODE_ORDER: readonly FriendlyModeName[] = [
|
||||
"fast",
|
||||
"thoughtful",
|
||||
"research",
|
||||
"create",
|
||||
"private",
|
||||
];
|
||||
|
||||
const MODE_LABELS: Readonly<Record<FriendlyModeName, string>> = {
|
||||
fast: "Fast",
|
||||
thoughtful: "Thoughtful",
|
||||
research: "Research",
|
||||
create: "Create",
|
||||
private: "Private",
|
||||
};
|
||||
const PROVIDERS = new Set<ModeProvider>(["codex", "claude", "local"]);
|
||||
const EFFORTS: readonly ModeEffort[] = ["low", "medium", "high", "xhigh"];
|
||||
const ROUTE = /^atlas\/(auto|manual|fallback|worker)\/[a-z0-9/-]+$/;
|
||||
const MANUAL_ROUTE = /^atlas\/manual\/[a-z0-9/-]+$/;
|
||||
const REMOTE_PROVIDER_NAME = /\b(?:anthropic|claude|codex|openai)\b/i;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): boolean {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
return (
|
||||
required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
|
||||
Object.keys(value).every((key) => allowed.has(key))
|
||||
);
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, min: number, max: number): value is string {
|
||||
return typeof value === "string" && value.length >= min && value.length <= max;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: unknown, values: readonly T[]): value is T {
|
||||
return typeof value === "string" && values.includes(value as T);
|
||||
}
|
||||
|
||||
function normalizeConstraints(value: unknown): ModeConstraints | null {
|
||||
const constraints = record(value);
|
||||
const required = [
|
||||
"providers",
|
||||
"local_only",
|
||||
"effort",
|
||||
"tools",
|
||||
"memory",
|
||||
"citations_required",
|
||||
"retention",
|
||||
];
|
||||
if (!constraints || !exactKeys(constraints, required)) return null;
|
||||
if (!Array.isArray(constraints.providers) || constraints.providers.length < 1)
|
||||
return null;
|
||||
const providers = constraints.providers;
|
||||
if (
|
||||
providers.some((provider) => !PROVIDERS.has(provider as ModeProvider)) ||
|
||||
new Set(providers).size !== providers.length ||
|
||||
typeof constraints.local_only !== "boolean"
|
||||
)
|
||||
return null;
|
||||
|
||||
const effort = record(constraints.effort);
|
||||
const tools = record(constraints.tools);
|
||||
const memory = record(constraints.memory);
|
||||
if (
|
||||
!effort ||
|
||||
!exactKeys(effort, ["min", "max"]) ||
|
||||
!oneOf(effort.min, EFFORTS) ||
|
||||
!oneOf(effort.max, EFFORTS) ||
|
||||
EFFORTS.indexOf(effort.min) > EFFORTS.indexOf(effort.max)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
!tools ||
|
||||
!exactKeys(tools, ["web", "shell", "artifacts", "delegate"]) ||
|
||||
!oneOf(tools.web, ["required", "allowed", "denied"] as const) ||
|
||||
!oneOf(tools.shell, ["allowed", "denied"] as const) ||
|
||||
!oneOf(tools.artifacts, ["encouraged", "allowed", "denied"] as const) ||
|
||||
!oneOf(tools.delegate, ["allowed", "denied"] as const)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
!memory ||
|
||||
!exactKeys(memory, ["read", "write"]) ||
|
||||
typeof memory.read !== "boolean" ||
|
||||
typeof memory.write !== "boolean" ||
|
||||
typeof constraints.citations_required !== "boolean" ||
|
||||
!oneOf(constraints.retention, ["default", "ephemeral"] as const)
|
||||
)
|
||||
return null;
|
||||
|
||||
// Provider arrays are semantically sets. Canonical ordering prevents their
|
||||
// transport order from becoming an accidental routing preference.
|
||||
const canonicalProviders = (["codex", "claude", "local"] as const).filter(
|
||||
(provider) => providers.includes(provider),
|
||||
);
|
||||
return {
|
||||
providers: [...canonicalProviders],
|
||||
local_only: constraints.local_only,
|
||||
effort: { min: effort.min, max: effort.max },
|
||||
tools: {
|
||||
web: tools.web,
|
||||
shell: tools.shell,
|
||||
artifacts: tools.artifacts,
|
||||
delegate: tools.delegate,
|
||||
},
|
||||
memory: { read: memory.read, write: memory.write },
|
||||
citations_required: constraints.citations_required,
|
||||
retention: constraints.retention,
|
||||
};
|
||||
}
|
||||
|
||||
function modeSemanticsAreSafe(
|
||||
mode: FriendlyModeName,
|
||||
constraints: ModeConstraints,
|
||||
routeId: string,
|
||||
): boolean {
|
||||
const providers = new Set(constraints.providers);
|
||||
if (mode === "private") {
|
||||
return (
|
||||
constraints.local_only &&
|
||||
providers.size === 1 &&
|
||||
providers.has("local") &&
|
||||
routeId.startsWith("atlas/auto/")
|
||||
);
|
||||
}
|
||||
return (
|
||||
!constraints.local_only &&
|
||||
providers.has("codex") &&
|
||||
providers.has("claude") &&
|
||||
!REMOTE_PROVIDER_NAME.test(routeId.replaceAll("/", " ")) &&
|
||||
routeId.startsWith("atlas/auto/")
|
||||
);
|
||||
}
|
||||
|
||||
export function friendlyModesEnabled(flags?: Iterable<string>): boolean {
|
||||
if (!flags) return false;
|
||||
const enabled = new Set(flags);
|
||||
return enabled.has(FOUNDATION_FLAG) && enabled.has(FRIENDLY_MODES_FLAG);
|
||||
}
|
||||
|
||||
export function normalizeMode(raw: RawModeIntent): FriendlyModeIntent | null {
|
||||
const root = record(raw);
|
||||
if (
|
||||
!root ||
|
||||
!exactKeys(root, ["schema", "mode", "label", "intent", "constraints", "switchyard"]) ||
|
||||
root.schema !== "hux.mode.v1" ||
|
||||
!oneOf(root.mode, MODE_ORDER) ||
|
||||
root.label !== MODE_LABELS[root.mode] ||
|
||||
!boundedString(root.intent, 1, 400) ||
|
||||
REMOTE_PROVIDER_NAME.test(root.intent)
|
||||
)
|
||||
return null;
|
||||
const constraints = normalizeConstraints(root.constraints);
|
||||
const switchyard = record(root.switchyard);
|
||||
if (
|
||||
!constraints ||
|
||||
!switchyard ||
|
||||
!exactKeys(switchyard, ["route_id"]) ||
|
||||
typeof switchyard.route_id !== "string" ||
|
||||
!ROUTE.test(switchyard.route_id) ||
|
||||
!modeSemanticsAreSafe(root.mode, constraints, switchyard.route_id)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
schema: "hux.mode.v1",
|
||||
mode: root.mode,
|
||||
label: root.label,
|
||||
intent: root.intent,
|
||||
constraints,
|
||||
switchyard: { route_id: switchyard.route_id },
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeModeCatalog(raw: unknown): FriendlyModeIntent[] | null {
|
||||
if (!Array.isArray(raw) || raw.length !== MODE_ORDER.length) return null;
|
||||
const modes = raw.map((value) => normalizeMode(value as RawModeIntent));
|
||||
if (modes.some((mode) => mode === null)) return null;
|
||||
const byName = new Map(
|
||||
(modes as FriendlyModeIntent[]).map((mode) => [mode.mode, mode]),
|
||||
);
|
||||
if (byName.size !== MODE_ORDER.length) return null;
|
||||
return MODE_ORDER.map((name) => byName.get(name) as FriendlyModeIntent);
|
||||
}
|
||||
|
||||
export function normalizeAdvancedRoutes(raw: unknown): AdvancedRouteChoice[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const seen = new Set<string>();
|
||||
const choices: AdvancedRouteChoice[] = [];
|
||||
for (const value of raw.slice(0, 100)) {
|
||||
const choice = record(value as RawAdvancedRouteChoice);
|
||||
if (
|
||||
!choice ||
|
||||
!exactKeys(choice, ["route_id", "label", "provider", "effort", "local_only"]) ||
|
||||
typeof choice.route_id !== "string" ||
|
||||
!MANUAL_ROUTE.test(choice.route_id) ||
|
||||
seen.has(choice.route_id) ||
|
||||
!boundedString(choice.label, 1, 80) ||
|
||||
!PROVIDERS.has(choice.provider as ModeProvider) ||
|
||||
!oneOf(choice.effort, EFFORTS) ||
|
||||
typeof choice.local_only !== "boolean"
|
||||
)
|
||||
continue;
|
||||
seen.add(choice.route_id);
|
||||
choices.push({
|
||||
routeId: choice.route_id,
|
||||
label: choice.label,
|
||||
provider: choice.provider as ModeProvider,
|
||||
effort: choice.effort,
|
||||
localOnly: choice.local_only,
|
||||
});
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
export function buildModeRequest(
|
||||
mode: FriendlyModeIntent,
|
||||
advancedRouteId?: string,
|
||||
choices: readonly AdvancedRouteChoice[] = [],
|
||||
): FriendlyModeIntent | null {
|
||||
const normalized = normalizeMode(mode);
|
||||
if (!normalized) return null;
|
||||
if (advancedRouteId === undefined) return normalized;
|
||||
const choice = choices.find((item) => item.routeId === advancedRouteId);
|
||||
if (!choice || !MANUAL_ROUTE.test(advancedRouteId)) return null;
|
||||
const allowed = normalized.constraints.providers.includes(choice.provider);
|
||||
const privateSafe =
|
||||
normalized.mode !== "private" ||
|
||||
(choice.localOnly && choice.provider === "local");
|
||||
const automaticSafe =
|
||||
normalized.mode === "private" || (!choice.localOnly && allowed);
|
||||
if (!allowed || !privateSafe || !automaticSafe) return null;
|
||||
return {
|
||||
...normalized,
|
||||
switchyard: {
|
||||
route_id: normalized.switchyard.route_id,
|
||||
override_route_id: advancedRouteId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResolvedRoute(raw: unknown): ResolvedRouteDisclosure | null {
|
||||
const route = record(raw as RawResolvedRoute);
|
||||
if (!route || !exactKeys(route, ["requested"], ["resolved_target", "provider", "effort"]))
|
||||
return null;
|
||||
if (!boundedString(route.requested, 1, 120)) return null;
|
||||
if (
|
||||
route.resolved_target !== undefined &&
|
||||
!boundedString(route.resolved_target, 1, 120)
|
||||
)
|
||||
return null;
|
||||
if (route.provider !== undefined && !PROVIDERS.has(route.provider as ModeProvider))
|
||||
return null;
|
||||
if (route.effort !== undefined && !oneOf(route.effort, EFFORTS)) return null;
|
||||
return {
|
||||
requested: route.requested,
|
||||
target: route.resolved_target as string | undefined,
|
||||
provider: route.provider as ModeProvider | undefined,
|
||||
effort: route.effort as ModeEffort | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFriendlyModesAdapter(
|
||||
foundation: HuxFoundationClient,
|
||||
): FriendlyModesAdapter {
|
||||
if (
|
||||
!foundation ||
|
||||
foundation.apiVersion !== "hux.v1" ||
|
||||
typeof foundation.enabled !== "function" ||
|
||||
typeof foundation.endpoint !== "function"
|
||||
)
|
||||
throw new TypeError("A hux.v1 foundation client is required");
|
||||
return Object.freeze({
|
||||
apiVersion: "hux.v1" as const,
|
||||
enabled: () =>
|
||||
foundation.enabled(FOUNDATION_FLAG) && foundation.enabled(FRIENDLY_MODES_FLAG),
|
||||
endpoint: () => foundation.endpoint("/modes"),
|
||||
adaptCatalog: normalizeModeCatalog,
|
||||
adaptResolvedRoute: normalizeResolvedRoute,
|
||||
buildRequest: (
|
||||
mode: FriendlyModeIntent,
|
||||
advancedRouteId?: string,
|
||||
choices?: readonly AdvancedRouteChoice[],
|
||||
) => buildModeRequest(mode, advancedRouteId, choices),
|
||||
});
|
||||
}
|
||||
178
dockerfiles/hermes-webui-hux/modes/styles.css
Normal file
178
dockerfiles/hermes-webui-hux/modes/styles.css
Normal file
@ -0,0 +1,178 @@
|
||||
/* Scoped, inert-until-imported HUX-06 presentation. */
|
||||
.hux-modes {
|
||||
color: inherit;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.hux-modes-heading {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-modes-heading h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-modes-heading p,
|
||||
.hux-route-pin-help {
|
||||
color: color-mix(in srgb, currentColor 58%, transparent);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.45;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.hux-modes-active {
|
||||
background: color-mix(in srgb, #22d3ee 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #22d3ee 34%, transparent);
|
||||
border-radius: 999px;
|
||||
color: color-mix(in srgb, currentColor 90%, #22d3ee);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 650;
|
||||
padding: 0.25rem 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hux-mode-grid {
|
||||
border: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hux-mode-card {
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hux-mode-card input {
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.hux-mode-card-copy {
|
||||
background: color-mix(in srgb, currentColor 3%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
border-radius: 0.7rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-height: 4.35rem;
|
||||
padding: 0.6rem;
|
||||
transition: border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.hux-mode-card-copy strong {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.hux-mode-card-copy > span {
|
||||
color: color-mix(in srgb, currentColor 60%, transparent);
|
||||
font-size: 0.625rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.hux-mode-card input:checked + .hux-mode-card-copy {
|
||||
background: color-mix(in srgb, #22d3ee 8%, transparent);
|
||||
border-color: color-mix(in srgb, #22d3ee 62%, currentColor);
|
||||
}
|
||||
|
||||
.hux-mode-card input:focus-visible + .hux-mode-card-copy,
|
||||
.hux-route-pin select:focus-visible,
|
||||
.hux-modes-advanced summary:focus-visible {
|
||||
outline: 2px solid #22d3ee;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hux-mode-grid:disabled .hux-mode-card {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.hux-modes-advanced {
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 11%, transparent);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.hux-modes-advanced summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hux-modes-advanced-panel {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.hux-modes-advanced dl {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-modes-advanced dl div {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: minmax(7rem, 0.35fr) 1fr;
|
||||
}
|
||||
|
||||
.hux-modes-advanced dt {
|
||||
color: color-mix(in srgb, currentColor 56%, transparent);
|
||||
}
|
||||
|
||||
.hux-modes-advanced dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hux-route-pin {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.hux-route-pin span {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.hux-route-pin select {
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
max-width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.hux-modes-error {
|
||||
border: 1px solid color-mix(in srgb, #fb7185 50%, transparent);
|
||||
border-radius: 0.65rem;
|
||||
color: color-mix(in srgb, currentColor 88%, #fb7185);
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
@container (max-width: 42rem) {
|
||||
.hux-mode-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-mode-card-copy {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
96
dockerfiles/hermes-webui-hux/modes/types.ts
Normal file
96
dockerfiles/hermes-webui-hux/modes/types.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/** Safe frontend types for the opt-in HUX-06 friendly mode selector. */
|
||||
|
||||
export type FriendlyModeName =
|
||||
| "fast"
|
||||
| "thoughtful"
|
||||
| "research"
|
||||
| "create"
|
||||
| "private";
|
||||
|
||||
export type ModeProvider = "codex" | "claude" | "local";
|
||||
export type ModeEffort = "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export interface RawModeIntent {
|
||||
schema?: unknown;
|
||||
mode?: unknown;
|
||||
label?: unknown;
|
||||
intent?: unknown;
|
||||
constraints?: unknown;
|
||||
switchyard?: unknown;
|
||||
}
|
||||
|
||||
export interface ModeConstraints {
|
||||
providers: ModeProvider[];
|
||||
local_only: boolean;
|
||||
effort: { min: ModeEffort; max: ModeEffort };
|
||||
tools: {
|
||||
web: "required" | "allowed" | "denied";
|
||||
shell: "allowed" | "denied";
|
||||
artifacts: "encouraged" | "allowed" | "denied";
|
||||
delegate: "allowed" | "denied";
|
||||
};
|
||||
memory: { read: boolean; write: boolean };
|
||||
citations_required: boolean;
|
||||
retention: "default" | "ephemeral";
|
||||
}
|
||||
|
||||
export interface FriendlyModeIntent {
|
||||
schema: "hux.mode.v1";
|
||||
mode: FriendlyModeName;
|
||||
label: string;
|
||||
intent: string;
|
||||
constraints: ModeConstraints;
|
||||
switchyard: {
|
||||
route_id: string;
|
||||
override_route_id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RawResolvedRoute {
|
||||
requested?: unknown;
|
||||
resolved_target?: unknown;
|
||||
provider?: unknown;
|
||||
effort?: unknown;
|
||||
}
|
||||
|
||||
export interface ResolvedRouteDisclosure {
|
||||
requested: string;
|
||||
target?: string;
|
||||
provider?: ModeProvider;
|
||||
effort?: ModeEffort;
|
||||
}
|
||||
|
||||
export interface RawAdvancedRouteChoice {
|
||||
route_id?: unknown;
|
||||
label?: unknown;
|
||||
provider?: unknown;
|
||||
effort?: unknown;
|
||||
local_only?: unknown;
|
||||
}
|
||||
|
||||
export interface AdvancedRouteChoice {
|
||||
routeId: string;
|
||||
label: string;
|
||||
provider: ModeProvider;
|
||||
effort: ModeEffort;
|
||||
localOnly: boolean;
|
||||
}
|
||||
|
||||
export interface HuxFoundationClient {
|
||||
apiVersion: string;
|
||||
enabled(flag: string): boolean;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
export interface FriendlyModesAdapter {
|
||||
apiVersion: "hux.v1";
|
||||
enabled(): boolean;
|
||||
endpoint(): string;
|
||||
adaptCatalog(raw: unknown): FriendlyModeIntent[] | null;
|
||||
adaptResolvedRoute(raw: unknown): ResolvedRouteDisclosure | null;
|
||||
buildRequest(
|
||||
mode: FriendlyModeIntent,
|
||||
advancedRouteId?: string,
|
||||
choices?: readonly AdvancedRouteChoice[],
|
||||
): FriendlyModeIntent | null;
|
||||
}
|
||||
166
dockerfiles/hermes-webui-hux/multimodal/MultimodalChat.tsx
Normal file
166
dockerfiles/hermes-webui-hux/multimodal/MultimodalChat.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
/** Accessible multimodal UI. It emits intents but never fetches, uploads, or opens devices. */
|
||||
|
||||
import { Camera, FileText, Image as ImageIcon, Mic2, MonitorUp, Pencil, Upload } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
|
||||
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
|
||||
} from "./model.ts";
|
||||
import { validateUpload } from "./security.ts";
|
||||
import type { FileCandidate, MediaView, MultimodalProps } from "./types.ts";
|
||||
|
||||
function MediaPreview({item}: {item: MediaView}) {
|
||||
if (item.kind === "image" && item.previewUrl) return <figure className="hux-mm-image">
|
||||
<img src={item.previewUrl} alt={item.altText} />
|
||||
<figcaption>{item.fileName} · version {item.artifactVersion}</figcaption>
|
||||
</figure>;
|
||||
if (item.kind === "document" && item.previewText) return <div className="hux-mm-document">
|
||||
<strong>{item.fileName}</strong>
|
||||
<pre tabIndex={0}><code>{item.previewText}</code></pre>
|
||||
<small>Safe text preview; active document content is not executed.</small>
|
||||
</div>;
|
||||
const Icon = item.kind === "image" ? ImageIcon : item.kind === "audio" ? Mic2 : FileText;
|
||||
return <div className="hux-mm-safe-preview" role="img" aria-label={`${item.kind} preview unavailable`}>
|
||||
<Icon aria-hidden />
|
||||
<strong>{item.fileName}</strong>
|
||||
<span>{item.mime} · {Math.ceil(item.bytes / 1024)} KB</span>
|
||||
<small>Content is not executed. Open an authorized artifact preview to inspect it.</small>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UploadPanel({props, authorization, setNotice}: {
|
||||
props: MultimodalProps;
|
||||
authorization: MultimodalProps["page"]["upload_authorization"];
|
||||
setNotice: (message: string) => void;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const authorized = authorization === "authorized";
|
||||
|
||||
function handle(files: FileList | readonly File[]) {
|
||||
const accepted: FileCandidate[] = [];
|
||||
const rejected: string[] = [];
|
||||
for (const file of Array.from(files).slice(0, 20)) {
|
||||
const result = validateUpload(file, String(authorization));
|
||||
if (result.ok) accepted.push(result.file);
|
||||
else rejected.push(`${file.name || "File"}: ${result.reason}`);
|
||||
}
|
||||
if (accepted.length) props.onUpload?.(accepted);
|
||||
setNotice(rejected.length ? rejected.join(" ") : `${accepted.length} file${accepted.length === 1 ? "" : "s"} ready to upload.`);
|
||||
}
|
||||
|
||||
return <section className="hux-mm-upload" aria-labelledby="hux-mm-upload-title"
|
||||
onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
|
||||
event.preventDefault(); handle(event.dataTransfer.files);
|
||||
}}>
|
||||
<Upload aria-hidden />
|
||||
<h3 id="hux-mm-upload-title">Add files or images</h3>
|
||||
<p>Drop authorized PNG, JPEG, WebP, PDF, text, Markdown, or audio files here.</p>
|
||||
<input ref={input} className="sr-only" type="file" multiple
|
||||
accept="image/png,image/jpeg,image/webp,application/pdf,text/plain,text/markdown,audio/webm,audio/wav,audio/mpeg,audio/ogg"
|
||||
onChange={(event) => event.currentTarget.files && handle(event.currentTarget.files)} />
|
||||
{authorized ? <button type="button" disabled={props.busy} onClick={() => input.current?.click()}>
|
||||
Choose files
|
||||
</button> : <button type="button" disabled={props.busy || authorization === "denied"}
|
||||
onClick={() => props.onRequestAuthorization?.("upload")}>
|
||||
{authorization === "denied" ? "Uploads denied" : "Ask before uploading"}
|
||||
</button>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function MediaEditor({item, props}: {item: MediaView; props: MultimodalProps}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [page, setPage] = useState("");
|
||||
if (item.kind === "audio") return null;
|
||||
const image = item.kind === "image";
|
||||
return <form className="hux-mm-editor" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (image) props.onImageEdit?.(buildImageEdit(item, props.scope, draft));
|
||||
else props.onAnnotate?.(buildAnnotation(item, props.scope, draft, page ? Number(page) : null));
|
||||
setDraft(""); setPage("");
|
||||
}}>
|
||||
<label htmlFor={`hux-mm-edit-${item.id}`}>{image ? "Describe an image edit" : "Add a document annotation"}</label>
|
||||
<textarea id={`hux-mm-edit-${item.id}`} value={draft} maxLength={image ? 2000 : 1000}
|
||||
onChange={(event) => setDraft(event.currentTarget.value)} required />
|
||||
{!image && <label>Page (optional)<input type="number" min="1" max="100000" value={page}
|
||||
onChange={(event) => setPage(event.currentTarget.value)} /></label>}
|
||||
<button type="submit" disabled={props.busy || !draft.trim()}>
|
||||
<Pencil aria-hidden /> {image ? "Create linked variant" : "Save annotation intent"}
|
||||
</button>
|
||||
{image && <small>The next version retains artifact and variant lineage.</small>}
|
||||
</form>;
|
||||
}
|
||||
|
||||
function TranscriptEditor({props, page}: {props: MultimodalProps; page: ReturnType<typeof normalizeMultimodalPage>}) {
|
||||
const transcript = page?.transcript;
|
||||
const [text, setText] = useState(transcript?.text || "");
|
||||
if (!transcript) return <p>No voice transcript is attached to this turn.</p>;
|
||||
return <form className="hux-mm-transcript" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
props.onCorrectTranscript?.(buildTranscriptCorrection(transcript, text, props.identity, props.scope));
|
||||
}}>
|
||||
<label htmlFor="hux-mm-transcript-text">Voice transcript · {transcript.language}</label>
|
||||
<textarea id="hux-mm-transcript-text" value={text} maxLength={20000}
|
||||
readOnly={!transcript.finalized} onChange={(event) => setText(event.currentTarget.value)} />
|
||||
<button type="submit" disabled={props.busy || !transcript.finalized || !text.trim() || text === transcript.text}>
|
||||
Correct transcript
|
||||
</button>
|
||||
{!transcript.finalized && <small>Correction unlocks when transcription is finalized.</small>}
|
||||
</form>;
|
||||
}
|
||||
|
||||
function CaptureButton({kind, props}: {kind: "camera" | "screen"; props: MultimodalProps}) {
|
||||
const decision = captureAuthorization(props.autonomyPolicy, null, props.identity);
|
||||
const Icon = kind === "camera" ? Camera : MonitorUp;
|
||||
return <button type="button" disabled={props.busy || decision === "deny"} onClick={() => {
|
||||
props.onCapture?.(buildCaptureIntent(kind, props.autonomyPolicy, props.identity, props.scope));
|
||||
}}>
|
||||
<Icon aria-hidden /> {decision === "deny" ? `${kind} denied` : `Ask to use ${kind}`}
|
||||
</button>;
|
||||
}
|
||||
|
||||
/** Render nothing unless foundation, projects, artifacts, autonomy, and multimodal are active. */
|
||||
export function MultimodalChat(props: MultimodalProps) {
|
||||
const enabled = multimodalEnabled(props.flags);
|
||||
const page = useMemo(() => enabled ? normalizeMultimodalPage(props.page, props.identity, props.scope) : null,
|
||||
[enabled, props.identity, props.page, props.scope]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
if (!enabled || !page) return null;
|
||||
const selected = page.media.find((item) => item.id === selectedId) || page.media[0] || null;
|
||||
|
||||
return <section className="hux-multimodal" aria-label="Multimodal chat" aria-busy={props.busy}>
|
||||
<header><div><span className="hux-mm-kicker">HUX-07</span><h2>Multimodal workspace</h2>
|
||||
<p>Files, voice, and generated variants remain bound to this conversation.</p></div>
|
||||
<div className="hux-mm-capture" aria-label="Live media permissions">
|
||||
<CaptureButton kind="camera" props={props} /><CaptureButton kind="screen" props={props} />
|
||||
</div>
|
||||
</header>
|
||||
{props.error && <p className="hux-mm-error" role="alert">{props.error}</p>}
|
||||
{page.rejected > 0 && <p className="hux-mm-warning" role="status">
|
||||
Some media was withheld because its owner, binding, type, or lineage was invalid.
|
||||
</p>}
|
||||
<UploadPanel props={props} authorization={page.uploadAuthorization} setNotice={setNotice} />
|
||||
<div className="hux-mm-workspace">
|
||||
<nav aria-label="Conversation media"><ul>{page.media.map((item) => <li key={item.id}>
|
||||
<button type="button" aria-current={selected?.id === item.id ? "page" : undefined}
|
||||
onClick={() => setSelectedId(item.id)}>{item.kind === "image" ? <ImageIcon aria-hidden /> :
|
||||
<FileText aria-hidden />}<span>{item.fileName}</span></button>
|
||||
</li>)}</ul>{!page.media.length && <p>No media is attached.</p>}</nav>
|
||||
<main>{selected ? <><MediaPreview item={selected} /><MediaEditor key={selected.id} item={selected} props={props} /></> :
|
||||
<p>Select media to preview or annotate it.</p>}</main>
|
||||
<aside aria-label="Generation history"><h3>Variant history</h3>
|
||||
{page.threads.map((thread) => <section key={thread.id}><h4>{thread.prompt}</h4><ol>
|
||||
{thread.variants.map((variant, index) => <li key={variant.id}>
|
||||
Variant {index + 1} · artifact v{variant.artifactVersion}
|
||||
{variant.parentVariantId && <small>Linked to an earlier variant</small>}
|
||||
</li>)}</ol></section>)}
|
||||
{!page.threads.length && <p>No generated variants yet.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
<TranscriptEditor key={`${page.transcript?.id || "none"}:${page.transcript?.revision || 0}`}
|
||||
props={props} page={page} />
|
||||
<p className="sr-only" role="status" aria-live="polite">{notice ||
|
||||
`${page.media.length} media items and ${page.threads.length} generation threads available`}</p>
|
||||
</section>;
|
||||
}
|
||||
61
dockerfiles/hermes-webui-hux/multimodal/endpoints.ts
Normal file
61
dockerfiles/hermes-webui-hux/multimodal/endpoints.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/** Same-origin endpoint descriptors for the future HUX-07 backend. */
|
||||
|
||||
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
|
||||
import { multimodalEnabled } from "./model.ts";
|
||||
import { normalizeIdentity, normalizeScope, safeEndpointPart, sameIdentity, sameScope } from "./security.ts";
|
||||
import type {
|
||||
CaptureKind, MultimodalEndpoint, MultimodalEndpointContract,
|
||||
} from "./types.ts";
|
||||
|
||||
interface FoundationClient {
|
||||
apiVersion: string;
|
||||
identity: HuxIdentity;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
function endpoint(method: MultimodalEndpoint["method"], path: string,
|
||||
responseSchema: string, requiredBody?: readonly string[]): MultimodalEndpoint {
|
||||
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
|
||||
}
|
||||
|
||||
/** Describe endpoints only. The caller owns authenticated transport and approval execution. */
|
||||
export function createMultimodalEndpointContract(client: FoundationClient, scope: ArtifactScope,
|
||||
flags?: Iterable<string>): MultimodalEndpointContract | null {
|
||||
if (!multimodalEnabled(flags)) return null;
|
||||
if (client.apiVersion !== "hux.v1" || !sameIdentity(normalizeIdentity(client.identity), client.identity) ||
|
||||
!sameScope(normalizeScope(scope), scope)) throw new TypeError("Scoped HUX v1 client required");
|
||||
const base = client.endpoint(`/projects/${safeEndpointPart(scope.projectId, "prj")}` +
|
||||
`/conversations/${safeEndpointPart(scope.conversationId, "conv")}/multimodal`);
|
||||
const child = (root: string, id: unknown, prefix: string, suffix = "") =>
|
||||
`${base}/${root}/${safeEndpointPart(id, prefix)}${suffix}`;
|
||||
return Object.freeze({
|
||||
page: endpoint("GET", base, "hux.multimodal_page.v1"),
|
||||
beginUpload: endpoint("POST", `${base}/uploads`, "hux.media_upload.v1",
|
||||
["file_name", "mime", "bytes", "authorization_id"]),
|
||||
finalizeUpload(uploadId) {
|
||||
return endpoint("POST", child("uploads", uploadId, "upl", "/finalize"), "hux.media.v1",
|
||||
["content_ref", "expected_upload_id"]);
|
||||
},
|
||||
editImage(mediaId) {
|
||||
return endpoint("POST", child("media", mediaId, "med", "/edits"), "hux.media.v1",
|
||||
["artifact_id", "expected_current_version", "instruction", "lineage"]);
|
||||
},
|
||||
annotate(mediaId) {
|
||||
return endpoint("POST", child("media", mediaId, "med", "/annotations"), "hux.media.v1",
|
||||
["artifact_id", "expected_current_version", "page", "note"]);
|
||||
},
|
||||
correctTranscript(transcriptId) {
|
||||
return endpoint("POST", child("transcripts", transcriptId, "trn", "/corrections"),
|
||||
"hux.voice_transcript.v1", ["turn_id", "expected_revision", "corrected_text"]);
|
||||
},
|
||||
requestCapture(kind: CaptureKind) {
|
||||
if (!['camera', 'screen'].includes(kind)) throw new TypeError("Capture kind is invalid");
|
||||
return endpoint("POST", `${base}/captures/${kind}/approvals`, "hux.approval.v1",
|
||||
["capability", "required_decision", "conversation_id"]);
|
||||
},
|
||||
createVariant(threadId) {
|
||||
return endpoint("POST", child("threads", threadId, "thr", "/variants"),
|
||||
"hux.generation_thread.v1", ["media_id", "artifact_id", "artifact_version", "parent_variant_id"]);
|
||||
},
|
||||
});
|
||||
}
|
||||
8
dockerfiles/hermes-webui-hux/multimodal/index.ts
Normal file
8
dockerfiles/hermes-webui-hux/multimodal/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { MultimodalChat } from "./MultimodalChat.tsx";
|
||||
export { createMultimodalEndpointContract } from "./endpoints.ts";
|
||||
export {
|
||||
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
|
||||
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
|
||||
} from "./model.ts";
|
||||
export { validateUpload } from "./security.ts";
|
||||
export type * from "./types.ts";
|
||||
192
dockerfiles/hermes-webui-hux/multimodal/model.ts
Normal file
192
dockerfiles/hermes-webui-hux/multimodal/model.ts
Normal file
@ -0,0 +1,192 @@
|
||||
/** Fail-closed multimodal normalization and intent builders for HUX-07. */
|
||||
|
||||
import { actionAuthorization } from "../autonomy/model.ts";
|
||||
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
|
||||
import type { ApprovalView, PolicyView } from "../autonomy/types.ts";
|
||||
import {
|
||||
isOpaqueId, isUtc, normalizeIdentity, normalizeScope, safeBlobUrl, safePreviewText, safeText,
|
||||
sameIdentity, sameScope,
|
||||
} from "./security.ts";
|
||||
import { validateUpload } from "./security.ts";
|
||||
import type {
|
||||
AnnotationIntent, CaptureIntent, CaptureKind, GenerationThread, GenerationVariant,
|
||||
ImageEditIntent, MediaKind, MediaView, MultimodalPage, RawMediaEnvelope,
|
||||
TranscriptCorrectionIntent, TranscriptView,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const PROJECTS_FLAG = "hux.projects";
|
||||
export const ARTIFACTS_FLAG = "hux.artifacts";
|
||||
export const AUTONOMY_FLAG = "hux.autonomy";
|
||||
export const MULTIMODAL_FLAG = "hux.multimodal";
|
||||
export const REQUIRED_FLAGS = Object.freeze([
|
||||
FOUNDATION_FLAG, PROJECTS_FLAG, ARTIFACTS_FLAG, AUTONOMY_FLAG, MULTIMODAL_FLAG,
|
||||
]);
|
||||
|
||||
export function multimodalEnabled(flags?: Iterable<string>): boolean {
|
||||
const active = new Set(flags || []);
|
||||
return REQUIRED_FLAGS.every((flag) => active.has(flag));
|
||||
}
|
||||
|
||||
function normalizeAnnotation(raw: unknown): MediaView["annotations"][number] | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const page = value.page === null ? null : value.page;
|
||||
const note = safeText(value.note, "", 1_000);
|
||||
return isOpaqueId(value.id, "ann") && (page === null ||
|
||||
(Number.isSafeInteger(page) && (page as number) > 0 && (page as number) <= 100_000)) && note ?
|
||||
{id: value.id, page: page as number | null, note} : null;
|
||||
}
|
||||
|
||||
function normalizeMedia(raw: unknown, owner: string, scope: ArtifactScope): MediaView | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const kind = value.kind as MediaKind;
|
||||
const fileName = safeText(value.file_name, "", 180);
|
||||
const altText = safeText(value.alt_text, "Preview", 500);
|
||||
const upload = validateUpload({name: fileName, type: String(value.mime), size: Number(value.bytes)}, "authorized");
|
||||
if (value.schema !== "hux.media.v1" || !isOpaqueId(value.id, "med") || value.owner !== owner ||
|
||||
value.project_id !== scope.projectId || value.conversation_id !== scope.conversationId ||
|
||||
!isOpaqueId(value.artifact_id, "art") || !Number.isSafeInteger(value.artifact_version) ||
|
||||
(value.artifact_version as number) < 1 || !["image", "document", "audio"].includes(kind) ||
|
||||
!fileName || !Number.isSafeInteger(value.bytes) || !upload.ok || upload.kind !== kind ||
|
||||
typeof value.mime !== "string" || !Array.isArray(value.annotations) || value.annotations.length > 500) return null;
|
||||
const annotations = value.annotations.map(normalizeAnnotation);
|
||||
if (annotations.some((item) => item === null)) return null;
|
||||
const previewUrl = value.preview_url === null || value.preview_url === undefined ? null : safeBlobUrl(value.preview_url);
|
||||
const previewText = value.preview_text === null || value.preview_text === undefined ? null : safePreviewText(value.preview_text);
|
||||
if ((value.preview_url && (!previewUrl || value.preview_authorized !== true)) ||
|
||||
(value.preview_text !== null && value.preview_text !== undefined && (!previewText || kind !== "document")) ||
|
||||
(previewUrl && kind !== "image") || (previewText && value.preview_authorized !== true)) return null;
|
||||
let lineage: MediaView["lineage"] = null;
|
||||
if (value.lineage !== null && value.lineage !== undefined) {
|
||||
const parent = value.lineage as Record<string, unknown> | null;
|
||||
if (!parent || !isOpaqueId(parent.media_id, "med") || !isOpaqueId(parent.artifact_id, "art") ||
|
||||
!Number.isSafeInteger(parent.artifact_version) || (parent.artifact_version as number) < 1 ||
|
||||
parent.media_id === value.id) return null;
|
||||
lineage = {mediaId: parent.media_id, artifactId: parent.artifact_id,
|
||||
artifactVersion: parent.artifact_version as number};
|
||||
}
|
||||
return {id: value.id, owner, artifactId: value.artifact_id,
|
||||
artifactVersion: value.artifact_version as number, kind, fileName, mime: value.mime,
|
||||
bytes: value.bytes as number, previewUrl, previewText, altText,
|
||||
annotations: annotations as MediaView["annotations"], lineage};
|
||||
}
|
||||
|
||||
function normalizeVariant(raw: unknown, allowed: ReadonlyMap<string, MediaView>): GenerationVariant | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const media = allowed.get(String(value.media_id));
|
||||
const parent = value.parent_variant_id;
|
||||
if (!isOpaqueId(value.id, "var") || !media || value.artifact_id !== media.artifactId ||
|
||||
value.artifact_version !== media.artifactVersion ||
|
||||
!(parent === null || isOpaqueId(parent, "var")) || !isUtc(value.created_at)) return null;
|
||||
return {id: value.id, mediaId: media.id, artifactId: media.artifactId,
|
||||
artifactVersion: media.artifactVersion, parentVariantId: parent as string | null,
|
||||
createdAt: value.created_at};
|
||||
}
|
||||
|
||||
function normalizeThread(raw: unknown, media: ReadonlyMap<string, MediaView>, owner: string,
|
||||
scope: ArtifactScope): GenerationThread | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const prompt = safeText(value.prompt, "", 2_000);
|
||||
if (!isOpaqueId(value.id, "thr") || value.owner !== owner || value.project_id !== scope.projectId ||
|
||||
value.conversation_id !== scope.conversationId || !prompt || !Array.isArray(value.variants) ||
|
||||
value.variants.length < 1 || value.variants.length > 200) return null;
|
||||
const variants = value.variants.map((item) => normalizeVariant(item, media));
|
||||
if (variants.some((item) => item === null)) return null;
|
||||
const ordered = variants as GenerationVariant[];
|
||||
const ids = new Set<string>();
|
||||
for (const variant of ordered) {
|
||||
if (ids.has(variant.id) || (variant.parentVariantId && !ids.has(variant.parentVariantId))) return null;
|
||||
ids.add(variant.id);
|
||||
}
|
||||
return {id: value.id, prompt, variants: ordered};
|
||||
}
|
||||
|
||||
function normalizeTranscript(raw: unknown, owner: string, scope: ArtifactScope): TranscriptView | null {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const text = safeText(value.text, "", 20_000);
|
||||
const language = safeText(value.language, "", 40);
|
||||
return value.schema === "hux.voice_transcript.v1" && value.owner === owner &&
|
||||
value.project_id === scope.projectId && value.conversation_id === scope.conversationId &&
|
||||
isOpaqueId(value.id, "trn") &&
|
||||
isOpaqueId(value.turn_id, "turn") && Number.isSafeInteger(value.revision) &&
|
||||
(value.revision as number) >= 1 && Boolean(language) && Boolean(text) &&
|
||||
typeof value.finalized === "boolean" ? {id: value.id, turnId: value.turn_id,
|
||||
revision: value.revision as number, language, text, finalized: value.finalized} : null;
|
||||
}
|
||||
|
||||
export function normalizeMultimodalPage(raw: RawMediaEnvelope, expected: HuxIdentity,
|
||||
scope: ArtifactScope): MultimodalPage | null {
|
||||
if (!raw || typeof raw !== "object" || raw.schema !== "hux.multimodal_page.v1" ||
|
||||
raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), expected) ||
|
||||
!sameScope(normalizeScope(raw.binding), scope) || !Array.isArray(raw.media) ||
|
||||
raw.media.length > 200 || !Array.isArray(raw.threads) || raw.threads.length > 100) return null;
|
||||
if (!["authorized", "requires_approval", "denied"].includes(String(
|
||||
(raw as Record<string, unknown>).upload_authorization))) return null;
|
||||
const normalized = raw.media.map((item) => normalizeMedia(item, expected.userRef, scope));
|
||||
const ids = new Set<string>();
|
||||
const unique = normalized.filter((item): item is MediaView => Boolean(item && !ids.has(item.id) && ids.add(item.id)));
|
||||
const allMedia = new Map(unique.map((item) => [item.id, item]));
|
||||
const media = unique.filter((item) => !item.lineage || (() => {
|
||||
const parent = allMedia.get(item.lineage!.mediaId);
|
||||
return Boolean(parent && parent.artifactId === item.lineage!.artifactId &&
|
||||
parent.artifactVersion === item.lineage!.artifactVersion);
|
||||
})());
|
||||
const mediaMap = new Map(media.map((item) => [item.id, item]));
|
||||
const threads = raw.threads.map((item) => normalizeThread(item, mediaMap, expected.userRef, scope));
|
||||
const transcript = normalizeTranscript(raw.transcript, expected.userRef, scope);
|
||||
return {media, threads: threads.filter((item): item is GenerationThread => item !== null), transcript,
|
||||
uploadAuthorization: (raw as Record<string, unknown>).upload_authorization as MultimodalPage["uploadAuthorization"],
|
||||
rejected: normalized.length - media.length + threads.filter((item) => item === null).length +
|
||||
(raw.transcript && !transcript ? 1 : 0)};
|
||||
}
|
||||
|
||||
function baseIntent(media: MediaView, scope: ArtifactScope) {
|
||||
return {artifactId: media.artifactId, expectedVersion: media.artifactVersion, scope: {...scope}};
|
||||
}
|
||||
|
||||
export function buildImageEdit(media: MediaView, scope: ArtifactScope,
|
||||
instruction: string): ImageEditIntent {
|
||||
const clean = safeText(instruction, "", 2_000);
|
||||
if (media.kind !== "image" || !clean) throw new TypeError("A bounded image edit instruction is required");
|
||||
return {...baseIntent(media, scope), mediaId: media.id, instruction: clean,
|
||||
lineage: {artifactId: media.artifactId, version: media.artifactVersion}};
|
||||
}
|
||||
|
||||
export function buildAnnotation(media: MediaView, scope: ArtifactScope,
|
||||
note: string, page: number | null): AnnotationIntent {
|
||||
const clean = safeText(note, "", 1_000);
|
||||
if (media.kind !== "document" || !clean || !(page === null ||
|
||||
(Number.isSafeInteger(page) && page > 0 && page <= 100_000))) {
|
||||
throw new TypeError("A bounded document annotation is required");
|
||||
}
|
||||
return {...baseIntent(media, scope), mediaId: media.id, page, note: clean};
|
||||
}
|
||||
|
||||
export function buildTranscriptCorrection(transcript: TranscriptView, correctedText: string,
|
||||
identity: HuxIdentity, scope: ArtifactScope): TranscriptCorrectionIntent {
|
||||
const clean = safeText(correctedText, "", 20_000);
|
||||
if (!transcript.finalized || !clean) throw new TypeError("Only a finalized transcript can be corrected");
|
||||
return {transcriptId: transcript.id, turnId: transcript.turnId,
|
||||
expectedRevision: transcript.revision, correctedText: clean, identity: {...identity}, scope: {...scope}};
|
||||
}
|
||||
|
||||
/** Camera and screen input always require a fresh Ask First decision. */
|
||||
export function captureAuthorization(policy: PolicyView | null, approval: ApprovalView | null,
|
||||
identity: HuxIdentity): "approval_required" | "deny" {
|
||||
return actionAuthorization(policy, "network", approval, identity) === "deny" ? "deny" : "approval_required";
|
||||
}
|
||||
|
||||
export function buildCaptureIntent(kind: CaptureKind, policy: PolicyView | null,
|
||||
identity: HuxIdentity, scope: ArtifactScope): CaptureIntent {
|
||||
if (!['camera', 'screen'].includes(kind) || captureAuthorization(policy, null, identity) === "deny") {
|
||||
throw new TypeError("Capture is denied by the autonomy policy");
|
||||
}
|
||||
return {kind, autonomyCapability: "network", requiredDecision: "ask",
|
||||
identity: {...identity}, scope: {...scope}};
|
||||
}
|
||||
99
dockerfiles/hermes-webui-hux/multimodal/security.ts
Normal file
99
dockerfiles/hermes-webui-hux/multimodal/security.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/** Identity, binding, media, and display guards for HUX-07. */
|
||||
|
||||
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
|
||||
import type { FileCandidate, MediaKind, UploadValidation } from "./types.ts";
|
||||
|
||||
const ID = /^[a-z]{2,8}_[A-Za-z0-9._-]{4,80}$/;
|
||||
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
|
||||
const USER = /^usr_[0-9a-f]{16,64}$/;
|
||||
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/g;
|
||||
const SECRET = /\b(password|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
|
||||
const MIME_KIND = new Map<string, MediaKind>([
|
||||
["image/png", "image"], ["image/jpeg", "image"], ["image/webp", "image"],
|
||||
["application/pdf", "document"], ["text/plain", "document"],
|
||||
["text/markdown", "document"], ["audio/webm", "audio"], ["audio/wav", "audio"],
|
||||
["audio/mpeg", "audio"], ["audio/ogg", "audio"],
|
||||
]);
|
||||
const MAX_BYTES: Readonly<Record<MediaKind, number>> = {
|
||||
image: 20 * 1024 * 1024, document: 25 * 1024 * 1024, audio: 50 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export function isOpaqueId(value: unknown, prefix?: string): value is string {
|
||||
return typeof value === "string" && ID.test(value) && (!prefix || value.startsWith(`${prefix}_`));
|
||||
}
|
||||
|
||||
export function isUtc(value: unknown): value is string {
|
||||
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
export function safeText(value: unknown, fallback: string, limit = 500): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const clean = value.replace(SECRET, "$1=[redacted]").replace(CONTROL, " ")
|
||||
.replace(/\s+/g, " ").trim();
|
||||
if (!clean) return fallback;
|
||||
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function normalizeIdentity(raw: unknown): HuxIdentity | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const tenantRef = value.tenant_ref ?? value.tenantRef;
|
||||
const userRef = value.user_ref ?? value.userRef;
|
||||
if (typeof tenantRef !== "string" || !TENANT.test(tenantRef) ||
|
||||
typeof userRef !== "string" || !USER.test(userRef) ||
|
||||
!["chat", "worker", "telegram", "voice", "api"].includes(String(value.surface))) return null;
|
||||
return {tenantRef, userRef, surface: value.surface as HuxIdentity["surface"]};
|
||||
}
|
||||
|
||||
export function normalizeScope(raw: unknown): ArtifactScope | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const projectId = value.project_id ?? value.projectId;
|
||||
const conversationId = value.conversation_id ?? value.conversationId;
|
||||
return isOpaqueId(projectId, "prj") && isOpaqueId(conversationId, "conv") ?
|
||||
{projectId, conversationId} : null;
|
||||
}
|
||||
|
||||
export function sameIdentity(left: HuxIdentity | null, right: HuxIdentity): boolean {
|
||||
return Boolean(left && left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
|
||||
left.surface === right.surface);
|
||||
}
|
||||
|
||||
export function sameScope(left: ArtifactScope | null, right: ArtifactScope): boolean {
|
||||
return Boolean(left && left.projectId === right.projectId && left.conversationId === right.conversationId);
|
||||
}
|
||||
|
||||
/** Preview URLs must be local object URLs. Inline data and remote resources are never accepted. */
|
||||
export function safeBlobUrl(value: unknown): string | null {
|
||||
return typeof value === "string" && /^blob:(?:https?:\/\/|null\/)[^\s]+$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Text previews render only as text nodes; controls that can spoof layout are rejected. */
|
||||
export function safePreviewText(value: unknown, limit = 200_000): string | null {
|
||||
return typeof value === "string" && value.length <= limit &&
|
||||
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export function classifyMime(value: unknown): MediaKind | null {
|
||||
return typeof value === "string" ? MIME_KIND.get(value.toLowerCase()) || null : null;
|
||||
}
|
||||
|
||||
/** Validate before reading bytes or handing a File to a transport adapter. */
|
||||
export function validateUpload(file: FileCandidate, authorization: string): UploadValidation {
|
||||
if (authorization !== "authorized") return {ok: false, reason: authorization === "requires_approval" ?
|
||||
"Upload approval is required before selecting media." : "Media uploads are denied for this conversation."};
|
||||
const kind = classifyMime(file?.type);
|
||||
if (!kind) return {ok: false, reason: "This file type is not supported."};
|
||||
if (!Number.isSafeInteger(file.size) || file.size < 1 || file.size > MAX_BYTES[kind]) {
|
||||
return {ok: false, reason: `This ${kind} is empty or exceeds the ${MAX_BYTES[kind] / 1024 / 1024} MB limit.`};
|
||||
}
|
||||
const name = safeText(file.name, "", 180);
|
||||
if (!name || /[/\\]/.test(name)) return {ok: false, reason: "The file name is invalid."};
|
||||
return {ok: true, kind, file: {name, type: file.type.toLowerCase(), size: file.size}};
|
||||
}
|
||||
|
||||
export function safeEndpointPart(value: unknown, prefix: string): string {
|
||||
if (!isOpaqueId(value, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
147
dockerfiles/hermes-webui-hux/multimodal/styles.css
Normal file
147
dockerfiles/hermes-webui-hux/multimodal/styles.css
Normal file
@ -0,0 +1,147 @@
|
||||
.hux-multimodal {
|
||||
--mm-border: color-mix(in srgb, currentColor 16%, transparent);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hux-multimodal > header,
|
||||
.hux-mm-capture,
|
||||
.hux-mm-workspace,
|
||||
.hux-mm-editor {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.hux-multimodal > header {
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-multimodal h2,
|
||||
.hux-multimodal h3,
|
||||
.hux-multimodal p {
|
||||
margin-block: .2rem;
|
||||
}
|
||||
|
||||
.hux-mm-kicker {
|
||||
font-size: .72rem;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-mm-upload {
|
||||
border: 1px dashed var(--mm-border);
|
||||
border-radius: .8rem;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hux-mm-workspace {
|
||||
align-items: stretch;
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
.hux-mm-workspace > nav,
|
||||
.hux-mm-workspace > aside {
|
||||
flex: 0 0 min(18rem, 25%);
|
||||
}
|
||||
|
||||
.hux-mm-workspace > main {
|
||||
border-inline: 1px solid var(--mm-border);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.hux-mm-workspace ul,
|
||||
.hux-mm-workspace ol {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hux-mm-workspace nav button {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
overflow-wrap: anywhere;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-mm-workspace button[aria-current="page"] {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.hux-mm-image img {
|
||||
display: block;
|
||||
height: auto;
|
||||
max-height: 28rem;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hux-mm-safe-preview {
|
||||
align-items: center;
|
||||
border: 1px solid var(--mm-border);
|
||||
border-radius: .6rem;
|
||||
display: grid;
|
||||
gap: .35rem;
|
||||
justify-items: center;
|
||||
min-height: 12rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hux-mm-editor,
|
||||
.hux-mm-transcript {
|
||||
flex-direction: column;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hux-mm-editor textarea,
|
||||
.hux-mm-transcript textarea {
|
||||
min-height: 6rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.hux-mm-error,
|
||||
.hux-mm-warning {
|
||||
border-inline-start: .25rem solid currentColor;
|
||||
padding: .65rem .85rem;
|
||||
}
|
||||
|
||||
.hux-mm-workspace aside li {
|
||||
border-bottom: 1px solid var(--mm-border);
|
||||
display: grid;
|
||||
padding-block: .5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 62rem) {
|
||||
.hux-mm-workspace,
|
||||
.hux-multimodal > header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hux-mm-workspace > nav,
|
||||
.hux-mm-workspace > aside {
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.hux-mm-workspace > main {
|
||||
border-block: 1px solid var(--mm-border);
|
||||
border-inline: 0;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-multimodal *,
|
||||
.hux-multimodal *::before,
|
||||
.hux-multimodal *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: .01ms !important;
|
||||
}
|
||||
}
|
||||
140
dockerfiles/hermes-webui-hux/multimodal/types.ts
Normal file
140
dockerfiles/hermes-webui-hux/multimodal/types.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/** Frontend-only contracts for default-off HUX-07 multimodal chat. */
|
||||
|
||||
import type { ArtifactMutationIntent, ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
|
||||
import type { PolicyView } from "../autonomy/types.ts";
|
||||
|
||||
export type MediaKind = "image" | "document" | "audio";
|
||||
export type MediaAuthorization = "authorized" | "requires_approval" | "denied";
|
||||
export type CaptureKind = "camera" | "screen";
|
||||
|
||||
export interface RawMediaEnvelope {
|
||||
schema?: unknown;
|
||||
api_version?: unknown;
|
||||
identity?: unknown;
|
||||
binding?: unknown;
|
||||
media?: unknown;
|
||||
threads?: unknown;
|
||||
transcript?: unknown;
|
||||
upload_authorization?: unknown;
|
||||
}
|
||||
|
||||
export interface MediaView {
|
||||
id: string;
|
||||
owner: string;
|
||||
artifactId: string;
|
||||
artifactVersion: number;
|
||||
kind: MediaKind;
|
||||
fileName: string;
|
||||
mime: string;
|
||||
bytes: number;
|
||||
previewUrl: string | null;
|
||||
previewText: string | null;
|
||||
altText: string;
|
||||
annotations: ReadonlyArray<{id: string; page: number | null; note: string}>;
|
||||
lineage: {mediaId: string; artifactId: string; artifactVersion: number} | null;
|
||||
}
|
||||
|
||||
export interface GenerationVariant {
|
||||
id: string;
|
||||
mediaId: string;
|
||||
artifactId: string;
|
||||
artifactVersion: number;
|
||||
parentVariantId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GenerationThread {
|
||||
id: string;
|
||||
prompt: string;
|
||||
variants: GenerationVariant[];
|
||||
}
|
||||
|
||||
export interface TranscriptView {
|
||||
id: string;
|
||||
turnId: string;
|
||||
revision: number;
|
||||
language: string;
|
||||
text: string;
|
||||
finalized: boolean;
|
||||
}
|
||||
|
||||
export interface MultimodalPage {
|
||||
media: MediaView[];
|
||||
threads: GenerationThread[];
|
||||
transcript: TranscriptView | null;
|
||||
uploadAuthorization: MediaAuthorization;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface FileCandidate {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type UploadValidation =
|
||||
| {ok: true; kind: MediaKind; file: FileCandidate}
|
||||
| {ok: false; reason: string};
|
||||
|
||||
export interface ImageEditIntent extends ArtifactMutationIntent {
|
||||
mediaId: string;
|
||||
instruction: string;
|
||||
lineage: {artifactId: string; version: number};
|
||||
}
|
||||
|
||||
export interface AnnotationIntent extends ArtifactMutationIntent {
|
||||
mediaId: string;
|
||||
page: number | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface TranscriptCorrectionIntent {
|
||||
transcriptId: string;
|
||||
turnId: string;
|
||||
expectedRevision: number;
|
||||
correctedText: string;
|
||||
identity: HuxIdentity;
|
||||
scope: ArtifactScope;
|
||||
}
|
||||
|
||||
export interface CaptureIntent {
|
||||
kind: CaptureKind;
|
||||
autonomyCapability: "network";
|
||||
requiredDecision: "ask";
|
||||
identity: HuxIdentity;
|
||||
scope: ArtifactScope;
|
||||
}
|
||||
|
||||
export interface MultimodalProps {
|
||||
flags?: Iterable<string>;
|
||||
identity: HuxIdentity;
|
||||
scope: ArtifactScope;
|
||||
page: RawMediaEnvelope;
|
||||
autonomyPolicy: PolicyView | null;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onUpload?: (files: readonly FileCandidate[]) => void;
|
||||
onRequestAuthorization?: (action: "upload") => void;
|
||||
onImageEdit?: (intent: ImageEditIntent) => void;
|
||||
onAnnotate?: (intent: AnnotationIntent) => void;
|
||||
onCorrectTranscript?: (intent: TranscriptCorrectionIntent) => void;
|
||||
onCapture?: (intent: CaptureIntent) => void;
|
||||
}
|
||||
|
||||
export interface MultimodalEndpoint {
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
responseSchema: string;
|
||||
requiredBody?: readonly string[];
|
||||
}
|
||||
|
||||
export interface MultimodalEndpointContract {
|
||||
page: MultimodalEndpoint;
|
||||
beginUpload: MultimodalEndpoint;
|
||||
finalizeUpload(uploadId: string): MultimodalEndpoint;
|
||||
editImage(mediaId: string): MultimodalEndpoint;
|
||||
annotate(mediaId: string): MultimodalEndpoint;
|
||||
correctTranscript(transcriptId: string): MultimodalEndpoint;
|
||||
requestCapture(kind: CaptureKind): MultimodalEndpoint;
|
||||
createVariant(threadId: string): MultimodalEndpoint;
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
/** Restrained, accessible HUX-09 suggestion card. No action runs implicitly. */
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
import {buildActionIntent, onboardingEnabled} from "./model.ts";
|
||||
import type {
|
||||
OnboardingActionIntent,
|
||||
OnboardingClient,
|
||||
SuggestionDecision,
|
||||
SuggestionEvaluation,
|
||||
} from "./types.ts";
|
||||
|
||||
interface OnboardingSuggestionsProps {
|
||||
flags?: Iterable<string>;
|
||||
client: OnboardingClient;
|
||||
evaluation: SuggestionEvaluation | null;
|
||||
disabled?: boolean;
|
||||
onAction(intent: OnboardingActionIntent): void | Promise<void>;
|
||||
onResolved?(decision: SuggestionDecision): void;
|
||||
}
|
||||
|
||||
const CONTEXT_LABELS = {
|
||||
first_session: "Shown because this is a new workspace session.",
|
||||
empty_project: "Shown because this project has no conversations yet.",
|
||||
after_artifact: "Shown because you just created an artifact.",
|
||||
after_research: "Shown because a research task just finished.",
|
||||
after_approval: "Shown because an approved task just finished.",
|
||||
idle: "Shown after this conversation became idle.",
|
||||
} as const;
|
||||
|
||||
function actionLabel(evaluation: SuggestionEvaluation): string | null {
|
||||
const {action} = evaluation.suggestion;
|
||||
if (action.type === "open_mode") return "Choose a mode";
|
||||
if (action.type === "create_project") return "Add to a project";
|
||||
if (action.type === "open_memory") return "Review memory";
|
||||
if (action.type === "open_artifacts") return "View artifacts";
|
||||
if (action.type === "start_workflow") {
|
||||
return action.payload?.intent === "schedule_weekly" ? "Run weekly" : "Save workflow";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Render nothing until the four HUX dependencies and an eligible claim exist. */
|
||||
export function OnboardingSuggestions(props: OnboardingSuggestionsProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [resolved, setResolved] = useState(false);
|
||||
const enabled = onboardingEnabled(props.flags) && props.client?.enabled();
|
||||
const evaluation = props.evaluation;
|
||||
|
||||
useEffect(() => {
|
||||
setBusy(false);
|
||||
setError("");
|
||||
setResolved(false);
|
||||
}, [evaluation?.claimId]);
|
||||
|
||||
if (!enabled || !evaluation || evaluation.privacy.noStore || resolved) return null;
|
||||
if (evaluation.suggestion.action.type === "open_memory" &&
|
||||
!evaluation.privacy.memorySuggestionsAllowed) return null;
|
||||
const label = actionLabel(evaluation);
|
||||
|
||||
async function resolve(decision: SuggestionDecision): Promise<void> {
|
||||
if (busy || props.disabled) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (decision === "acted") {
|
||||
const intent = buildActionIntent(evaluation);
|
||||
if (!intent) throw new Error("This suggestion has no safe action");
|
||||
await props.onAction(intent);
|
||||
}
|
||||
await props.client.decide(evaluation, decision);
|
||||
setResolved(true);
|
||||
props.onResolved?.(decision);
|
||||
} catch (_) {
|
||||
setError("That choice could not be saved. Nothing else was changed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hux-onboarding" aria-labelledby={`hux-onboarding-${evaluation.suggestion.id}`}>
|
||||
<div className="hux-onboarding-copy">
|
||||
<span className="hux-onboarding-kicker">Optional next step</span>
|
||||
<h2 id={`hux-onboarding-${evaluation.suggestion.id}`}>
|
||||
{evaluation.suggestion.title}
|
||||
</h2>
|
||||
<p>{evaluation.suggestion.body}</p>
|
||||
<details className="hux-onboarding-why">
|
||||
<summary>Why am I seeing this?</summary>
|
||||
<p>{CONTEXT_LABELS[evaluation.suggestion.trigger.context]}</p>
|
||||
</details>
|
||||
</div>
|
||||
<div className="hux-onboarding-controls" aria-busy={busy}>
|
||||
{label && (
|
||||
<button type="button" className="hux-onboarding-primary"
|
||||
disabled={busy || props.disabled} onClick={() => void resolve("acted")}>
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={busy || props.disabled}
|
||||
onClick={() => void resolve("dismiss")}>
|
||||
Not now
|
||||
</button>
|
||||
<button type="button" className="hux-onboarding-never"
|
||||
disabled={busy || props.disabled} onClick={() => void resolve("never_again")}>
|
||||
Don't suggest this again
|
||||
</button>
|
||||
</div>
|
||||
<div className="hux-onboarding-status" role="status" aria-live="polite">
|
||||
{busy ? "Saving your choice…" : ""}
|
||||
</div>
|
||||
{error && <p className="hux-onboarding-error" role="alert">{error}</p>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
145
dockerfiles/hermes-webui-hux/onboarding/client.ts
Normal file
145
dockerfiles/hermes-webui-hux/onboarding/client.ts
Normal file
@ -0,0 +1,145 @@
|
||||
/** Same-origin, session-bound transport for contextual HUX-09 suggestions. */
|
||||
|
||||
import {ACCEPT, API_VERSION, DECISION_SCHEMA, EVALUATION_SCHEMA} from "./contracts.ts";
|
||||
import {
|
||||
FOUNDATION_FLAG,
|
||||
FRIENDLY_MODES_FLAG,
|
||||
ONBOARDING_FLAG,
|
||||
PRIVACY_FLAG,
|
||||
PROJECTS_FLAG,
|
||||
normalizeDecisionState,
|
||||
normalizeEvaluation,
|
||||
} from "./model.ts";
|
||||
import {
|
||||
asRecord,
|
||||
exactKeys,
|
||||
isId,
|
||||
normalizeIdentity,
|
||||
normalizeScope,
|
||||
sameIdentity,
|
||||
scopedSuggestionPath,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
FetchResponse,
|
||||
HuxFoundationClient,
|
||||
OnboardingScope,
|
||||
RawSuggestionState,
|
||||
SuggestionDecision,
|
||||
SuggestionEvaluation,
|
||||
SuggestionState,
|
||||
} from "./types.ts";
|
||||
|
||||
interface ClientOptions {
|
||||
client: HuxFoundationClient;
|
||||
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
|
||||
}
|
||||
|
||||
export class OnboardingContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OnboardingContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function hasCoreFlags(client: HuxFoundationClient): boolean {
|
||||
return client.apiVersion === API_VERSION && [
|
||||
FOUNDATION_FLAG, PROJECTS_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG,
|
||||
].every((flag) => client.enabled(flag));
|
||||
}
|
||||
|
||||
export function createOnboardingClient(options: ClientOptions) {
|
||||
const client = options?.client;
|
||||
const fetcher = options?.fetcher ||
|
||||
(typeof fetch === "function" ? fetch.bind(globalThis) : null);
|
||||
if (!client || !fetcher) throw new TypeError("Onboarding requires HUX and fetch clients");
|
||||
const identity = normalizeIdentity({
|
||||
tenant_ref: client.identity?.tenantRef,
|
||||
user_ref: client.identity?.userRef,
|
||||
surface: client.identity?.surface,
|
||||
});
|
||||
if (!identity) throw new TypeError("Onboarding requires a scoped HUX identity");
|
||||
const seenClaims = new Set<string>();
|
||||
const activeClaims = new Map<string, SuggestionEvaluation>();
|
||||
|
||||
async function request(path: string, body: Record<string, unknown>): Promise<unknown> {
|
||||
if (!hasCoreFlags(client)) throw new OnboardingContractError("Onboarding is not enabled");
|
||||
const response = await fetcher(client.endpoint(path), {
|
||||
method: "POST", cache: "no-store", credentials: "same-origin",
|
||||
headers: {Accept: ACCEPT, "Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (response.status === 404 || response.status === 204) return null;
|
||||
if (!response.ok) throw new OnboardingContractError(
|
||||
response.status === 409 ? "This suggestion is no longer current" :
|
||||
`Onboarding request failed (${response.status})`,
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function evaluate(scope: OnboardingScope): Promise<SuggestionEvaluation | null> {
|
||||
if (!hasCoreFlags(client)) throw new OnboardingContractError("Onboarding is not enabled");
|
||||
const safeScope = normalizeScope(scope, identity);
|
||||
if (!safeScope) throw new TypeError("Onboarding trigger is outside this session");
|
||||
const raw = await request("/onboarding/evaluate", {
|
||||
schema: "hux.suggestion_evaluation_request.v1",
|
||||
session_id: safeScope.sessionId,
|
||||
conversation_id: safeScope.conversationId,
|
||||
trigger: safeScope.trigger,
|
||||
});
|
||||
if (raw === null) return null;
|
||||
const result = normalizeEvaluation(raw, identity, safeScope, client.enabled(PRIVACY_FLAG));
|
||||
if (!result || seenClaims.has(result.claimId)) return null;
|
||||
seenClaims.add(result.claimId);
|
||||
activeClaims.set(result.claimId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeDecision(
|
||||
raw: unknown,
|
||||
evaluation: SuggestionEvaluation,
|
||||
decision: SuggestionDecision,
|
||||
): SuggestionState | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, [
|
||||
"schema", "api_version", "identity", "session_id", "conversation_id",
|
||||
"claim_id", "suggestion_id", "state",
|
||||
]) || value.schema !== DECISION_SCHEMA || value.api_version !== API_VERSION ||
|
||||
value.session_id !== evaluation.scope.sessionId ||
|
||||
value.conversation_id !== evaluation.scope.conversationId ||
|
||||
value.claim_id !== evaluation.claimId ||
|
||||
value.suggestion_id !== evaluation.suggestion.id ||
|
||||
!isId(value.claim_id, "clm") || !isId(value.suggestion_id, "sug")) return null;
|
||||
const actualIdentity = normalizeIdentity(value.identity);
|
||||
if (!actualIdentity || !sameIdentity(actualIdentity, identity)) return null;
|
||||
return normalizeDecisionState(value.state as RawSuggestionState, evaluation, decision);
|
||||
}
|
||||
|
||||
async function decide(
|
||||
evaluation: SuggestionEvaluation,
|
||||
decision: SuggestionDecision,
|
||||
): Promise<SuggestionState> {
|
||||
if (!["dismiss", "never_again", "acted"].includes(decision) ||
|
||||
activeClaims.get(evaluation?.claimId) !== evaluation) {
|
||||
throw new TypeError("Suggestion decision is not bound to this session");
|
||||
}
|
||||
const path = scopedSuggestionPath("/onboarding/suggestions", evaluation.suggestion.id);
|
||||
const raw = await request(path, {
|
||||
schema: "hux.suggestion_decision_request.v1",
|
||||
session_id: evaluation.scope.sessionId,
|
||||
conversation_id: evaluation.scope.conversationId,
|
||||
claim_id: evaluation.claimId,
|
||||
suggestion_id: evaluation.suggestion.id,
|
||||
expected_shows: evaluation.state.shows,
|
||||
decision,
|
||||
});
|
||||
const state = normalizeDecision(raw, evaluation, decision);
|
||||
if (!state) throw new OnboardingContractError("Suggestion decision crossed its scope");
|
||||
activeClaims.delete(evaluation.claimId);
|
||||
return state;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
enabled: () => hasCoreFlags(client), evaluate, decide,
|
||||
schemas: Object.freeze({evaluation: EVALUATION_SCHEMA, decision: DECISION_SCHEMA}),
|
||||
});
|
||||
}
|
||||
32
dockerfiles/hermes-webui-hux/onboarding/contracts.ts
Normal file
32
dockerfiles/hermes-webui-hux/onboarding/contracts.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/** Same-origin backend contracts required by the HUX-09 frontend. */
|
||||
|
||||
export const API_VERSION = "hux.v1";
|
||||
export const ACCEPT = "application/vnd.hermes.hux+json; version=1";
|
||||
export const EVALUATION_SCHEMA = "hux.suggestion_evaluation.v1";
|
||||
export const DECISION_SCHEMA = "hux.suggestion_decision.v1";
|
||||
|
||||
/**
|
||||
* POST /hux/v1/onboarding/evaluate
|
||||
* POST /hux/v1/onboarding/suggestions/{sug_id}/decisions
|
||||
*
|
||||
* Evaluate accepts only schema, session_id, conversation_id and the exact
|
||||
* trigger. Identity MUST NOT be accepted from browser storage, query
|
||||
* parameters, or request bodies; derive it from the authenticated session.
|
||||
* The server verifies that both opaque ids belong to that identity and surface.
|
||||
*
|
||||
* Before returning a candidate, evaluation MUST atomically apply never_again,
|
||||
* max_shows and cooldown_seconds from suggestion.schema.json, increment shows,
|
||||
* and bind a single-use claim_id to identity + session + conversation + trigger.
|
||||
* If no_store is active, the server MUST NOT write suppression state and
|
||||
* returns suggestion/state/claim_id as null. Memory suggestions require the
|
||||
* hux.privacy capability and memory_suggestions_allowed=true.
|
||||
*
|
||||
* Decisions accept only schema, session_id, conversation_id, claim_id,
|
||||
* suggestion_id, expected_shows and decision. The server rechecks the claim
|
||||
* binding and returns 409 for stale/replayed claims. A decision never performs
|
||||
* the suggested action; it only persists dismiss, never-again, or acted state.
|
||||
*/
|
||||
export const BACKEND_ENDPOINTS = Object.freeze({
|
||||
evaluate: "/onboarding/evaluate",
|
||||
decision: "/onboarding/suggestions/{sug_id}/decisions",
|
||||
});
|
||||
20
dockerfiles/hermes-webui-hux/onboarding/index.ts
Normal file
20
dockerfiles/hermes-webui-hux/onboarding/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/** Public integration surface for the inert HUX-09 onboarding module. */
|
||||
|
||||
export {createOnboardingClient, OnboardingContractError} from "./client.ts";
|
||||
export {BACKEND_ENDPOINTS} from "./contracts.ts";
|
||||
export {
|
||||
FOUNDATION_FLAG,
|
||||
FRIENDLY_MODES_FLAG,
|
||||
ONBOARDING_FLAG,
|
||||
PRIVACY_FLAG,
|
||||
PROJECTS_FLAG,
|
||||
REQUIRED_FLAGS,
|
||||
buildActionIntent,
|
||||
normalizeDecisionState,
|
||||
normalizeEvaluation,
|
||||
normalizeState,
|
||||
normalizeSuggestion,
|
||||
onboardingEnabled,
|
||||
} from "./model.ts";
|
||||
export {OnboardingSuggestions} from "./OnboardingSuggestions.tsx";
|
||||
export type * from "./types.ts";
|
||||
246
dockerfiles/hermes-webui-hux/onboarding/model.ts
Normal file
246
dockerfiles/hermes-webui-hux/onboarding/model.ts
Normal file
@ -0,0 +1,246 @@
|
||||
/** Fail-closed suggestion.schema.json adapters for HUX-09. */
|
||||
|
||||
import {
|
||||
asRecord,
|
||||
exactKeys,
|
||||
isId,
|
||||
isSafeText,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
normalizeTrigger,
|
||||
sameIdentity,
|
||||
sameTrigger,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
OnboardingActionIntent,
|
||||
OnboardingScope,
|
||||
OnboardingSuggestion,
|
||||
RawSuggestion,
|
||||
RawSuggestionState,
|
||||
ScopedIdentity,
|
||||
SuggestionActionType,
|
||||
SuggestionDecision,
|
||||
SuggestionEvaluation,
|
||||
SuggestionKind,
|
||||
SuggestionState,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const PROJECTS_FLAG = "hux.projects";
|
||||
export const FRIENDLY_MODES_FLAG = "hux.friendly_modes";
|
||||
export const ONBOARDING_FLAG = "hux.onboarding";
|
||||
export const PRIVACY_FLAG = "hux.privacy";
|
||||
export const REQUIRED_FLAGS = Object.freeze([
|
||||
FOUNDATION_FLAG,
|
||||
PROJECTS_FLAG,
|
||||
FRIENDLY_MODES_FLAG,
|
||||
ONBOARDING_FLAG,
|
||||
]);
|
||||
|
||||
const KINDS = new Set<SuggestionKind>(["workflow", "project", "feature", "tip"]);
|
||||
const ACTIONS = new Set<SuggestionActionType>([
|
||||
"open_mode", "create_project", "open_memory", "open_artifacts",
|
||||
"start_workflow", "none",
|
||||
]);
|
||||
const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]);
|
||||
const INTENTS = new Set(["schedule_weekly", "save_reusable"]);
|
||||
const KIND_ACTIONS: Readonly<Record<SuggestionKind, ReadonlySet<SuggestionActionType>>> = {
|
||||
workflow: new Set(["start_workflow"]),
|
||||
project: new Set(["create_project"]),
|
||||
feature: new Set(["open_mode", "open_memory", "open_artifacts"]),
|
||||
tip: new Set(["none"]),
|
||||
};
|
||||
|
||||
function finiteInteger(value: unknown, min: number, max: number): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
|
||||
}
|
||||
|
||||
function normalizePayload(
|
||||
type: SuggestionActionType,
|
||||
raw: unknown,
|
||||
): Readonly<Record<string, string>> | null | undefined {
|
||||
const value = raw === undefined ? {} : asRecord(raw);
|
||||
if (!value) return null;
|
||||
if (type === "start_workflow") {
|
||||
return exactKeys(value, ["intent"]) && INTENTS.has(value.intent as string) ?
|
||||
{intent: value.intent as string} : null;
|
||||
}
|
||||
if (type === "create_project") {
|
||||
if (!exactKeys(value, [], ["prefill_title"])) return null;
|
||||
return value.prefill_title === undefined ? {} :
|
||||
isSafeText(value.prefill_title, 1, 120) ? {prefill_title: value.prefill_title} : null;
|
||||
}
|
||||
if (type === "open_mode") {
|
||||
if (!exactKeys(value, [], ["mode"])) return null;
|
||||
return value.mode === undefined ? {} : MODES.has(value.mode as string) ?
|
||||
{mode: value.mode as string} : null;
|
||||
}
|
||||
if (type === "open_memory") {
|
||||
return exactKeys(value, [], ["view"]) &&
|
||||
(value.view === undefined || value.view === "suggestions") ?
|
||||
(value.view ? {view: "suggestions"} : {}) : null;
|
||||
}
|
||||
if (type === "open_artifacts") {
|
||||
return exactKeys(value, [], ["view"]) &&
|
||||
(value.view === undefined || value.view === "current") ?
|
||||
(value.view ? {view: "current"} : {}) : null;
|
||||
}
|
||||
return exactKeys(value, []) ? undefined : null;
|
||||
}
|
||||
|
||||
export function onboardingEnabled(flags?: Iterable<string>): boolean {
|
||||
if (!flags) return false;
|
||||
const enabled = new Set(flags);
|
||||
return REQUIRED_FLAGS.every((flag) => enabled.has(flag));
|
||||
}
|
||||
|
||||
export function normalizeSuggestion(
|
||||
raw: RawSuggestion,
|
||||
expectedTrigger: OnboardingScope["trigger"],
|
||||
): OnboardingSuggestion | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, [
|
||||
"schema", "id", "kind", "trigger", "title", "body", "priority", "suppression",
|
||||
], ["action"]) || value.schema !== "hux.suggestion.v1" || !isId(value.id, "sug") ||
|
||||
!KINDS.has(value.kind as SuggestionKind) || !isSafeText(value.title, 1, 80) ||
|
||||
!isSafeText(value.body, 1, 280) || !finiteInteger(value.priority, 0, 100)) return null;
|
||||
const trigger = normalizeTrigger(value.trigger);
|
||||
const suppression = asRecord(value.suppression);
|
||||
if (!trigger || !sameTrigger(trigger, expectedTrigger) || !suppression ||
|
||||
!exactKeys(suppression, [
|
||||
"dismissable", "max_shows", "cooldown_seconds", "never_again_supported",
|
||||
]) || suppression.dismissable !== true || suppression.never_again_supported !== true ||
|
||||
!finiteInteger(suppression.max_shows, 1, 5) ||
|
||||
!finiteInteger(suppression.cooldown_seconds, 3600, Number.MAX_SAFE_INTEGER)) return null;
|
||||
|
||||
const action = value.action === undefined ? {type: "none"} : asRecord(value.action);
|
||||
if (!action || !exactKeys(action, ["type"], ["payload"]) ||
|
||||
!ACTIONS.has(action.type as SuggestionActionType)) return null;
|
||||
const type = action.type as SuggestionActionType;
|
||||
const payload = normalizePayload(type, action.payload);
|
||||
const kind = value.kind as SuggestionKind;
|
||||
if (payload === null || !KIND_ACTIONS[kind].has(type)) return null;
|
||||
return {
|
||||
schema: "hux.suggestion.v1", id: value.id as string, kind, trigger,
|
||||
title: value.title as string, body: value.body as string,
|
||||
action: {type, ...(payload === undefined ? {} : {payload})},
|
||||
priority: value.priority as number,
|
||||
suppression: {
|
||||
dismissable: true, maxShows: suppression.max_shows as number,
|
||||
cooldownSeconds: suppression.cooldown_seconds as number,
|
||||
neverAgainSupported: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeState(
|
||||
raw: RawSuggestionState,
|
||||
suggestion: OnboardingSuggestion,
|
||||
owner: string,
|
||||
): SuggestionState | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, [
|
||||
"schema", "owner", "suggestion_id", "shows", "last_shown_at", "never_again",
|
||||
], ["dismissed_at", "acted_at"]) || value.schema !== "hux.suggestion_state.v1" ||
|
||||
value.owner !== owner || value.suggestion_id !== suggestion.id ||
|
||||
!finiteInteger(value.shows, 1, suggestion.suppression.maxShows) ||
|
||||
!isUtc(value.last_shown_at) || value.never_again !== false ||
|
||||
(value.dismissed_at !== undefined && !isUtc(value.dismissed_at)) ||
|
||||
(value.acted_at !== undefined && !isUtc(value.acted_at))) return null;
|
||||
return {
|
||||
schema: "hux.suggestion_state.v1", owner, suggestionId: suggestion.id,
|
||||
shows: value.shows as number, lastShownAt: value.last_shown_at as string,
|
||||
...(value.dismissed_at ? {dismissedAt: value.dismissed_at as string} : {}),
|
||||
...(value.acted_at ? {actedAt: value.acted_at as string} : {}),
|
||||
neverAgain: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDecisionState(
|
||||
raw: RawSuggestionState,
|
||||
evaluation: SuggestionEvaluation,
|
||||
decision: SuggestionDecision,
|
||||
): SuggestionState | null {
|
||||
const value = asRecord(raw);
|
||||
const suggestion = evaluation.suggestion;
|
||||
if (!value || !exactKeys(value, [
|
||||
"schema", "owner", "suggestion_id", "shows", "last_shown_at", "never_again",
|
||||
], ["dismissed_at", "acted_at"]) || value.schema !== "hux.suggestion_state.v1" ||
|
||||
value.owner !== evaluation.identity.userRef || value.suggestion_id !== suggestion.id ||
|
||||
value.shows !== evaluation.state.shows || !isUtc(value.last_shown_at) ||
|
||||
value.last_shown_at !== evaluation.state.lastShownAt ||
|
||||
typeof value.never_again !== "boolean") return null;
|
||||
const dismissed = value.dismissed_at === undefined || isUtc(value.dismissed_at);
|
||||
const acted = value.acted_at === undefined || isUtc(value.acted_at);
|
||||
const outcomeMatches = decision === "acted" ?
|
||||
Boolean(value.acted_at) && value.never_again === false :
|
||||
Boolean(value.dismissed_at) &&
|
||||
value.never_again === (decision === "never_again");
|
||||
if (!dismissed || !acted || !outcomeMatches) return null;
|
||||
return {
|
||||
schema: "hux.suggestion_state.v1", owner: evaluation.identity.userRef,
|
||||
suggestionId: suggestion.id, shows: value.shows as number,
|
||||
lastShownAt: value.last_shown_at as string,
|
||||
...(value.dismissed_at ? {dismissedAt: value.dismissed_at as string} : {}),
|
||||
...(value.acted_at ? {actedAt: value.acted_at as string} : {}),
|
||||
neverAgain: value.never_again,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEvaluation(
|
||||
raw: unknown,
|
||||
expectedIdentity: ScopedIdentity,
|
||||
expectedScope: OnboardingScope,
|
||||
privacyCapability: boolean,
|
||||
): SuggestionEvaluation | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, [
|
||||
"schema", "api_version", "identity", "session_id", "conversation_id", "trigger",
|
||||
"privacy", "claim_id", "issued_at", "suggestion", "state",
|
||||
]) || value.schema !== "hux.suggestion_evaluation.v1" || value.api_version !== "hux.v1")
|
||||
return null;
|
||||
const identity = normalizeIdentity(value.identity);
|
||||
const trigger = normalizeTrigger(value.trigger);
|
||||
const privacy = asRecord(value.privacy);
|
||||
if (!identity || !sameIdentity(identity, expectedIdentity) ||
|
||||
value.session_id !== expectedScope.sessionId ||
|
||||
value.conversation_id !== expectedScope.conversationId || !trigger ||
|
||||
!sameTrigger(trigger, expectedScope.trigger) || !privacy ||
|
||||
!exactKeys(privacy, ["schema", "no_store", "memory_suggestions_allowed"]) ||
|
||||
privacy.schema !== "hux.onboarding_privacy.v1" ||
|
||||
typeof privacy.no_store !== "boolean" ||
|
||||
typeof privacy.memory_suggestions_allowed !== "boolean") return null;
|
||||
if (privacy.no_store || value.suggestion === null || value.state === null ||
|
||||
value.claim_id === null) return null;
|
||||
if (!isId(value.claim_id, "clm") || !isUtc(value.issued_at)) return null;
|
||||
const suggestion = normalizeSuggestion(value.suggestion as RawSuggestion, expectedScope.trigger);
|
||||
if (!suggestion || (suggestion.action.type === "open_memory" &&
|
||||
(!privacyCapability || privacy.memory_suggestions_allowed !== true))) return null;
|
||||
const state = normalizeState(value.state as RawSuggestionState, suggestion, identity.userRef);
|
||||
if (!state) return null;
|
||||
return {
|
||||
schema: "hux.suggestion_evaluation.v1", identity,
|
||||
scope: expectedScope, claimId: value.claim_id, issuedAt: value.issued_at,
|
||||
privacy: {
|
||||
schema: "hux.onboarding_privacy.v1", noStore: false,
|
||||
memorySuggestionsAllowed: privacy.memory_suggestions_allowed,
|
||||
},
|
||||
suggestion, state,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildActionIntent(
|
||||
evaluation: SuggestionEvaluation,
|
||||
): OnboardingActionIntent | null {
|
||||
const {action} = evaluation.suggestion;
|
||||
const payload = action.payload || {};
|
||||
if (action.type === "open_mode") return {type: action.type, ...(payload.mode ? {mode: payload.mode} : {})};
|
||||
if (action.type === "create_project") return {type: action.type,
|
||||
conversationId: evaluation.scope.conversationId,
|
||||
...(payload.prefill_title ? {prefillTitle: payload.prefill_title} : {})};
|
||||
if (action.type === "open_memory") return {type: action.type, view: "suggestions"};
|
||||
if (action.type === "open_artifacts") return {type: action.type, view: "current"};
|
||||
if (action.type === "start_workflow") return {type: action.type,
|
||||
intent: payload.intent as "schedule_weekly" | "save_reusable"};
|
||||
return null;
|
||||
}
|
||||
98
dockerfiles/hermes-webui-hux/onboarding/security.ts
Normal file
98
dockerfiles/hermes-webui-hux/onboarding/security.ts
Normal file
@ -0,0 +1,98 @@
|
||||
/** Identity, scope, trigger and time guards for HUX-09. */
|
||||
|
||||
import type {
|
||||
HuxSurface,
|
||||
OnboardingScope,
|
||||
RawIdentity,
|
||||
ScopedIdentity,
|
||||
SuggestionContext,
|
||||
SuggestionTrigger,
|
||||
} from "./types.ts";
|
||||
|
||||
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
|
||||
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
|
||||
const USER = /^usr_[0-9a-f]{16,64}$/;
|
||||
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/;
|
||||
const SURFACES = new Set<HuxSurface>(["chat", "worker", "telegram", "voice", "api"]);
|
||||
const CONTEXTS = new Set<SuggestionContext>([
|
||||
"first_session", "empty_project", "after_artifact", "after_research",
|
||||
"after_approval", "idle",
|
||||
]);
|
||||
|
||||
export function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): boolean {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
|
||||
Object.keys(value).every((key) => allowed.has(key));
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ?
|
||||
value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
export function isId(value: unknown, prefix?: string): value is string {
|
||||
return typeof value === "string" && ID.test(value) &&
|
||||
(!prefix || value.startsWith(`${prefix}_`));
|
||||
}
|
||||
|
||||
export function isUtc(value: unknown): value is string {
|
||||
return typeof value === "string" && UTC.test(value) &&
|
||||
!Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
export function isSafeText(value: unknown, min: number, max: number): value is string {
|
||||
return typeof value === "string" && value.length >= min && value.length <= max &&
|
||||
!CONTROL.test(value);
|
||||
}
|
||||
|
||||
export function normalizeIdentity(raw: unknown): ScopedIdentity | null {
|
||||
const value = raw as RawIdentity | null;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
if (!exactKeys(value as Record<string, unknown>, ["tenant_ref", "user_ref", "surface"]))
|
||||
return null;
|
||||
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
|
||||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) ||
|
||||
typeof value.surface !== "string" || !SURFACES.has(value.surface as HuxSurface))
|
||||
return null;
|
||||
return {tenantRef: value.tenant_ref, userRef: value.user_ref,
|
||||
surface: value.surface as HuxSurface};
|
||||
}
|
||||
|
||||
export function sameIdentity(left: ScopedIdentity, right: ScopedIdentity): boolean {
|
||||
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
|
||||
left.surface === right.surface;
|
||||
}
|
||||
|
||||
export function normalizeTrigger(raw: unknown): SuggestionTrigger | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, ["surface", "context"]) ||
|
||||
!SURFACES.has(value.surface as HuxSurface) ||
|
||||
!CONTEXTS.has(value.context as SuggestionContext)) return null;
|
||||
return {surface: value.surface as HuxSurface, context: value.context as SuggestionContext};
|
||||
}
|
||||
|
||||
export function sameTrigger(left: SuggestionTrigger, right: SuggestionTrigger): boolean {
|
||||
return left.surface === right.surface && left.context === right.context;
|
||||
}
|
||||
|
||||
export function normalizeScope(raw: OnboardingScope, identity: ScopedIdentity): OnboardingScope | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, ["sessionId", "conversationId", "trigger"]) ||
|
||||
!isId(value.sessionId, "ses") || !isId(value.conversationId, "conv")) return null;
|
||||
const trigger = normalizeTrigger(value.trigger);
|
||||
if (!trigger || trigger.surface !== identity.surface) return null;
|
||||
return {sessionId: value.sessionId, conversationId: value.conversationId, trigger};
|
||||
}
|
||||
|
||||
export function scopedSuggestionPath(base: string, suggestionId: string): string {
|
||||
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) ||
|
||||
base.includes("..") || !isId(suggestionId, "sug")) {
|
||||
throw new TypeError("Onboarding path is not safely scoped");
|
||||
}
|
||||
return `${base.replace(/\/$/, "")}/${encodeURIComponent(suggestionId)}/decisions`;
|
||||
}
|
||||
124
dockerfiles/hermes-webui-hux/onboarding/styles.css
Normal file
124
dockerfiles/hermes-webui-hux/onboarding/styles.css
Normal file
@ -0,0 +1,124 @@
|
||||
/* Component-scoped HUX-09 presentation; importing it cannot recolor the page. */
|
||||
.hux-onboarding {
|
||||
background: color-mix(in srgb, #22d3ee 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #22d3ee 22%, currentColor 8%);
|
||||
border-radius: 0.8rem;
|
||||
color: inherit;
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
gap: 0.7rem 1rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 0.8rem 0.9rem;
|
||||
}
|
||||
|
||||
.hux-onboarding-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hux-onboarding-kicker {
|
||||
color: color-mix(in srgb, currentColor 60%, #22d3ee);
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.055em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-onboarding h2 {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 680;
|
||||
line-height: 1.35;
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
|
||||
.hux-onboarding-copy > p,
|
||||
.hux-onboarding-why p {
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.45;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.hux-onboarding-why {
|
||||
font-size: 0.625rem;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.hux-onboarding-why summary {
|
||||
color: color-mix(in srgb, currentColor 58%, transparent);
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.hux-onboarding-controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hux-onboarding button {
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.6875rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.hux-onboarding .hux-onboarding-primary {
|
||||
background: color-mix(in srgb, #22d3ee 12%, transparent);
|
||||
border-color: color-mix(in srgb, #22d3ee 48%, currentColor);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.hux-onboarding .hux-onboarding-never {
|
||||
border-color: transparent;
|
||||
color: color-mix(in srgb, currentColor 58%, transparent);
|
||||
}
|
||||
|
||||
.hux-onboarding button:focus-visible,
|
||||
.hux-onboarding summary:focus-visible {
|
||||
outline: 2px solid #22d3ee;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hux-onboarding button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hux-onboarding-status,
|
||||
.hux-onboarding-error {
|
||||
font-size: 0.625rem;
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-onboarding-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hux-onboarding-error {
|
||||
color: color-mix(in srgb, currentColor 82%, #fb7185);
|
||||
}
|
||||
|
||||
@container (max-width: 38rem) {
|
||||
.hux-onboarding {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hux-onboarding-controls {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-onboarding * {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
142
dockerfiles/hermes-webui-hux/onboarding/types.ts
Normal file
142
dockerfiles/hermes-webui-hux/onboarding/types.ts
Normal file
@ -0,0 +1,142 @@
|
||||
/** Scoped types for the default-off HUX-09 contextual onboarding surface. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
export type SuggestionContext =
|
||||
| "first_session"
|
||||
| "empty_project"
|
||||
| "after_artifact"
|
||||
| "after_research"
|
||||
| "after_approval"
|
||||
| "idle";
|
||||
export type SuggestionKind = "workflow" | "project" | "feature" | "tip";
|
||||
export type SuggestionActionType =
|
||||
| "open_mode"
|
||||
| "create_project"
|
||||
| "open_memory"
|
||||
| "open_artifacts"
|
||||
| "start_workflow"
|
||||
| "none";
|
||||
export type SuggestionDecision = "dismiss" | "never_again" | "acted";
|
||||
|
||||
export interface ScopedIdentity {
|
||||
tenantRef: string;
|
||||
userRef: string;
|
||||
surface: HuxSurface;
|
||||
}
|
||||
|
||||
export interface RawIdentity {
|
||||
tenant_ref?: unknown;
|
||||
user_ref?: unknown;
|
||||
surface?: unknown;
|
||||
}
|
||||
|
||||
export interface SuggestionTrigger {
|
||||
surface: HuxSurface;
|
||||
context: SuggestionContext;
|
||||
}
|
||||
|
||||
export interface OnboardingScope {
|
||||
sessionId: string;
|
||||
conversationId: string;
|
||||
trigger: SuggestionTrigger;
|
||||
}
|
||||
|
||||
export interface RawSuggestion {
|
||||
schema?: unknown;
|
||||
id?: unknown;
|
||||
kind?: unknown;
|
||||
trigger?: unknown;
|
||||
title?: unknown;
|
||||
body?: unknown;
|
||||
action?: unknown;
|
||||
priority?: unknown;
|
||||
suppression?: unknown;
|
||||
}
|
||||
|
||||
export interface OnboardingSuggestion {
|
||||
schema: "hux.suggestion.v1";
|
||||
id: string;
|
||||
kind: SuggestionKind;
|
||||
trigger: SuggestionTrigger;
|
||||
title: string;
|
||||
body: string;
|
||||
action: {
|
||||
type: SuggestionActionType;
|
||||
payload?: Readonly<Record<string, string>>;
|
||||
};
|
||||
priority: number;
|
||||
suppression: {
|
||||
dismissable: true;
|
||||
maxShows: number;
|
||||
cooldownSeconds: number;
|
||||
neverAgainSupported: true;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RawSuggestionState {
|
||||
schema?: unknown;
|
||||
owner?: unknown;
|
||||
suggestion_id?: unknown;
|
||||
shows?: unknown;
|
||||
last_shown_at?: unknown;
|
||||
dismissed_at?: unknown;
|
||||
acted_at?: unknown;
|
||||
never_again?: unknown;
|
||||
}
|
||||
|
||||
export interface SuggestionState {
|
||||
schema: "hux.suggestion_state.v1";
|
||||
owner: string;
|
||||
suggestionId: string;
|
||||
shows: number;
|
||||
lastShownAt: string;
|
||||
dismissedAt?: string;
|
||||
actedAt?: string;
|
||||
neverAgain: boolean;
|
||||
}
|
||||
|
||||
export interface PrivacySignal {
|
||||
schema: "hux.onboarding_privacy.v1";
|
||||
noStore: boolean;
|
||||
memorySuggestionsAllowed: boolean;
|
||||
}
|
||||
|
||||
export interface SuggestionEvaluation {
|
||||
schema: "hux.suggestion_evaluation.v1";
|
||||
identity: ScopedIdentity;
|
||||
scope: OnboardingScope;
|
||||
claimId: string;
|
||||
issuedAt: string;
|
||||
privacy: PrivacySignal;
|
||||
suggestion: OnboardingSuggestion;
|
||||
state: SuggestionState;
|
||||
}
|
||||
|
||||
export type OnboardingActionIntent =
|
||||
| {type: "open_mode"; mode?: string}
|
||||
| {type: "create_project"; conversationId: string; prefillTitle?: string}
|
||||
| {type: "open_memory"; view: "suggestions"}
|
||||
| {type: "open_artifacts"; view: "current"}
|
||||
| {type: "start_workflow"; intent: "schedule_weekly" | "save_reusable"};
|
||||
|
||||
export interface HuxFoundationClient {
|
||||
apiVersion: string;
|
||||
identity: ScopedIdentity;
|
||||
enabled(flag: string): boolean;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
export interface FetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface OnboardingClient {
|
||||
enabled(): boolean;
|
||||
evaluate(scope: OnboardingScope): Promise<SuggestionEvaluation | null>;
|
||||
decide(
|
||||
evaluation: SuggestionEvaluation,
|
||||
decision: SuggestionDecision,
|
||||
): Promise<SuggestionState>;
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
/** Accessible, default-off Projects and Conversations workspace for HUX-03. */
|
||||
|
||||
import { Folder, GitBranch, Paperclip, Pin, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
import { attachmentViews, branchLineage, projectsEnabled } from "./model.ts";
|
||||
import type {
|
||||
ArtifactSummary,
|
||||
Conversation,
|
||||
Project,
|
||||
} from "./types.ts";
|
||||
|
||||
interface OrganizationWorkspaceProps {
|
||||
flags?: Iterable<string>;
|
||||
projects: readonly Project[];
|
||||
conversations: readonly Conversation[];
|
||||
artifacts?: readonly ArtifactSummary[];
|
||||
searchResults?: readonly Conversation[];
|
||||
nextSearchCursor?: string | null;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onSearch?: (query: string, cursor: string | null) => void;
|
||||
onRenameProject?: (project: Project, name: string) => void;
|
||||
onRenameConversation?: (conversation: Conversation, title: string) => void;
|
||||
onUpdateProjectTags?: (project: Project, tags: string[]) => void;
|
||||
onUpdateConversationTags?: (conversation: Conversation, tags: string[]) => void;
|
||||
onMoveConversation?: (conversation: Conversation, projectId: string | null) => void;
|
||||
onToggleProjectPin?: (project: Project) => void;
|
||||
onToggleConversationPin?: (conversation: Conversation) => void;
|
||||
}
|
||||
|
||||
function ordered<T extends {pinned: boolean; updatedAt: string}>(items: readonly T[]): T[] {
|
||||
return [...items].sort((left, right) => Number(right.pinned) - Number(left.pinned) ||
|
||||
right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
function Tags({values}: {values: readonly string[]}) {
|
||||
if (!values.length) return null;
|
||||
return <ul className="hux-org-tags" aria-label="Tags">
|
||||
{values.map((tag) => <li key={tag}>{tag}</li>)}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
function EditableTitle({label, value, maxLength, onSave}: {
|
||||
label: string;
|
||||
value: string;
|
||||
maxLength: number;
|
||||
onSave?: (value: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
if (!onSave) return <span>{value}</span>;
|
||||
if (!editing) return <button className="hux-org-title-button" type="button"
|
||||
onClick={() => { setDraft(value); setEditing(true); }} aria-label={`${label}: ${value}. Rename`}>
|
||||
Rename
|
||||
</button>;
|
||||
return <form className="hux-org-rename" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const next = draft.trim();
|
||||
if (next && next !== value) onSave(next);
|
||||
setEditing(false);
|
||||
}}>
|
||||
<label><span className="sr-only">{label}</span>
|
||||
<input value={draft} maxLength={maxLength} autoFocus onChange={(event) => setDraft(event.target.value)} />
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</form>;
|
||||
}
|
||||
|
||||
function EditableTags({label, values, onSave}: {
|
||||
label: string;
|
||||
values: readonly string[];
|
||||
onSave?: (values: string[]) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(values.join(", "));
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
if (!onSave) return <Tags values={values} />;
|
||||
if (!editing) return <div><Tags values={values} /><button type="button"
|
||||
onClick={() => { setDraft(values.join(", ")); setInvalid(false); setEditing(true); }}>
|
||||
Edit tags
|
||||
</button></div>;
|
||||
return <form className="hux-org-rename" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const tags = draft.split(",").map((tag) => tag.trim()).filter(Boolean);
|
||||
const valid = tags.length <= 32 && new Set(tags).size === tags.length &&
|
||||
tags.every((tag) => /^[a-z0-9][a-z0-9-]{0,39}$/.test(tag));
|
||||
setInvalid(!valid);
|
||||
if (valid) { onSave(tags); setEditing(false); }
|
||||
}}>
|
||||
<label><span>{label}</span>
|
||||
<input value={draft} aria-invalid={invalid} onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="planning, research" />
|
||||
</label>
|
||||
{invalid && <span role="alert">Use up to 32 lowercase tags separated by commas.</span>}
|
||||
<button type="submit">Save tags</button>
|
||||
<button type="button" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</form>;
|
||||
}
|
||||
|
||||
function ProjectList({projects, selectedId, onSelect, onRename, onTags, onPin}: {
|
||||
projects: readonly Project[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onRename?: OrganizationWorkspaceProps["onRenameProject"];
|
||||
onTags?: OrganizationWorkspaceProps["onUpdateProjectTags"];
|
||||
onPin?: OrganizationWorkspaceProps["onToggleProjectPin"];
|
||||
}) {
|
||||
return <nav className="hux-org-projects" aria-label="Project folders">
|
||||
<h2><Folder aria-hidden /> Projects</h2>
|
||||
<ul>
|
||||
<li><button type="button" aria-current={selectedId === null ? "page" : undefined}
|
||||
onClick={() => onSelect(null)}>All conversations</button></li>
|
||||
{ordered(projects).map((project) => <li key={project.id} data-pinned={project.pinned}>
|
||||
<button className="hux-org-folder" type="button"
|
||||
aria-current={selectedId === project.id ? "page" : undefined}
|
||||
onClick={() => onSelect(project.id)}>{project.name}</button>
|
||||
{onPin && <button className="hux-org-icon" type="button"
|
||||
aria-label={`${project.pinned ? "Unpin" : "Pin"} ${project.name}`}
|
||||
aria-pressed={project.pinned} onClick={() => onPin(project)}><Pin aria-hidden /></button>}
|
||||
{onRename && <EditableTitle label="Project name" value={project.name} maxLength={120}
|
||||
onSave={(name) => onRename(project, name)} />}
|
||||
<EditableTags label="Project tags" values={project.tags}
|
||||
onSave={onTags ? (tags) => onTags(project, tags) : undefined} />
|
||||
</li>)}
|
||||
</ul>
|
||||
</nav>;
|
||||
}
|
||||
|
||||
function Lineage({conversation, conversations}: {
|
||||
conversation: Conversation;
|
||||
conversations: readonly Conversation[];
|
||||
}) {
|
||||
const nodes = branchLineage(conversation.id, conversations);
|
||||
return <section className="hux-org-lineage" aria-labelledby="hux-org-lineage-title">
|
||||
<h3 id="hux-org-lineage-title"><GitBranch aria-hidden /> Branch lineage</h3>
|
||||
<ol>
|
||||
{nodes.map((node) => <li key={`${node.conversationId}-${node.depth}`}
|
||||
data-missing={node.missing} data-cycle={node.cycle}>
|
||||
<span>{node.title}</span>
|
||||
{node.branchPointMessageId && <small>branched at message {node.branchPointMessageId}</small>}
|
||||
</li>)}
|
||||
</ol>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Attachments({conversation, artifacts}: {
|
||||
conversation: Conversation;
|
||||
artifacts: readonly ArtifactSummary[];
|
||||
}) {
|
||||
const items = attachmentViews(conversation, artifacts);
|
||||
return <section className="hux-org-attachments" aria-labelledby="hux-org-attachments-title">
|
||||
<h3 id="hux-org-attachments-title"><Paperclip aria-hidden /> Attached artifacts</h3>
|
||||
{items.length ? <ul>{items.map((item) => <li key={item.id} data-missing={item.missing}>
|
||||
<span>{item.title}</span><small>{item.missing ? "Still attached; details unavailable" : item.kind}</small>
|
||||
</li>)}</ul> : <p>No artifacts attached.</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ConversationList({items, projects, selected, onSelect, onRename, onTags, onMove, onPin}: {
|
||||
items: readonly Conversation[];
|
||||
projects: readonly Project[];
|
||||
selected: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onRename?: OrganizationWorkspaceProps["onRenameConversation"];
|
||||
onTags?: OrganizationWorkspaceProps["onUpdateConversationTags"];
|
||||
onMove?: OrganizationWorkspaceProps["onMoveConversation"];
|
||||
onPin?: OrganizationWorkspaceProps["onToggleConversationPin"];
|
||||
}) {
|
||||
return <ul className="hux-org-conversations" aria-label="Conversations">
|
||||
{ordered(items).map((conversation) => <li key={conversation.id}
|
||||
data-selected={selected === conversation.id} data-pinned={conversation.pinned}>
|
||||
<div className="hux-org-conversation-title">
|
||||
<button type="button" onClick={() => onSelect(conversation.id)}
|
||||
aria-pressed={selected === conversation.id}>{conversation.title}</button>
|
||||
{onPin && <button className="hux-org-icon" type="button"
|
||||
aria-label={`${conversation.pinned ? "Unpin" : "Pin"} ${conversation.title}`}
|
||||
aria-pressed={conversation.pinned} onClick={() => onPin(conversation)}><Pin aria-hidden /></button>}
|
||||
</div>
|
||||
{onRename && <EditableTitle label="Conversation title" value={conversation.title} maxLength={200}
|
||||
onSave={(title) => onRename(conversation, title)} />}
|
||||
<EditableTags label="Conversation tags" values={conversation.tags}
|
||||
onSave={onTags ? (tags) => onTags(conversation, tags) : undefined} />
|
||||
<a className="hux-org-resume" href={`/session/${encodeURIComponent(conversation.id)}`}>
|
||||
Resume conversation
|
||||
</a>
|
||||
{onMove && <label className="hux-org-move">Move to
|
||||
<select value={conversation.projectId || ""}
|
||||
onChange={(event) => onMove(conversation, event.target.value || null)}>
|
||||
<option value="">No project</option>
|
||||
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
</li>)}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
/** Render nothing unless the negotiated hux.foundation and hux.projects flags are enabled. */
|
||||
export function OrganizationWorkspace(props: OrganizationWorkspaceProps) {
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const enabled = projectsEnabled(props.flags);
|
||||
const baseItems = props.searchResults || props.conversations;
|
||||
const items = useMemo(() => projectId === null ? baseItems :
|
||||
baseItems.filter((item) => item.projectId === projectId), [baseItems, projectId]);
|
||||
const selected = props.conversations.find((item) => item.id === selectedId) ||
|
||||
items[0] || null;
|
||||
if (!enabled) return null;
|
||||
|
||||
function submitSearch(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const value = query.trim();
|
||||
if (value) props.onSearch?.(value, null);
|
||||
}
|
||||
|
||||
return <section className="hux-organization" aria-label="Projects and conversations" aria-busy={props.busy}>
|
||||
<ProjectList projects={props.projects} selectedId={projectId} onSelect={setProjectId}
|
||||
onRename={props.onRenameProject} onTags={props.onUpdateProjectTags}
|
||||
onPin={props.onToggleProjectPin} />
|
||||
<main className="hux-org-main">
|
||||
<header><h2>Conversations</h2>
|
||||
<form role="search" onSubmit={submitSearch}>
|
||||
<label htmlFor="hux-org-search">Search conversations, messages, projects, and artifacts</label>
|
||||
<div><Search aria-hidden /><input id="hux-org-search" type="search" maxLength={200}
|
||||
value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<button type="submit" disabled={!query.trim() || props.busy}>Search</button></div>
|
||||
</form>
|
||||
</header>
|
||||
{props.error && <p className="hux-org-error" role="alert">{props.error}</p>}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{props.busy ? "Loading conversations" : `${items.length} conversations shown`}
|
||||
</p>
|
||||
<ConversationList items={items} projects={props.projects} selected={selected?.id || null}
|
||||
onSelect={setSelectedId} onRename={props.onRenameConversation}
|
||||
onTags={props.onUpdateConversationTags} onMove={props.onMoveConversation}
|
||||
onPin={props.onToggleConversationPin} />
|
||||
{!items.length && <p className="hux-org-empty">No matching conversations.</p>}
|
||||
{props.nextSearchCursor && props.onSearch && <button className="hux-org-more" type="button"
|
||||
disabled={props.busy} onClick={() => props.onSearch?.(query.trim(), props.nextSearchCursor || null)}>
|
||||
Load more results
|
||||
</button>}
|
||||
</main>
|
||||
<aside className="hux-org-details" aria-label="Conversation details">
|
||||
{selected ? <><Lineage conversation={selected} conversations={props.conversations} />
|
||||
<Attachments conversation={selected} artifacts={props.artifacts || []} /></> :
|
||||
<p>Select a conversation to inspect its lineage and attachments.</p>}
|
||||
</aside>
|
||||
</section>;
|
||||
}
|
||||
253
dockerfiles/hermes-webui-hux/organization/client.ts
Normal file
253
dockerfiles/hermes-webui-hux/organization/client.ts
Normal file
@ -0,0 +1,253 @@
|
||||
/** Same-origin, capability-gated transport for HUX-03. */
|
||||
|
||||
import { ACCEPT, API_VERSION, RESPONSE_SCHEMAS } from "./contracts.ts";
|
||||
import { normalizeConversation, normalizeProject, preservesConversation } from "./model.ts";
|
||||
import {
|
||||
boundedLimit,
|
||||
isOpaqueId,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
safeCursor,
|
||||
safeTags,
|
||||
sameIdentity,
|
||||
scopedPath,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationPatch,
|
||||
Page,
|
||||
Project,
|
||||
ProjectPatch,
|
||||
RawConversation,
|
||||
RawEnvelope,
|
||||
RawProject,
|
||||
ScopedIdentity,
|
||||
SearchFilters,
|
||||
} from "./types.ts";
|
||||
|
||||
interface FoundationClient {
|
||||
apiVersion: string;
|
||||
identity: ScopedIdentity;
|
||||
enabled(flag: string): boolean;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
interface FetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ClientOptions {
|
||||
client: FoundationClient;
|
||||
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
|
||||
}
|
||||
|
||||
interface RawMutationEnvelope {
|
||||
schema?: unknown;
|
||||
api_version?: unknown;
|
||||
identity?: unknown;
|
||||
item?: unknown;
|
||||
}
|
||||
|
||||
export class OrganizationContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OrganizationContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function assertAvailable(client: FoundationClient): void {
|
||||
if (client.apiVersion !== API_VERSION || !client.enabled("hux.foundation") ||
|
||||
!client.enabled("hux.projects")) {
|
||||
throw new OrganizationContractError("Project organization is not enabled");
|
||||
}
|
||||
}
|
||||
|
||||
function assertEnvelope(raw: unknown, schema: string, expected: ScopedIdentity): RawEnvelope {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new OrganizationContractError("Invalid organization response");
|
||||
}
|
||||
const value = raw as RawEnvelope;
|
||||
const identity = normalizeIdentity(value.identity);
|
||||
if (value.schema !== schema || value.api_version !== API_VERSION || !identity ||
|
||||
!sameIdentity(identity, expected)) {
|
||||
throw new OrganizationContractError("Organization response crossed its scope");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function pagination(raw: RawEnvelope): Pick<Page<never>, "nextCursor" | "total"> {
|
||||
const cursor = safeCursor(raw.next_cursor);
|
||||
if (cursor === undefined || (raw.total !== null && raw.total !== undefined &&
|
||||
(!Number.isSafeInteger(raw.total) || (raw.total as number) < 0))) {
|
||||
throw new OrganizationContractError("Invalid organization pagination");
|
||||
}
|
||||
return {nextCursor: cursor || null, total: raw.total as number | null | undefined ?? null};
|
||||
}
|
||||
|
||||
function pageQuery(cursor: string | null | undefined, limit: number | undefined): URLSearchParams {
|
||||
const query = new URLSearchParams();
|
||||
const safe = safeCursor(cursor);
|
||||
if (safe === undefined) throw new TypeError("Invalid organization cursor");
|
||||
if (safe) query.set("cursor", safe);
|
||||
query.set("limit", String(boundedLimit(limit)));
|
||||
return query;
|
||||
}
|
||||
|
||||
function projectPage(raw: unknown, expected: ScopedIdentity): Page<Project> {
|
||||
const value = assertEnvelope(raw, RESPONSE_SCHEMAS.projects, expected);
|
||||
if (!Array.isArray(value.items) || value.items.length > 100) {
|
||||
throw new OrganizationContractError("Project page exceeds its bound");
|
||||
}
|
||||
const items = value.items.map((item) => normalizeProject(item as RawProject, expected.userRef));
|
||||
if (items.some((item) => item === null) ||
|
||||
new Set(items.map((item) => item?.id)).size !== items.length) {
|
||||
throw new OrganizationContractError("Invalid project record");
|
||||
}
|
||||
return {...pagination(value), items: items as Project[]};
|
||||
}
|
||||
|
||||
function conversationPage(
|
||||
raw: unknown,
|
||||
schema: string,
|
||||
expected: ScopedIdentity,
|
||||
projectIds: ReadonlySet<string>,
|
||||
): Page<Conversation> {
|
||||
const value = assertEnvelope(raw, schema, expected);
|
||||
if (!Array.isArray(value.items) || value.items.length > 100) {
|
||||
throw new OrganizationContractError("Conversation page exceeds its bound");
|
||||
}
|
||||
const items = value.items.map((item) =>
|
||||
normalizeConversation(item as RawConversation, expected.userRef, projectIds));
|
||||
if (items.some((item) => item === null) ||
|
||||
new Set(items.map((item) => item?.id)).size !== items.length) {
|
||||
throw new OrganizationContractError("Invalid conversation record");
|
||||
}
|
||||
return {...pagination(value), items: items as Conversation[]};
|
||||
}
|
||||
|
||||
function mutationItem(
|
||||
raw: unknown,
|
||||
schema: string,
|
||||
expected: ScopedIdentity,
|
||||
): unknown {
|
||||
const value = assertEnvelope(raw, schema, expected) as RawMutationEnvelope;
|
||||
if (!("item" in value) || value.item === undefined) {
|
||||
throw new OrganizationContractError("Mutation response has no item");
|
||||
}
|
||||
return value.item;
|
||||
}
|
||||
|
||||
export function createOrganizationClient(options: ClientOptions) {
|
||||
const client = options.client;
|
||||
const fetcher = options.fetcher || (typeof fetch === "function" ? fetch.bind(globalThis) : null);
|
||||
if (!client || !fetcher) throw new TypeError("Organization client requires HUX and fetch clients");
|
||||
const identity = client.identity;
|
||||
|
||||
async function request(path: string, init: Record<string, unknown> = {}): Promise<unknown> {
|
||||
assertAvailable(client);
|
||||
const response = await fetcher(client.endpoint(path), {
|
||||
cache: "no-store", credentials: "same-origin",
|
||||
headers: {Accept: ACCEPT, ...(init.body ? {"Content-Type": "application/json"} : {})},
|
||||
...init,
|
||||
});
|
||||
if (!response.ok) throw new OrganizationContractError(
|
||||
response.status === 409 ? "This item changed; refresh before trying again" :
|
||||
`Organization request failed (${response.status})`,
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function listProjects(cursor?: string | null, limit?: number): Promise<Page<Project>> {
|
||||
const query = pageQuery(cursor, limit);
|
||||
return projectPage(await request(`/projects?${query}`), identity);
|
||||
}
|
||||
|
||||
async function listConversations(
|
||||
projectId: string,
|
||||
projectIds: ReadonlySet<string>,
|
||||
cursor?: string | null,
|
||||
limit?: number,
|
||||
): Promise<Page<Conversation>> {
|
||||
if (!projectIds.has(projectId)) throw new TypeError("Project is outside this workspace");
|
||||
const path = `${scopedPath("/projects", projectId)}/conversations?${pageQuery(cursor, limit)}`;
|
||||
const page = conversationPage(await request(path), RESPONSE_SCHEMAS.conversations, identity, projectIds);
|
||||
if (page.items.some((item) => item.projectId !== projectId)) {
|
||||
throw new OrganizationContractError("Conversation crossed its project boundary");
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
async function searchConversations(
|
||||
filters: SearchFilters,
|
||||
projectIds: ReadonlySet<string>,
|
||||
): Promise<Page<Conversation>> {
|
||||
const text = typeof filters.query === "string" ? filters.query.trim() : "";
|
||||
if (!text || text.length > 200) throw new TypeError("Search query must be 1 to 200 characters");
|
||||
const query = pageQuery(filters.cursor, filters.limit);
|
||||
query.set("q", text);
|
||||
if (filters.projectId) {
|
||||
if (!projectIds.has(filters.projectId)) throw new TypeError("Search project is outside this workspace");
|
||||
query.set("project_id", filters.projectId);
|
||||
}
|
||||
const tags = safeTags(filters.tags || []);
|
||||
if (tags === null) throw new TypeError("Search tags are invalid");
|
||||
tags.forEach((tag) => query.append("tag", tag));
|
||||
if (filters.pinned !== undefined) query.set("pinned", String(filters.pinned));
|
||||
return conversationPage(await request(`/conversations/search?${query}`),
|
||||
RESPONSE_SCHEMAS.search, identity, projectIds);
|
||||
}
|
||||
|
||||
async function updateProject(before: Project, patch: ProjectPatch): Promise<Project> {
|
||||
if (before.owner !== identity.userRef || !isUtc(patch.expectedUpdatedAt)) {
|
||||
throw new TypeError("Project mutation is outside this workspace");
|
||||
}
|
||||
const tags = patch.tags === undefined ? undefined : safeTags(patch.tags);
|
||||
const name = patch.name === undefined ? undefined : patch.name.trim();
|
||||
if (tags === null || (name !== undefined && (!name || name.length > 120))) {
|
||||
throw new TypeError("Project mutation is invalid");
|
||||
}
|
||||
const body = {expected_updated_at: patch.expectedUpdatedAt,
|
||||
...(name !== undefined ? {name} : {}), ...(tags !== undefined ? {tags} : {}),
|
||||
...(patch.pinned !== undefined ? {pinned: patch.pinned} : {})};
|
||||
const raw = mutationItem(await request(scopedPath("/projects", before.id),
|
||||
{method: "PATCH", body: JSON.stringify(body)}), RESPONSE_SCHEMAS.projectMutation, identity);
|
||||
const item = normalizeProject(raw as RawProject, identity.userRef);
|
||||
if (!item || item.id !== before.id) throw new OrganizationContractError("Project identity changed during mutation");
|
||||
return item;
|
||||
}
|
||||
|
||||
async function updateConversation(
|
||||
before: Conversation,
|
||||
patch: ConversationPatch,
|
||||
projectIds: ReadonlySet<string>,
|
||||
): Promise<Conversation> {
|
||||
if (before.owner !== identity.userRef || !isUtc(patch.expectedUpdatedAt) ||
|
||||
(before.projectId !== null && !projectIds.has(before.projectId)) ||
|
||||
(patch.projectId !== undefined && patch.projectId !== null && !projectIds.has(patch.projectId))) {
|
||||
throw new TypeError("Conversation mutation is outside this workspace");
|
||||
}
|
||||
const tags = patch.tags === undefined ? undefined : safeTags(patch.tags);
|
||||
const title = patch.title === undefined ? undefined : patch.title.trim();
|
||||
if (tags === null || (title !== undefined && (!title || title.length > 200))) {
|
||||
throw new TypeError("Conversation mutation is invalid");
|
||||
}
|
||||
const body = {expected_updated_at: patch.expectedUpdatedAt,
|
||||
...(title !== undefined ? {title} : {}),
|
||||
...(patch.projectId !== undefined ? {project_id: patch.projectId} : {}),
|
||||
...(tags !== undefined ? {tags} : {}),
|
||||
...(patch.pinned !== undefined ? {pinned: patch.pinned} : {})};
|
||||
const raw = mutationItem(await request(scopedPath("/conversations", before.id),
|
||||
{method: "PATCH", body: JSON.stringify(body)}), RESPONSE_SCHEMAS.conversationMutation, identity);
|
||||
const item = normalizeConversation(raw as RawConversation, identity.userRef, projectIds);
|
||||
const intendedProject = patch.projectId === undefined ? before.projectId : patch.projectId;
|
||||
if (!item || item.projectId !== intendedProject || !preservesConversation(before, item)) {
|
||||
throw new OrganizationContractError("Conversation lineage or attachments changed during mutation");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
return Object.freeze({listConversations, listProjects, searchConversations,
|
||||
updateConversation, updateProject});
|
||||
}
|
||||
38
dockerfiles/hermes-webui-hux/organization/contracts.ts
Normal file
38
dockerfiles/hermes-webui-hux/organization/contracts.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/** Exact same-origin backend contracts required by the HUX-03 frontend. */
|
||||
|
||||
export const API_VERSION = "hux.v1";
|
||||
export const ACCEPT = "application/vnd.hermes.hux+json; version=1";
|
||||
|
||||
export const RESPONSE_SCHEMAS = Object.freeze({
|
||||
projects: "hux.projects.page.v1",
|
||||
conversations: "hux.conversations.page.v1",
|
||||
search: "hux.conversations.search.v1",
|
||||
projectMutation: "hux.project.response.v1",
|
||||
conversationMutation: "hux.conversation.response.v1",
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /hux/v1/projects?cursor=&limit=
|
||||
* GET /hux/v1/projects/{prj_id}/conversations?cursor=&limit=
|
||||
* GET /hux/v1/conversations/search?q=&project_id=&tag=&pinned=&cursor=&limit=
|
||||
* PATCH /hux/v1/projects/{prj_id}
|
||||
* PATCH /hux/v1/conversations/{conv_id}
|
||||
*
|
||||
* Every response is JSON with api_version="hux.v1", its operation-specific
|
||||
* schema, and identity={tenant_ref,user_ref,surface}. Page responses contain
|
||||
* items, next_cursor (opaque or null), and total (non-negative integer or
|
||||
* null). Mutation responses contain item. PATCH bodies use only name/title,
|
||||
* project_id, tags, pinned, and required expected_updated_at for optimistic
|
||||
* concurrency. Identity is derived from the authenticated server session and
|
||||
* MUST NOT be accepted from a query, path, body, or browser storage. Moves MUST
|
||||
* preserve conversation id, branch, and artifact_ids atomically. A 409 means
|
||||
* stale expected_updated_at; 404 is not converted into an unscoped lookup.
|
||||
*/
|
||||
export const BACKEND_ENDPOINTS = Object.freeze({
|
||||
projects: "/projects",
|
||||
projectConversations: "/projects/{prj_id}/conversations",
|
||||
search: "/conversations/search",
|
||||
project: "/projects/{prj_id}",
|
||||
conversation: "/conversations/{conv_id}",
|
||||
});
|
||||
|
||||
14
dockerfiles/hermes-webui-hux/organization/index.ts
Normal file
14
dockerfiles/hermes-webui-hux/organization/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export { createOrganizationClient, OrganizationContractError } from "./client.ts";
|
||||
export { OrganizationWorkspace } from "./OrganizationWorkspace.tsx";
|
||||
export {
|
||||
attachmentViews,
|
||||
branchLineage,
|
||||
normalizeConversation,
|
||||
normalizeProject,
|
||||
normalizeSnapshot,
|
||||
preservesConversation,
|
||||
projectsEnabled,
|
||||
} from "./model.ts";
|
||||
export { BACKEND_ENDPOINTS, RESPONSE_SCHEMAS } from "./contracts.ts";
|
||||
export type * from "./types.ts";
|
||||
|
||||
165
dockerfiles/hermes-webui-hux/organization/model.ts
Normal file
165
dockerfiles/hermes-webui-hux/organization/model.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/** Schema-aligned normalization and lineage invariants for HUX-03. */
|
||||
|
||||
import { isOpaqueId, isUtc, safeTags, safeText } from "./security.ts";
|
||||
import type {
|
||||
ArtifactSummary,
|
||||
AttachmentView,
|
||||
Conversation,
|
||||
LineageNode,
|
||||
OrganizationSnapshot,
|
||||
Project,
|
||||
RawConversation,
|
||||
RawProject,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const PROJECTS_FLAG = "hux.projects";
|
||||
const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]);
|
||||
|
||||
export function projectsEnabled(flags?: Iterable<string>): boolean {
|
||||
const enabled = new Set(flags || []);
|
||||
return enabled.has(FOUNDATION_FLAG) && enabled.has(PROJECTS_FLAG);
|
||||
}
|
||||
|
||||
export function normalizeProject(raw: RawProject, owner: string): Project | null {
|
||||
const tags = safeTags(raw.tags);
|
||||
if (raw.schema !== "hux.project.v1" || !isOpaqueId(raw.id, "prj") ||
|
||||
raw.owner !== owner || tags === null || typeof raw.pinned !== "boolean" ||
|
||||
typeof raw.archived !== "boolean" || !isUtc(raw.created_at) ||
|
||||
!isUtc(raw.updated_at) || (raw.default_mode !== undefined &&
|
||||
(typeof raw.default_mode !== "string" || !MODES.has(raw.default_mode)))) return null;
|
||||
const name = safeText(raw.name, "", 120);
|
||||
if (!name) return null;
|
||||
return {
|
||||
id: raw.id, owner, name,
|
||||
description: safeText(raw.description, "", 2000), tags,
|
||||
pinned: raw.pinned, archived: raw.archived, updatedAt: raw.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBranch(raw: unknown): Conversation["branch"] | undefined {
|
||||
if (raw === undefined) return null;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (!isOpaqueId(value.parent_conversation_id, "conv") ||
|
||||
typeof value.branch_point_message_id !== "string") return undefined;
|
||||
const messageId = safeText(value.branch_point_message_id, "", 120);
|
||||
return messageId ? {
|
||||
parentConversationId: value.parent_conversation_id,
|
||||
branchPointMessageId: messageId,
|
||||
} : undefined;
|
||||
}
|
||||
|
||||
export function normalizeConversation(
|
||||
raw: RawConversation,
|
||||
owner: string,
|
||||
projectIds: ReadonlySet<string>,
|
||||
): Conversation | null {
|
||||
const tags = safeTags(raw.tags);
|
||||
const branch = normalizeBranch(raw.branch);
|
||||
const artifacts = Array.isArray(raw.artifact_ids) ? raw.artifact_ids : null;
|
||||
const projectId = raw.project_id === undefined ? null : raw.project_id;
|
||||
if (raw.schema !== "hux.conversation.v1" || !isOpaqueId(raw.id, "conv") ||
|
||||
raw.owner !== owner || tags === null || branch === undefined ||
|
||||
typeof raw.pinned !== "boolean" || typeof raw.archived !== "boolean" ||
|
||||
!artifacts || artifacts.length > 500 || new Set(artifacts).size !== artifacts.length ||
|
||||
artifacts.some((id) => !isOpaqueId(id, "art")) || !isUtc(raw.created_at) ||
|
||||
!isUtc(raw.updated_at) || (raw.last_message_at !== undefined &&
|
||||
!isUtc(raw.last_message_at)) || (raw.mode !== undefined &&
|
||||
(typeof raw.mode !== "string" || !MODES.has(raw.mode))) ||
|
||||
(projectId !== null && (!isOpaqueId(projectId, "prj") || !projectIds.has(projectId)))) return null;
|
||||
const title = safeText(raw.title, "", 200);
|
||||
if (!title || branch?.parentConversationId === raw.id) return null;
|
||||
return {
|
||||
id: raw.id, owner, projectId, title, tags, pinned: raw.pinned,
|
||||
archived: raw.archived, artifactIds: [...artifacts] as string[], branch,
|
||||
lastMessageAt: raw.last_message_at as string | undefined || null,
|
||||
updatedAt: raw.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject cross-owner/project records instead of silently re-homing them. */
|
||||
export function normalizeSnapshot(
|
||||
rawProjects: readonly RawProject[],
|
||||
rawConversations: readonly RawConversation[],
|
||||
owner: string,
|
||||
): OrganizationSnapshot {
|
||||
const seenProjects = new Set<string>();
|
||||
const projects = rawProjects.slice(0, 100).map((item) => normalizeProject(item, owner))
|
||||
.filter((item): item is Project => {
|
||||
if (!item || seenProjects.has(item.id)) return false;
|
||||
seenProjects.add(item.id);
|
||||
return true;
|
||||
});
|
||||
const projectIds = new Set(projects.map((item) => item.id));
|
||||
const seenConversations = new Set<string>();
|
||||
const conversations = rawConversations.slice(0, 500)
|
||||
.map((item) => normalizeConversation(item, owner, projectIds))
|
||||
.filter((item): item is Conversation => {
|
||||
if (!item || seenConversations.has(item.id)) return false;
|
||||
seenConversations.add(item.id);
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
projects, conversations,
|
||||
rejected: Math.max(0, rawProjects.length - projects.length) +
|
||||
Math.max(0, rawConversations.length - conversations.length),
|
||||
};
|
||||
}
|
||||
|
||||
/** Missing metadata stays visible as an attachment placeholder. */
|
||||
export function attachmentViews(
|
||||
conversation: Conversation,
|
||||
summaries: readonly ArtifactSummary[],
|
||||
): AttachmentView[] {
|
||||
const byId = new Map(summaries.filter((item) =>
|
||||
isOpaqueId(item.id, "art")).slice(0, 500).map((item) => [item.id, item]));
|
||||
return conversation.artifactIds.map((id) => {
|
||||
const found = byId.get(id);
|
||||
return found ? {
|
||||
id, title: safeText(found.title, "Untitled artifact", 200),
|
||||
kind: safeText(found.kind, "artifact", 40), missing: false,
|
||||
} : {id, title: "Attachment details unavailable", kind: "artifact", missing: true};
|
||||
});
|
||||
}
|
||||
|
||||
/** Build bounded ancestry with explicit missing/cycle nodes, never a fake root. */
|
||||
export function branchLineage(
|
||||
selectedId: string,
|
||||
conversations: readonly Conversation[],
|
||||
): LineageNode[] {
|
||||
if (!isOpaqueId(selectedId, "conv")) return [];
|
||||
const byId = new Map(conversations.slice(0, 500).map((item) => [item.id, item]));
|
||||
const result: LineageNode[] = [];
|
||||
const visited = new Set<string>();
|
||||
let currentId: string | null = selectedId;
|
||||
while (currentId && result.length < 64) {
|
||||
if (visited.has(currentId)) {
|
||||
result.push({conversationId: currentId, title: "Circular branch reference", parentConversationId: null,
|
||||
branchPointMessageId: null, depth: result.length, missing: false, cycle: true});
|
||||
break;
|
||||
}
|
||||
visited.add(currentId);
|
||||
const item = byId.get(currentId);
|
||||
if (!item) {
|
||||
result.push({conversationId: currentId, title: "Earlier conversation unavailable",
|
||||
parentConversationId: null, branchPointMessageId: null, depth: result.length,
|
||||
missing: true, cycle: false});
|
||||
break;
|
||||
}
|
||||
result.push({conversationId: item.id, title: item.title,
|
||||
parentConversationId: item.branch?.parentConversationId || null,
|
||||
branchPointMessageId: item.branch?.branchPointMessageId || null,
|
||||
depth: result.length, missing: false, cycle: false});
|
||||
currentId = item.branch?.parentConversationId || null;
|
||||
}
|
||||
return result.reverse().map((item, depth) => ({...item, depth}));
|
||||
}
|
||||
|
||||
/** A move/rename may not change stable lineage or orphan any attachment. */
|
||||
export function preservesConversation(before: Conversation, after: Conversation): boolean {
|
||||
return before.id === after.id && before.owner === after.owner &&
|
||||
JSON.stringify(before.branch) === JSON.stringify(after.branch) &&
|
||||
before.artifactIds.length === after.artifactIds.length &&
|
||||
before.artifactIds.every((id, index) => after.artifactIds[index] === id);
|
||||
}
|
||||
78
dockerfiles/hermes-webui-hux/organization/security.ts
Normal file
78
dockerfiles/hermes-webui-hux/organization/security.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/** Identity, text, URL, and collection guards for HUX-03. */
|
||||
|
||||
import type { RawIdentity, ScopedIdentity } from "./types.ts";
|
||||
|
||||
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
|
||||
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
|
||||
const USER = /^usr_[0-9a-f]{16,64}$/;
|
||||
const CURSOR = /^[A-Za-z0-9._~-]{1,256}$/;
|
||||
const TAG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
||||
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/g;
|
||||
const SURFACES = new Set(["chat", "worker", "telegram", "voice", "api"]);
|
||||
|
||||
export function isOpaqueId(value: unknown, prefix?: string): value is string {
|
||||
return typeof value === "string" && ID.test(value) &&
|
||||
(!prefix || value.startsWith(`${prefix}_`));
|
||||
}
|
||||
|
||||
export function isUtc(value: unknown): value is string {
|
||||
return typeof value === "string" && UTC.test(value) &&
|
||||
!Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
export function safeText(value: unknown, fallback: string, limit: number): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const clean = value.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
||||
if (!clean) return fallback;
|
||||
return clean.length <= limit ? clean : `${clean.slice(0, limit - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function safeTags(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value) || value.length > 32) return null;
|
||||
const tags = value.filter((tag): tag is string => typeof tag === "string");
|
||||
if (tags.length !== value.length || tags.some((tag) => !TAG.test(tag))) return null;
|
||||
const unique = [...new Set(tags)];
|
||||
return unique.length === tags.length ? unique : null;
|
||||
}
|
||||
|
||||
export function normalizeIdentity(raw: unknown): ScopedIdentity | null {
|
||||
const value = raw as RawIdentity | null;
|
||||
if (!value || typeof value !== "object") return null;
|
||||
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref)) return null;
|
||||
if (typeof value.user_ref !== "string" || !USER.test(value.user_ref)) return null;
|
||||
if (typeof value.surface !== "string" || !SURFACES.has(value.surface)) return null;
|
||||
return {
|
||||
tenantRef: value.tenant_ref,
|
||||
userRef: value.user_ref,
|
||||
surface: value.surface as ScopedIdentity["surface"],
|
||||
};
|
||||
}
|
||||
|
||||
export function sameIdentity(left: ScopedIdentity, right: ScopedIdentity): boolean {
|
||||
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
|
||||
left.surface === right.surface;
|
||||
}
|
||||
|
||||
export function safeCursor(value: unknown): string | null | undefined {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return typeof value === "string" && CURSOR.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function scopedPath(base: string, ...parts: string[]): string {
|
||||
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) || base.includes("..")) {
|
||||
throw new TypeError("HUX base URL must be a same-origin path");
|
||||
}
|
||||
const cleanBase = base.replace(/\/$/, "");
|
||||
const encoded = parts.map((part) => {
|
||||
if (!isOpaqueId(part)) throw new TypeError("HUX path identifier is invalid");
|
||||
return encodeURIComponent(part);
|
||||
});
|
||||
return [cleanBase, ...encoded].join("/");
|
||||
}
|
||||
|
||||
export function boundedLimit(value: unknown, fallback = 40): number {
|
||||
return Number.isInteger(value) && (value as number) > 0 ?
|
||||
Math.min(value as number, 100) : fallback;
|
||||
}
|
||||
|
||||
89
dockerfiles/hermes-webui-hux/organization/styles.css
Normal file
89
dockerfiles/hermes-webui-hux/organization/styles.css
Normal file
@ -0,0 +1,89 @@
|
||||
.hux-organization {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 0.7fr) minmax(18rem, 1.5fr) minmax(14rem, 1fr);
|
||||
gap: 0.75rem;
|
||||
color: var(--hux-fg, #dbe8ef);
|
||||
}
|
||||
|
||||
.hux-organization > * {
|
||||
min-width: 0;
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
background: color-mix(in srgb, var(--hux-bg, #0b121c) 94%, transparent);
|
||||
}
|
||||
|
||||
.hux-organization h2,
|
||||
.hux-organization h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.hux-organization svg { width: 1rem; height: 1rem; }
|
||||
.hux-organization ul,
|
||||
.hux-organization ol { margin: 0; padding: 0; list-style: none; }
|
||||
.hux-org-projects li,
|
||||
.hux-org-conversations > li,
|
||||
.hux-org-lineage li,
|
||||
.hux-org-attachments li { margin-top: 0.45rem; }
|
||||
|
||||
.hux-org-projects button[aria-current="page"],
|
||||
.hux-org-conversations li[data-selected="true"] {
|
||||
color: var(--hux-accent, #22d3ee);
|
||||
background: color-mix(in srgb, var(--hux-accent, #22d3ee) 9%, transparent);
|
||||
}
|
||||
|
||||
.hux-org-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.hux-org-tags li {
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.hux-org-conversation-title,
|
||||
.hux-org-main form div { display: flex; gap: 0.4rem; align-items: center; }
|
||||
.hux-org-icon { padding: 0.2rem; }
|
||||
.hux-org-icon[aria-pressed="false"] { opacity: 0.45; }
|
||||
.hux-org-resume,
|
||||
.hux-org-move { display: block; margin-top: 0.35rem; font-size: 0.75rem; }
|
||||
.hux-org-move select { margin-left: 0.4rem; }
|
||||
.hux-org-rename { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
|
||||
|
||||
.hux-org-lineage li { position: relative; padding-left: 1rem; }
|
||||
.hux-org-lineage li::before {
|
||||
position: absolute;
|
||||
left: 0.2rem;
|
||||
top: -0.5rem;
|
||||
bottom: -0.5rem;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--hux-accent, #22d3ee) 50%, transparent);
|
||||
content: "";
|
||||
}
|
||||
.hux-org-lineage li::after {
|
||||
position: absolute; left: 0; top: 0.45rem; width: 0.45rem; height: 0.45rem;
|
||||
border-radius: 50%; background: var(--hux-accent, #22d3ee); content: "";
|
||||
}
|
||||
.hux-org-lineage small,
|
||||
.hux-org-attachments small { display: block; opacity: 0.6; }
|
||||
.hux-org-lineage li[data-missing="true"],
|
||||
.hux-org-lineage li[data-cycle="true"],
|
||||
.hux-org-attachments li[data-missing="true"] { color: var(--hux-warning, #fbbf24); }
|
||||
.hux-org-error { color: var(--hux-danger, #fb7185); }
|
||||
|
||||
@media (max-width: 62rem) {
|
||||
.hux-organization { grid-template-columns: minmax(10rem, 0.7fr) minmax(18rem, 1.5fr); }
|
||||
.hux-org-details { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.hux-organization { grid-template-columns: 1fr; }
|
||||
.hux-org-details { grid-column: auto; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-organization * { scroll-behavior: auto; transition: none; }
|
||||
}
|
||||
|
||||
143
dockerfiles/hermes-webui-hux/organization/types.ts
Normal file
143
dockerfiles/hermes-webui-hux/organization/types.ts
Normal file
@ -0,0 +1,143 @@
|
||||
/** Safe frontend and transport types for HUX-03 project organization. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
|
||||
export interface ScopedIdentity {
|
||||
tenantRef: string;
|
||||
userRef: string;
|
||||
surface: HuxSurface;
|
||||
}
|
||||
|
||||
export interface RawIdentity {
|
||||
tenant_ref?: unknown;
|
||||
user_ref?: unknown;
|
||||
surface?: unknown;
|
||||
}
|
||||
|
||||
export interface RawProject {
|
||||
schema?: unknown;
|
||||
id?: unknown;
|
||||
owner?: unknown;
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
tags?: unknown;
|
||||
pinned?: unknown;
|
||||
archived?: unknown;
|
||||
default_mode?: unknown;
|
||||
memory_scope_id?: unknown;
|
||||
created_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
}
|
||||
|
||||
export interface RawConversation {
|
||||
schema?: unknown;
|
||||
id?: unknown;
|
||||
owner?: unknown;
|
||||
project_id?: unknown;
|
||||
title?: unknown;
|
||||
tags?: unknown;
|
||||
pinned?: unknown;
|
||||
archived?: unknown;
|
||||
mode?: unknown;
|
||||
branch?: unknown;
|
||||
artifact_ids?: unknown;
|
||||
last_message_at?: unknown;
|
||||
created_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ConversationBranch {
|
||||
parentConversationId: string;
|
||||
branchPointMessageId: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
owner: string;
|
||||
projectId: string | null;
|
||||
title: string;
|
||||
tags: string[];
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
artifactIds: string[];
|
||||
branch: ConversationBranch | null;
|
||||
lastMessageAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ArtifactSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface AttachmentView extends ArtifactSummary {
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export interface LineageNode {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
parentConversationId: string | null;
|
||||
branchPointMessageId: string | null;
|
||||
depth: number;
|
||||
missing: boolean;
|
||||
cycle: boolean;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export interface RawEnvelope {
|
||||
schema?: unknown;
|
||||
api_version?: unknown;
|
||||
identity?: unknown;
|
||||
items?: unknown;
|
||||
next_cursor?: unknown;
|
||||
total?: unknown;
|
||||
}
|
||||
|
||||
export interface OrganizationSnapshot {
|
||||
projects: Project[];
|
||||
conversations: Conversation[];
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
query: string;
|
||||
projectId?: string | null;
|
||||
tags?: readonly string[];
|
||||
pinned?: boolean;
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ConversationPatch {
|
||||
title?: string;
|
||||
projectId?: string | null;
|
||||
tags?: readonly string[];
|
||||
pinned?: boolean;
|
||||
expectedUpdatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectPatch {
|
||||
name?: string;
|
||||
tags?: readonly string[];
|
||||
pinned?: boolean;
|
||||
expectedUpdatedAt: string;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user