hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10 plus node+pytest suites that read the hux.v1 contract schemas directly. Reconciled drift found on integration: the activity model now accepts all 32 hux.event.v1 kinds (delegation.*, memory.suppressed, memory.retrieval_removed, budget.exhausted, side_effect.*), the autonomy model carries the external_side_effect capability, and the foundation boundary test now asserts the shipped static HUX surface exists on disk and that images never bake activated HUX_FLAGS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
parent
47165274f0
commit
b66c762f5d
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;
|
||||
}
|
||||
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[];
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
165
dockerfiles/hermes-webui-hux/privacy/PrivacyCenter.tsx
Normal file
165
dockerfiles/hermes-webui-hux/privacy/PrivacyCenter.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
/** Accessible HUX-10 privacy status and explicit conversation controls. */
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
import {privacyControls, privacyEnabled} from "./model.ts";
|
||||
import type {
|
||||
PrivacyClient,
|
||||
PrivacyControl,
|
||||
PrivacySnapshot,
|
||||
} from "./types.ts";
|
||||
|
||||
interface PrivacyCenterProps {
|
||||
flags?: Iterable<string>;
|
||||
client: PrivacyClient;
|
||||
snapshot: PrivacySnapshot | null;
|
||||
disabled?: boolean;
|
||||
onUpdated(snapshot: PrivacySnapshot, action: PrivacyControl): void;
|
||||
}
|
||||
|
||||
const CONTROL_LABELS: Readonly<Record<PrivacyControl, string>> = {
|
||||
enable_no_store: "Use no-store here",
|
||||
disable_no_store: "Use standard retention",
|
||||
forget_this_conversation: "Forget this conversation",
|
||||
switch_to_private: "Switch to Private mode",
|
||||
disable_memory_here: "Disable memory here",
|
||||
dismiss: "Dismiss notice",
|
||||
};
|
||||
|
||||
function readableTime(timestamp: string): string {
|
||||
return timestamp.replace("T", " ").replace("Z", " UTC");
|
||||
}
|
||||
|
||||
/** Render nothing unless both HUX-11 and HUX-10 are explicitly enabled. */
|
||||
export function PrivacyCenter(props: PrivacyCenterProps) {
|
||||
const [busy, setBusy] = useState<PrivacyControl | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmForget, setConfirmForget] = useState(false);
|
||||
const enabled = privacyEnabled(props.flags) && props.client?.enabled();
|
||||
const snapshot = props.snapshot;
|
||||
|
||||
useEffect(() => {
|
||||
setBusy(null);
|
||||
setError("");
|
||||
setConfirmForget(false);
|
||||
}, [snapshot?.revision]);
|
||||
|
||||
if (!enabled || !snapshot) return null;
|
||||
const controls = privacyControls(snapshot);
|
||||
const ordinaryControls = controls.filter((control) =>
|
||||
control !== "forget_this_conversation" && control !== "dismiss");
|
||||
|
||||
async function apply(action: PrivacyControl): Promise<void> {
|
||||
if (busy || props.disabled) return;
|
||||
setBusy(action);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await props.client.control(snapshot, action);
|
||||
props.onUpdated(updated, action);
|
||||
} catch (_) {
|
||||
setError("The privacy change could not be verified. Nothing was changed in this view.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setConfirmForget(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="hux-privacy" aria-labelledby="hux-privacy-heading">
|
||||
<header className="hux-privacy-heading">
|
||||
<div>
|
||||
<span className="hux-privacy-kicker">Conversation privacy</span>
|
||||
<h2 id="hux-privacy-heading">What Hermes keeps</h2>
|
||||
</div>
|
||||
<span className={`hux-privacy-state ${snapshot.noStore ? "is-private" : ""}`}
|
||||
aria-live="polite">
|
||||
{snapshot.noStore ? "No-store active" : "Standard retention"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{snapshot.notice && (
|
||||
<aside className="hux-privacy-notice" aria-labelledby="hux-privacy-notice-title">
|
||||
<h3 id="hux-privacy-notice-title">Sensitive-topic protection</h3>
|
||||
<p>{snapshot.notice.text}</p>
|
||||
{snapshot.notice.controls.includes("dismiss") && (
|
||||
<button type="button" disabled={Boolean(busy) || props.disabled}
|
||||
onClick={() => void apply("dismiss")}>Dismiss notice</button>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<dl className="hux-privacy-retention" aria-label="Effective retention">
|
||||
<div>
|
||||
<dt>Conversation</dt>
|
||||
<dd>{snapshot.noStore ? "Session only; not retained afterward" :
|
||||
"Retained under the workspace policy"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Memory</dt>
|
||||
<dd>{snapshot.memoryEnabled ? "Ask before saving sensitive facts" :
|
||||
"Memory writes disabled here"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Sensitive context</dt>
|
||||
<dd>{snapshot.retention.topicContextDecayDays === null ?
|
||||
"Not retained beyond this session" :
|
||||
`Minimized within ${snapshot.retention.topicContextDecayDays} days`}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>After the topic changes</dt>
|
||||
<dd>Previous sensitive details are not repeated</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Across surfaces</dt>
|
||||
<dd>{snapshot.retention.crossSurfaceSharing === "never" ?
|
||||
"Never shared" : "Same owner only"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Retention audit</dt>
|
||||
<dd>Every {snapshot.retention.auditIntervalDays} days; next{" "}
|
||||
<time dateTime={snapshot.retention.nextAuditAt}>
|
||||
{readableTime(snapshot.retention.nextAuditAt)}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="hux-privacy-controls" aria-busy={Boolean(busy)}>
|
||||
{ordinaryControls.map((control) => (
|
||||
<button key={control} type="button" disabled={Boolean(busy) || props.disabled}
|
||||
onClick={() => void apply(control)}>
|
||||
{CONTROL_LABELS[control]}
|
||||
</button>
|
||||
))}
|
||||
{controls.includes("forget_this_conversation") && !confirmForget && (
|
||||
<button type="button" className="hux-privacy-danger"
|
||||
disabled={Boolean(busy) || props.disabled} onClick={() => setConfirmForget(true)}>
|
||||
Forget this conversation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmForget && (
|
||||
<div className="hux-privacy-confirm" role="group"
|
||||
aria-labelledby="hux-privacy-confirm-title">
|
||||
<strong id="hux-privacy-confirm-title">Remove this conversation and its saved context?</strong>
|
||||
<span>This cannot be undone.</span>
|
||||
<div>
|
||||
<button type="button" className="hux-privacy-danger"
|
||||
disabled={Boolean(busy) || props.disabled}
|
||||
onClick={() => void apply("forget_this_conversation")}>
|
||||
Confirm forget
|
||||
</button>
|
||||
<button type="button" disabled={Boolean(busy)}
|
||||
onClick={() => setConfirmForget(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hux-privacy-status" role="status" aria-live="polite">
|
||||
{busy ? "Applying privacy control…" : ""}
|
||||
</div>
|
||||
{error && <p className="hux-privacy-error" role="alert">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
130
dockerfiles/hermes-webui-hux/privacy/client.ts
Normal file
130
dockerfiles/hermes-webui-hux/privacy/client.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/** Same-origin, scope-bound transport for HUX-10 privacy state. */
|
||||
|
||||
import {ACCEPT, API_VERSION} from "./contracts.ts";
|
||||
import {
|
||||
FOUNDATION_FLAG,
|
||||
PRIVACY_FLAG,
|
||||
normalizeSnapshot,
|
||||
privacyControls,
|
||||
withoutNotice,
|
||||
} from "./model.ts";
|
||||
import {
|
||||
normalizeIdentity,
|
||||
normalizeScope,
|
||||
privacyQuery,
|
||||
sameIdentity,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
FetchResponse,
|
||||
HuxFoundationClient,
|
||||
PrivacyControl,
|
||||
PrivacyScope,
|
||||
PrivacySnapshot,
|
||||
} from "./types.ts";
|
||||
|
||||
interface ClientOptions {
|
||||
client: HuxFoundationClient;
|
||||
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
|
||||
}
|
||||
|
||||
export class PrivacyContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PrivacyContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function enabled(client: HuxFoundationClient): boolean {
|
||||
return client.apiVersion === API_VERSION && client.enabled(FOUNDATION_FLAG) &&
|
||||
client.enabled(PRIVACY_FLAG);
|
||||
}
|
||||
|
||||
function effectMatches(action: PrivacyControl, snapshot: PrivacySnapshot): boolean {
|
||||
if (action === "enable_no_store" || action === "switch_to_private")
|
||||
return snapshot.noStore;
|
||||
if (action === "disable_no_store") return !snapshot.noStore;
|
||||
if (action === "disable_memory_here") return !snapshot.memoryEnabled;
|
||||
if (action === "dismiss") return snapshot.notice === null;
|
||||
return action === "forget_this_conversation" && snapshot.noStore &&
|
||||
!snapshot.memoryEnabled && snapshot.notice === null;
|
||||
}
|
||||
|
||||
export function createPrivacyClient(options: ClientOptions) {
|
||||
const client = options?.client;
|
||||
const fetcher = options?.fetcher ||
|
||||
(typeof fetch === "function" ? fetch.bind(globalThis) : null);
|
||||
if (!client || !fetcher) throw new TypeError("Privacy requires HUX and fetch clients");
|
||||
const identity = normalizeIdentity({
|
||||
tenant_ref: client.identity?.tenantRef,
|
||||
user_ref: client.identity?.userRef,
|
||||
surface: client.identity?.surface,
|
||||
});
|
||||
if (!identity || !sameIdentity(identity, client.identity))
|
||||
throw new TypeError("Privacy requires a scoped HUX identity");
|
||||
const current = new Map<string, PrivacySnapshot>();
|
||||
const seenNotices = new Set<string>();
|
||||
|
||||
function scopeKey(scope: PrivacyScope): string {
|
||||
return `${scope.sessionId}\u0000${scope.conversationId}`;
|
||||
}
|
||||
|
||||
function minimizeRepeatedNotice(snapshot: PrivacySnapshot): PrivacySnapshot {
|
||||
if (!snapshot.notice) return snapshot;
|
||||
const key = `${snapshot.scope.conversationId}\u0000${snapshot.notice.shownAt}`;
|
||||
if (seenNotices.has(key)) return withoutNotice(snapshot);
|
||||
seenNotices.add(key);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function request(path: string, init: Record<string, unknown>): Promise<unknown> {
|
||||
if (!enabled(client)) throw new PrivacyContractError("Privacy controls are not enabled");
|
||||
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 PrivacyContractError(
|
||||
response.status === 409 ? "Privacy state changed; refresh before trying again" :
|
||||
`Privacy request failed (${response.status})`,
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function load(scope: PrivacyScope): Promise<PrivacySnapshot> {
|
||||
const safeScope = normalizeScope(scope);
|
||||
if (!safeScope) throw new TypeError("Privacy scope is invalid");
|
||||
const raw = await request(privacyQuery(safeScope), {method: "GET"});
|
||||
const snapshot = normalizeSnapshot(raw, identity, safeScope);
|
||||
if (!snapshot) throw new PrivacyContractError("Privacy response crossed its scope");
|
||||
const visible = minimizeRepeatedNotice(snapshot);
|
||||
current.set(scopeKey(safeScope), visible);
|
||||
return visible;
|
||||
}
|
||||
|
||||
async function control(
|
||||
snapshot: PrivacySnapshot,
|
||||
action: PrivacyControl,
|
||||
): Promise<PrivacySnapshot> {
|
||||
const key = snapshot?.scope ? scopeKey(snapshot.scope) : "";
|
||||
if (current.get(key) !== snapshot || !privacyControls(snapshot).includes(action))
|
||||
throw new TypeError("Privacy control is not bound to the current conversation");
|
||||
const raw = await request("/privacy/controls", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
schema: "hux.privacy_control_request.v1",
|
||||
session_id: snapshot.scope.sessionId,
|
||||
conversation_id: snapshot.scope.conversationId,
|
||||
expected_revision: snapshot.revision,
|
||||
action,
|
||||
}),
|
||||
});
|
||||
const updated = normalizeSnapshot(raw, identity, snapshot.scope);
|
||||
if (!updated || updated.revision <= snapshot.revision || !effectMatches(action, updated))
|
||||
throw new PrivacyContractError("Privacy update could not be verified");
|
||||
const visible = minimizeRepeatedNotice(updated);
|
||||
current.set(key, visible);
|
||||
return visible;
|
||||
}
|
||||
|
||||
return Object.freeze({enabled: () => enabled(client), load, control});
|
||||
}
|
||||
28
dockerfiles/hermes-webui-hux/privacy/contracts.ts
Normal file
28
dockerfiles/hermes-webui-hux/privacy/contracts.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/** Same-origin backend contracts required by the HUX-10 frontend. */
|
||||
|
||||
export const API_VERSION = "hux.v1";
|
||||
export const ACCEPT = "application/vnd.hermes.hux+json; version=1";
|
||||
export const SNAPSHOT_SCHEMA = "hux.privacy_snapshot.v1";
|
||||
|
||||
/**
|
||||
* GET /hux/v1/privacy/snapshot?session_id=&conversation_id=
|
||||
* POST /hux/v1/privacy/controls
|
||||
*
|
||||
* Identity is derived only from the authenticated server session. The server
|
||||
* MUST bind both opaque ids to that identity and surface; neither an identity
|
||||
* nor a tenant selector is accepted from browser state or request parameters.
|
||||
* Responses contain only the current effective retention state and, at most,
|
||||
* one active hux.privacy_notice.v1. They MUST omit inactive or previous topic
|
||||
* categories and the source message that caused classification.
|
||||
*
|
||||
* Controls accept schema, session_id, conversation_id, expected_revision and
|
||||
* one enumerated action. A 409 rejects stale state. Enabling no-store disables
|
||||
* memory writes and makes retention session-only atomically. Forgetting a
|
||||
* conversation requires an explicit confirmed request and returns a scoped
|
||||
* tombstone snapshot; the UI never infers consent. Topic changes clear the prior notice server-side
|
||||
* so polling cannot reiterate sensitive details after the subject changes.
|
||||
*/
|
||||
export const BACKEND_ENDPOINTS = Object.freeze({
|
||||
snapshot: "/privacy/snapshot",
|
||||
controls: "/privacy/controls",
|
||||
});
|
||||
16
dockerfiles/hermes-webui-hux/privacy/index.ts
Normal file
16
dockerfiles/hermes-webui-hux/privacy/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/** Public integration surface for the inert HUX-10 privacy module. */
|
||||
|
||||
export {createPrivacyClient, PrivacyContractError} from "./client.ts";
|
||||
export {BACKEND_ENDPOINTS} from "./contracts.ts";
|
||||
export {
|
||||
FOUNDATION_FLAG,
|
||||
GENERIC_NOTICE,
|
||||
PRIVACY_FLAG,
|
||||
normalizeNotice,
|
||||
normalizeSnapshot,
|
||||
privacyControls,
|
||||
privacyEnabled,
|
||||
withoutNotice,
|
||||
} from "./model.ts";
|
||||
export {PrivacyCenter} from "./PrivacyCenter.tsx";
|
||||
export type * from "./types.ts";
|
||||
142
dockerfiles/hermes-webui-hux/privacy/model.ts
Normal file
142
dockerfiles/hermes-webui-hux/privacy/model.ts
Normal file
@ -0,0 +1,142 @@
|
||||
/** Fail-closed privacy.schema.json adapters for the HUX-10 frontend. */
|
||||
|
||||
import {
|
||||
asRecord,
|
||||
exactKeys,
|
||||
isSafeText,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
sameIdentity,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
PrivacyControl,
|
||||
PrivacyNoticeView,
|
||||
PrivacyScope,
|
||||
PrivacySnapshot,
|
||||
RawPrivacyNotice,
|
||||
ScopedIdentity,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const PRIVACY_FLAG = "hux.privacy";
|
||||
export const GENERIC_NOTICE =
|
||||
"Sensitive-topic protections are active. This context stays in this conversation and is not remembered without your approval.";
|
||||
|
||||
const TOPIC_RULES = Object.freeze({
|
||||
health: {memoryWrite: "ask", decayDays: 30},
|
||||
finance: {memoryWrite: "ask", decayDays: 30},
|
||||
legal: {memoryWrite: "ask", decayDays: 30},
|
||||
relationships: {memoryWrite: "ask", decayDays: 14},
|
||||
credentials: {memoryWrite: "deny", decayDays: 1},
|
||||
minors: {memoryWrite: "deny", decayDays: 7},
|
||||
location: {memoryWrite: "deny", decayDays: 7},
|
||||
biometric: {memoryWrite: "deny", decayDays: 1},
|
||||
} as const);
|
||||
const NOTICE_CONTROLS = new Set<PrivacyControl>([
|
||||
"forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss",
|
||||
]);
|
||||
const RETENTION_SCOPES = new Set(["conversation", "session"]);
|
||||
const MEMORY_WRITES = new Set(["ask", "deny"]);
|
||||
const SHARING = new Set(["never", "same_owner_only"]);
|
||||
|
||||
function integer(value: unknown, min: number, max: number): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
|
||||
}
|
||||
|
||||
export function privacyEnabled(flags?: Iterable<string>): boolean {
|
||||
if (!flags) return false;
|
||||
const enabled = new Set(flags);
|
||||
return enabled.has(FOUNDATION_FLAG) && enabled.has(PRIVACY_FLAG);
|
||||
}
|
||||
|
||||
export function normalizeNotice(
|
||||
raw: RawPrivacyNotice,
|
||||
conversationId: string,
|
||||
expectedMemoryWrite: "ask" | "deny",
|
||||
expectedDecayDays: number | null,
|
||||
): PrivacyNoticeView | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, [
|
||||
"schema", "topic", "conversation_id", "text", "controls", "shown_at",
|
||||
]) || value.schema !== "hux.privacy_notice.v1" ||
|
||||
!Object.prototype.hasOwnProperty.call(TOPIC_RULES, value.topic as string) ||
|
||||
value.conversation_id !== conversationId || !isSafeText(value.text, 1, 280) ||
|
||||
!isUtc(value.shown_at) || !Array.isArray(value.controls) ||
|
||||
value.controls.length < 1 || value.controls.length > NOTICE_CONTROLS.size ||
|
||||
value.controls.some((control) => !NOTICE_CONTROLS.has(control as PrivacyControl)) ||
|
||||
new Set(value.controls).size !== value.controls.length) return null;
|
||||
const rule = TOPIC_RULES[value.topic as keyof typeof TOPIC_RULES];
|
||||
if (rule.memoryWrite !== expectedMemoryWrite || rule.decayDays !== expectedDecayDays)
|
||||
return null;
|
||||
// Never carry the category or server text into view state. The server record
|
||||
// is validated, then reduced to one generic sentence and explicit controls.
|
||||
return {
|
||||
schema: "hux.privacy_notice.v1", text: GENERIC_NOTICE,
|
||||
controls: value.controls as PrivacyControl[], shownAt: value.shown_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSnapshot(
|
||||
raw: unknown,
|
||||
expectedIdentity: ScopedIdentity,
|
||||
expectedScope: PrivacyScope,
|
||||
): PrivacySnapshot | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, [
|
||||
"schema", "api_version", "identity", "session_id", "conversation_id", "revision",
|
||||
"no_store", "memory_enabled", "retention", "notice",
|
||||
]) || value.schema !== "hux.privacy_snapshot.v1" || value.api_version !== "hux.v1" ||
|
||||
value.session_id !== expectedScope.sessionId ||
|
||||
value.conversation_id !== expectedScope.conversationId ||
|
||||
!integer(value.revision, 1, Number.MAX_SAFE_INTEGER) ||
|
||||
typeof value.no_store !== "boolean" || typeof value.memory_enabled !== "boolean")
|
||||
return null;
|
||||
const identity = normalizeIdentity(value.identity);
|
||||
const retention = asRecord(value.retention);
|
||||
if (!identity || !sameIdentity(identity, expectedIdentity) || !retention ||
|
||||
!exactKeys(retention, [
|
||||
"scope", "memory_write", "topic_context_decay_days", "audit_interval_days",
|
||||
"next_audit_at", "cross_surface_sharing",
|
||||
]) || !RETENTION_SCOPES.has(retention.scope as string) ||
|
||||
!MEMORY_WRITES.has(retention.memory_write as string) ||
|
||||
(retention.topic_context_decay_days !== null &&
|
||||
!integer(retention.topic_context_decay_days, 1, 365)) ||
|
||||
!integer(retention.audit_interval_days, 1, 30) || !isUtc(retention.next_audit_at) ||
|
||||
!SHARING.has(retention.cross_surface_sharing as string)) return null;
|
||||
const memoryWrite = retention.memory_write as "ask" | "deny";
|
||||
const decayDays = retention.topic_context_decay_days as number | null;
|
||||
if (value.no_store ?
|
||||
retention.scope !== "session" || value.memory_enabled || memoryWrite !== "deny" || decayDays !== null :
|
||||
retention.scope !== "conversation" || (value.memory_enabled !== (memoryWrite === "ask")))
|
||||
return null;
|
||||
const notice = value.notice === null ? null : normalizeNotice(
|
||||
value.notice as RawPrivacyNotice, expectedScope.conversationId, memoryWrite, decayDays,
|
||||
);
|
||||
if (value.notice !== null && !notice) return null;
|
||||
return {
|
||||
schema: "hux.privacy_snapshot.v1", identity, scope: expectedScope,
|
||||
revision: value.revision as number, noStore: value.no_store,
|
||||
memoryEnabled: value.memory_enabled,
|
||||
retention: {
|
||||
scope: retention.scope as "conversation" | "session", memoryWrite,
|
||||
topicContextDecayDays: decayDays,
|
||||
auditIntervalDays: retention.audit_interval_days as number,
|
||||
nextAuditAt: retention.next_audit_at as string,
|
||||
crossSurfaceSharing: retention.cross_surface_sharing as "never" | "same_owner_only",
|
||||
},
|
||||
notice,
|
||||
};
|
||||
}
|
||||
|
||||
export function withoutNotice(snapshot: PrivacySnapshot): PrivacySnapshot {
|
||||
return snapshot.notice ? {...snapshot, notice: null} : snapshot;
|
||||
}
|
||||
|
||||
export function privacyControls(snapshot: PrivacySnapshot): PrivacyControl[] {
|
||||
const controls: PrivacyControl[] = [
|
||||
snapshot.noStore ? "disable_no_store" : "enable_no_store",
|
||||
...(snapshot.memoryEnabled ? ["disable_memory_here" as const] : []),
|
||||
...(snapshot.notice?.controls || []),
|
||||
];
|
||||
return [...new Set(controls)];
|
||||
}
|
||||
80
dockerfiles/hermes-webui-hux/privacy/security.ts
Normal file
80
dockerfiles/hermes-webui-hux/privacy/security.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/** Identity, scope, URL and primitive guards for HUX-10. */
|
||||
|
||||
import type {
|
||||
HuxSurface,
|
||||
PrivacyScope,
|
||||
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 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"]);
|
||||
|
||||
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 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 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) ||
|
||||
!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 normalizeScope(raw: PrivacyScope): PrivacyScope | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || !exactKeys(value, ["sessionId", "conversationId"]) ||
|
||||
!isId(value.sessionId, "ses") || !isId(value.conversationId, "conv")) return null;
|
||||
return {sessionId: value.sessionId, conversationId: value.conversationId};
|
||||
}
|
||||
|
||||
export function privacyQuery(scope: PrivacyScope): string {
|
||||
const safe = normalizeScope(scope);
|
||||
if (!safe) throw new TypeError("Privacy scope is invalid");
|
||||
const query = new URLSearchParams({
|
||||
session_id: safe.sessionId,
|
||||
conversation_id: safe.conversationId,
|
||||
});
|
||||
return `/privacy/snapshot?${query}`;
|
||||
}
|
||||
161
dockerfiles/hermes-webui-hux/privacy/styles.css
Normal file
161
dockerfiles/hermes-webui-hux/privacy/styles.css
Normal file
@ -0,0 +1,161 @@
|
||||
/* Component-scoped HUX-10 presentation; importing it cannot recolor the page. */
|
||||
.hux-privacy {
|
||||
color: inherit;
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hux-privacy-heading {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-privacy-kicker {
|
||||
color: color-mix(in srgb, currentColor 58%, #22d3ee);
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.055em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-privacy h2,
|
||||
.hux-privacy h3 {
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-privacy h2 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hux-privacy h3 {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.hux-privacy-state {
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 999px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 650;
|
||||
padding: 0.25rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hux-privacy-state.is-private {
|
||||
background: color-mix(in srgb, #22d3ee 10%, transparent);
|
||||
border-color: color-mix(in srgb, #22d3ee 42%, currentColor);
|
||||
}
|
||||
|
||||
.hux-privacy-notice,
|
||||
.hux-privacy-confirm {
|
||||
background: color-mix(in srgb, #22d3ee 5%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #22d3ee 24%, transparent);
|
||||
border-radius: 0.7rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.hux-privacy-notice p,
|
||||
.hux-privacy-confirm span {
|
||||
color: color-mix(in srgb, currentColor 68%, transparent);
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.45;
|
||||
margin: 0.2rem 0 0.55rem;
|
||||
}
|
||||
|
||||
.hux-privacy-retention {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-privacy-retention div {
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.hux-privacy-retention dt {
|
||||
color: color-mix(in srgb, currentColor 58%, transparent);
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.hux-privacy-retention dd {
|
||||
font-size: 0.6875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-privacy-controls,
|
||||
.hux-privacy-confirm div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.hux-privacy button {
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.6875rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.hux-privacy button:focus-visible {
|
||||
outline: 2px solid #22d3ee;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hux-privacy button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hux-privacy .hux-privacy-danger {
|
||||
border-color: color-mix(in srgb, #fb7185 48%, currentColor);
|
||||
color: color-mix(in srgb, currentColor 88%, #fb7185);
|
||||
}
|
||||
|
||||
.hux-privacy-confirm {
|
||||
border-color: color-mix(in srgb, #fb7185 28%, transparent);
|
||||
}
|
||||
|
||||
.hux-privacy-confirm strong {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.hux-privacy-status,
|
||||
.hux-privacy-error {
|
||||
font-size: 0.625rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-privacy-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hux-privacy-error {
|
||||
color: color-mix(in srgb, currentColor 82%, #fb7185);
|
||||
}
|
||||
|
||||
@container (max-width: 34rem) {
|
||||
.hux-privacy-retention {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-privacy * {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
83
dockerfiles/hermes-webui-hux/privacy/types.ts
Normal file
83
dockerfiles/hermes-webui-hux/privacy/types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/** Scoped types for the default-off HUX-10 privacy frontend. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
export type PrivacyControl =
|
||||
| "enable_no_store"
|
||||
| "disable_no_store"
|
||||
| "forget_this_conversation"
|
||||
| "switch_to_private"
|
||||
| "disable_memory_here"
|
||||
| "dismiss";
|
||||
|
||||
export interface ScopedIdentity {
|
||||
tenantRef: string;
|
||||
userRef: string;
|
||||
surface: HuxSurface;
|
||||
}
|
||||
|
||||
export interface RawIdentity {
|
||||
tenant_ref?: unknown;
|
||||
user_ref?: unknown;
|
||||
surface?: unknown;
|
||||
}
|
||||
|
||||
export interface PrivacyScope {
|
||||
sessionId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export interface RawPrivacyNotice {
|
||||
schema?: unknown;
|
||||
topic?: unknown;
|
||||
conversation_id?: unknown;
|
||||
text?: unknown;
|
||||
controls?: unknown;
|
||||
shown_at?: unknown;
|
||||
}
|
||||
|
||||
/** Intentionally omits topic: the surface never needs to retain that label. */
|
||||
export interface PrivacyNoticeView {
|
||||
schema: "hux.privacy_notice.v1";
|
||||
text: string;
|
||||
controls: PrivacyControl[];
|
||||
shownAt: string;
|
||||
}
|
||||
|
||||
export interface RetentionView {
|
||||
scope: "conversation" | "session";
|
||||
memoryWrite: "ask" | "deny";
|
||||
topicContextDecayDays: number | null;
|
||||
auditIntervalDays: number;
|
||||
nextAuditAt: string;
|
||||
crossSurfaceSharing: "never" | "same_owner_only";
|
||||
}
|
||||
|
||||
export interface PrivacySnapshot {
|
||||
schema: "hux.privacy_snapshot.v1";
|
||||
identity: ScopedIdentity;
|
||||
scope: PrivacyScope;
|
||||
revision: number;
|
||||
noStore: boolean;
|
||||
memoryEnabled: boolean;
|
||||
retention: RetentionView;
|
||||
notice: PrivacyNoticeView | null;
|
||||
}
|
||||
|
||||
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 PrivacyClient {
|
||||
enabled(): boolean;
|
||||
load(scope: PrivacyScope): Promise<PrivacySnapshot>;
|
||||
control(snapshot: PrivacySnapshot, action: PrivacyControl): Promise<PrivacySnapshot>;
|
||||
}
|
||||
135
dockerfiles/hermes-webui-hux/research/ResearchWorkspace.tsx
Normal file
135
dockerfiles/hermes-webui-hux/research/ResearchWorkspace.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
/** Accessible research UX. All supporting text renders as text, never executable markup. */
|
||||
|
||||
import { AlertCircle, BookOpen, CheckCircle2, ExternalLink, FileSearch, HelpCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { normalizeResearchPage, researchEnabled } from "./model.ts";
|
||||
import type {
|
||||
CitationView, NotebookView, PassageView, ResearchPage, ResearchWorkspaceProps, SourceView,
|
||||
} from "./types.ts";
|
||||
|
||||
const SUPPORT_LABEL = {
|
||||
supports: "Supports claim",
|
||||
partially_supports: "Partially supports",
|
||||
contradicts: "Contradicts claim",
|
||||
unverified: "Unverified",
|
||||
} as const;
|
||||
|
||||
function SourceMetadata({source}: {source: SourceView}) {
|
||||
return <article className="hux-research-source" data-classification={source.classification}>
|
||||
<header><span>{source.classification} source</span><h4>{source.title}</h4></header>
|
||||
<dl>
|
||||
<div><dt>Publisher</dt><dd>{source.publisher || "Not provided"}</dd></div>
|
||||
<div><dt>Published</dt><dd>{source.publishedAt ?
|
||||
<time dateTime={source.publishedAt}>{new Date(source.publishedAt).toLocaleDateString()}</time> : "Not provided"}</dd></div>
|
||||
<div><dt>Retrieved</dt><dd><time dateTime={source.retrievedAt}>
|
||||
{new Date(source.retrievedAt).toLocaleString()}</time></dd></div>
|
||||
<div><dt>Kind</dt><dd>{source.kind.replace("_", " ")}</dd></div>
|
||||
{source.provenance && <div><dt>Collected by</dt><dd>
|
||||
{source.provenance.actorType} on {source.provenance.surface}
|
||||
</dd></div>}
|
||||
</dl>
|
||||
{source.uri && <a href={source.uri} target="_blank" rel="noopener noreferrer">
|
||||
Open source <ExternalLink aria-hidden />
|
||||
</a>}
|
||||
</article>;
|
||||
}
|
||||
|
||||
function Passage({passage, source}: {passage: PassageView; source: SourceView}) {
|
||||
return <figure className="hux-research-passage">
|
||||
<blockquote>{passage.text}</blockquote>
|
||||
<figcaption>{source.title}{passage.locator ? ` · ${passage.locator}` : ""}</figcaption>
|
||||
</figure>;
|
||||
}
|
||||
|
||||
function CitationCard({citation, page}: {citation: CitationView; page: ResearchPage}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const passages = citation.passageIds.map((id) => page.passages.find((item) => item.id === id))
|
||||
.filter((item): item is PassageView => Boolean(item));
|
||||
const sourceIds = [...new Set(passages.map((item) => item.sourceId))];
|
||||
const sources = sourceIds.map((id) => page.sources.find((item) => item.id === id))
|
||||
.filter((item): item is SourceView => Boolean(item));
|
||||
const panelId = `hux-citation-${citation.id}`;
|
||||
const Icon = citation.support === "supports" ? CheckCircle2 :
|
||||
citation.support === "unverified" ? HelpCircle : AlertCircle;
|
||||
return <li className="hux-research-citation" data-support={citation.support}>
|
||||
<button type="button" aria-expanded={expanded} aria-controls={panelId}
|
||||
onClick={() => setExpanded((value) => !value)}>
|
||||
<Icon aria-hidden /><span><strong>{citation.claim}</strong>
|
||||
<small>{SUPPORT_LABEL[citation.support]} · {sources.length} source{sources.length === 1 ? "" : "s"}</small></span>
|
||||
</button>
|
||||
{expanded && <section id={panelId} aria-label={`Evidence for ${citation.claim}`}>
|
||||
{citation.note && <p><strong>Research note:</strong> {citation.note}</p>}
|
||||
<div className="hux-research-passages">{passages.map((item) => {
|
||||
const source = page.sources.find((candidate) => candidate.id === item.sourceId)!;
|
||||
return <Passage key={item.id} passage={item} source={source} />;
|
||||
})}</div>
|
||||
<div className="hux-research-sources">{sources.map((item) =>
|
||||
<SourceMetadata key={item.id} source={item} />)}</div>
|
||||
</section>}
|
||||
</li>;
|
||||
}
|
||||
|
||||
function CitationStrip({page, messageId}: {page: ResearchPage; messageId?: string}) {
|
||||
const citations = messageId ? page.citations.filter((item) => item.messageId === messageId) : page.citations;
|
||||
return <section className="hux-research-strip" aria-labelledby="hux-citation-strip-title">
|
||||
<header><h2 id="hux-citation-strip-title"><FileSearch aria-hidden /> Sources and citations</h2>
|
||||
<p>{citations.length} claim{citations.length === 1 ? "" : "s"} with retained evidence.</p></header>
|
||||
<ul>{citations.map((citation) => <CitationCard key={citation.id} citation={citation} page={page} />)}</ul>
|
||||
{!citations.length && <p>No citations are attached to this answer.</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ItemList({title, values, empty}: {title: string; values: readonly string[]; empty: string}) {
|
||||
return <section><h4>{title}</h4>{values.length ? <ul>{values.map((value, index) =>
|
||||
<li key={`${index}-${value}`}>{value}</li>)}</ul> : <p>{empty}</p>}</section>;
|
||||
}
|
||||
|
||||
function Notebook({notebook, page}: {notebook: NotebookView; page: ResearchPage}) {
|
||||
const sources = notebook.sourceIds.map((id) => page.sources.find((item) => item.id === id))
|
||||
.filter((item): item is SourceView => Boolean(item));
|
||||
return <article className="hux-research-notebook">
|
||||
<header><div><span>{notebook.status}</span><h3>{notebook.question}</h3></div>
|
||||
<time dateTime={notebook.updatedAt}>Updated {new Date(notebook.updatedAt).toLocaleString()}</time></header>
|
||||
<section className="hux-research-notebook-grid">
|
||||
<section><h4>Collected sources</h4>{sources.length ? <ol>{sources.map((source) => <li key={source.id}>
|
||||
<strong>{source.title}</strong><span>{source.classification} · {source.publisher || source.kind}</span>
|
||||
</li>)}</ol> : <p>No sources collected.</p>}</section>
|
||||
<ItemList title="Research notes" values={notebook.notes} empty="No notes recorded." />
|
||||
<ItemList title="Assumptions" values={notebook.assumptions} empty="No assumptions recorded." />
|
||||
<ItemList title="Unresolved questions" values={notebook.unresolvedQuestions} empty="No unresolved questions." />
|
||||
</section>
|
||||
</article>;
|
||||
}
|
||||
|
||||
/** Render nothing unless foundation, friendly modes, and research are negotiated. */
|
||||
export function ResearchWorkspace(props: ResearchWorkspaceProps) {
|
||||
const enabled = researchEnabled(props.flags);
|
||||
const page = useMemo(() => enabled ? normalizeResearchPage(props.page, props.identity, props.scope) : null,
|
||||
[enabled, props.identity, props.page, props.scope]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
if (!enabled || !page) return null;
|
||||
const selected = page.notebooks.find((item) => item.id === selectedId) || page.notebooks[0] || null;
|
||||
return <section className="hux-research" aria-label="Research and citations" aria-busy={props.busy}>
|
||||
{props.error && <p className="hux-research-error" role="alert">{props.error}</p>}
|
||||
{page.rejected > 0 && <p className="hux-research-warning" role="status">
|
||||
Some evidence was withheld because its scope, provenance, URL, or references were invalid.
|
||||
</p>}
|
||||
<CitationStrip page={page} messageId={props.messageId} />
|
||||
<section className="hux-research-view" aria-labelledby="hux-research-view-title">
|
||||
<header><h2 id="hux-research-view-title"><BookOpen aria-hidden /> Research notebooks</h2>
|
||||
<p>Sources, notes, assumptions, and open questions stay together.</p></header>
|
||||
<div className="hux-research-layout">
|
||||
<nav aria-label="Research notebooks"><ul>{page.notebooks.map((item) => <li key={item.id}>
|
||||
<button type="button" aria-current={selected?.id === item.id ? "page" : undefined}
|
||||
onClick={() => setSelectedId(item.id)}>{item.question}<small>{item.status}</small></button>
|
||||
</li>)}</ul>{!page.notebooks.length && <p>No research notebooks yet.</p>}</nav>
|
||||
<main>{selected ? <Notebook key={selected.id} notebook={selected} page={page} /> :
|
||||
<p>Select a notebook to inspect retained research.</p>}</main>
|
||||
</div>
|
||||
</section>
|
||||
<p className="sr-only" aria-live="polite" role="status">
|
||||
{`${page.citations.length} citations and ${page.notebooks.length} notebooks available`}
|
||||
</p>
|
||||
</section>;
|
||||
}
|
||||
46
dockerfiles/hermes-webui-hux/research/endpoints.ts
Normal file
46
dockerfiles/hermes-webui-hux/research/endpoints.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/** Same-origin endpoint descriptors for the future HUX-08 backend. */
|
||||
|
||||
import { researchEnabled } from "./model.ts";
|
||||
import { normalizeIdentity, normalizeScope, safeMessageId, safePathId, sameIdentity, sameScope } from "./security.ts";
|
||||
import type { HuxIdentity, ResearchEndpoint, ResearchEndpointContract, ResearchScope } from "./types.ts";
|
||||
|
||||
interface FoundationClient {
|
||||
apiVersion: string;
|
||||
identity: HuxIdentity;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
function endpoint(method: ResearchEndpoint["method"], path: string, responseSchema: string,
|
||||
requiredBody?: readonly string[]): ResearchEndpoint {
|
||||
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
|
||||
}
|
||||
|
||||
/** Describes authenticated endpoints without issuing network requests. */
|
||||
export function createResearchEndpointContract(client: FoundationClient, scope: ResearchScope,
|
||||
flags?: Iterable<string>): ResearchEndpointContract | null {
|
||||
if (!researchEnabled(flags)) return null;
|
||||
const rawIdentity = {tenant_ref: client.identity.tenantRef, user_ref: client.identity.userRef,
|
||||
surface: client.identity.surface};
|
||||
const rawScope = {project_id: scope.projectId, conversation_id: scope.conversationId};
|
||||
if (client.apiVersion !== "hux.v1" || !sameIdentity(normalizeIdentity(rawIdentity), client.identity) ||
|
||||
!sameScope(normalizeScope(rawScope), scope)) throw new TypeError("Scoped HUX v1 research client required");
|
||||
const base = client.endpoint(`/projects/${safePathId(scope.projectId, "prj")}` +
|
||||
`/conversations/${safePathId(scope.conversationId, "conv")}/research`);
|
||||
return Object.freeze({
|
||||
page: endpoint("GET", base, "hux.research_page.v1"),
|
||||
citationsForMessage(messageId) {
|
||||
return endpoint("GET", `${base}/messages/${safeMessageId(messageId)}/citations`, "hux.citation_page.v1");
|
||||
},
|
||||
notebook(notebookId) {
|
||||
return endpoint("GET", `${base}/notebooks/${safePathId(notebookId, "nb")}`, "hux.research_notebook.v1");
|
||||
},
|
||||
updateNotebook(notebookId) {
|
||||
return endpoint("PUT", `${base}/notebooks/${safePathId(notebookId, "nb")}`, "hux.research_notebook.v1",
|
||||
["expected_updated_at", "status", "assumptions", "unresolved_questions"]);
|
||||
},
|
||||
attachCitation(messageId) {
|
||||
return endpoint("POST", `${base}/messages/${safeMessageId(messageId)}/citations`, "hux.citation.v1",
|
||||
["claim", "passage_ids", "support", "note"]);
|
||||
},
|
||||
});
|
||||
}
|
||||
4
dockerfiles/hermes-webui-hux/research/index.ts
Normal file
4
dockerfiles/hermes-webui-hux/research/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { ResearchWorkspace } from "./ResearchWorkspace.tsx";
|
||||
export { createResearchEndpointContract } from "./endpoints.ts";
|
||||
export { normalizeResearchPage, researchEnabled } from "./model.ts";
|
||||
export type * from "./types.ts";
|
||||
150
dockerfiles/hermes-webui-hux/research/model.ts
Normal file
150
dockerfiles/hermes-webui-hux/research/model.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/** Schema-aligned, fail-closed HUX-08 research normalization. */
|
||||
|
||||
import {
|
||||
asRecord, boundedText, isHash, isId, isUtc, normalizeIdentity, normalizeScope,
|
||||
safeSourceUri, sameIdentity, sameScope,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
CitationView, Classification, HuxIdentity, NotebookStatus, NotebookView, PassageView,
|
||||
ProvenanceView, RawResearchEnvelope, ResearchPage, ResearchScope, SourceKind, SourceView, Support,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const FRIENDLY_MODES_FLAG = "hux.friendly_modes";
|
||||
export const RESEARCH_FLAG = "hux.research";
|
||||
const KINDS = new Set<SourceKind>(["web", "document", "artifact", "memory", "tool_output", "dataset"]);
|
||||
const CLASSIFICATIONS = new Set<Classification>(["primary", "secondary", "unknown"]);
|
||||
const SUPPORT = new Set<Support>(["supports", "partially_supports", "contradicts", "unverified"]);
|
||||
const STATUSES = new Set<NotebookStatus>(["open", "answered", "abandoned"]);
|
||||
const MAX_RECORDS = 500;
|
||||
|
||||
export function researchEnabled(flags?: Iterable<string>): boolean {
|
||||
const active = new Set(flags || []);
|
||||
return [FOUNDATION_FLAG, FRIENDLY_MODES_FLAG, RESEARCH_FLAG].every((flag) => active.has(flag));
|
||||
}
|
||||
|
||||
function provenance(raw: unknown, conversationId: string): ProvenanceView | null | false {
|
||||
if (raw === undefined) return null;
|
||||
const value = asRecord(raw);
|
||||
const actor = asRecord(value?.actor);
|
||||
if (!value || !actor || !["chat", "worker", "telegram", "voice", "api"].includes(String(value.surface)) ||
|
||||
!["user", "assistant", "tool", "system", "operator"].includes(String(actor.type)) ||
|
||||
!boundedText(actor.id, 1, 120) || !isUtc(value.recorded_at) ||
|
||||
(value.conversation_id !== undefined && value.conversation_id !== conversationId) ||
|
||||
(value.run_id !== undefined && !boundedText(value.run_id, 1, 120))) return false;
|
||||
return {surface: value.surface as ProvenanceView["surface"], actorType: actor.type as ProvenanceView["actorType"],
|
||||
recordedAt: value.recorded_at, runId: value.run_id as string | undefined || null};
|
||||
}
|
||||
|
||||
function source(raw: unknown, conversationId: string): SourceView | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || value.schema !== "hux.source.v1" || !isId(value.id, "src") ||
|
||||
!KINDS.has(value.kind as SourceKind) || !boundedText(value.title, 1, 300) ||
|
||||
!boundedText(value.publisher ?? "", 0, 200) || !isUtc(value.retrieved_at) ||
|
||||
!CLASSIFICATIONS.has(value.classification as Classification) ||
|
||||
(value.published_at !== undefined && !isUtc(value.published_at)) ||
|
||||
(isUtc(value.published_at) && value.published_at > value.retrieved_at) ||
|
||||
(value.content_hash !== undefined && !isHash(value.content_hash))) return null;
|
||||
const uri = safeSourceUri(value.uri);
|
||||
if (value.uri !== undefined && value.uri !== "" && !uri) return null;
|
||||
const origin = provenance(value.provenance, conversationId);
|
||||
if (origin === false) return null;
|
||||
return {id: value.id, kind: value.kind as SourceKind, title: value.title,
|
||||
publisher: value.publisher as string | undefined || "", publishedAt: value.published_at as string | undefined || null,
|
||||
retrievedAt: value.retrieved_at, classification: value.classification as Classification,
|
||||
uri, provenance: origin};
|
||||
}
|
||||
|
||||
function locator(raw: unknown): string | null {
|
||||
if (raw === undefined) return "";
|
||||
const value = asRecord(raw);
|
||||
if (!value || Object.keys(value).some((key) => !["page", "selector", "line_start", "line_end", "char_start", "char_end"].includes(key))) return null;
|
||||
const positive = (item: unknown) => Number.isSafeInteger(item) && (item as number) >= 1;
|
||||
const natural = (item: unknown) => Number.isSafeInteger(item) && (item as number) >= 0;
|
||||
if ((value.page !== undefined && !positive(value.page)) ||
|
||||
(value.selector !== undefined && !boundedText(value.selector, 0, 500)) ||
|
||||
(value.line_start !== undefined && !positive(value.line_start)) ||
|
||||
(value.line_end !== undefined && !positive(value.line_end)) ||
|
||||
(value.char_start !== undefined && !natural(value.char_start)) ||
|
||||
(value.char_end !== undefined && !natural(value.char_end)) ||
|
||||
(positive(value.line_start) && positive(value.line_end) && (value.line_end as number) < (value.line_start as number)) ||
|
||||
(natural(value.char_start) && natural(value.char_end) && (value.char_end as number) < (value.char_start as number))) return null;
|
||||
const parts = [value.page && `page ${value.page}`, value.selector && String(value.selector),
|
||||
value.line_start && `lines ${value.line_start}${value.line_end ? `–${value.line_end}` : ""}`,
|
||||
value.char_start !== undefined && `characters ${value.char_start}${value.char_end !== undefined ? `–${value.char_end}` : ""}`];
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function passage(raw: unknown, sources: ReadonlySet<string>): PassageView | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || value.schema !== "hux.passage.v1" || !isId(value.id, "psg") ||
|
||||
!isId(value.source_id, "src") || !sources.has(value.source_id) ||
|
||||
!boundedText(value.text, 1, 4_000) || !isHash(value.hash)) return null;
|
||||
const position = locator(value.locator);
|
||||
return position === null ? null : {id: value.id, sourceId: value.source_id, text: value.text, locator: position};
|
||||
}
|
||||
|
||||
function citation(raw: unknown, passages: ReadonlySet<string>): CitationView | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || value.schema !== "hux.citation.v1" || !isId(value.id, "cit") ||
|
||||
!boundedText(value.message_id, 1, 120) || !boundedText(value.claim, 1, 1_000) ||
|
||||
!Array.isArray(value.passage_ids) || value.passage_ids.length < 1 || value.passage_ids.length > 32 ||
|
||||
new Set(value.passage_ids).size !== value.passage_ids.length ||
|
||||
value.passage_ids.some((id) => !isId(id, "psg") || !passages.has(id)) ||
|
||||
!SUPPORT.has(value.support as Support) || !boundedText(value.note ?? "", 0, 500)) return null;
|
||||
return {id: value.id, messageId: value.message_id, claim: value.claim,
|
||||
passageIds: [...value.passage_ids] as string[], support: value.support as Support,
|
||||
note: value.note as string | undefined || ""};
|
||||
}
|
||||
|
||||
function idList(raw: unknown, prefix: string, allowed: ReadonlySet<string>): string[] | null {
|
||||
return Array.isArray(raw) && raw.length <= MAX_RECORDS && new Set(raw).size === raw.length &&
|
||||
raw.every((id) => isId(id, prefix) && allowed.has(id)) ? [...raw] as string[] : null;
|
||||
}
|
||||
|
||||
function textList(raw: unknown): string[] | null {
|
||||
return Array.isArray(raw) && raw.length <= 64 && raw.every((item) => boundedText(item, 1, 500)) ? [...raw] : null;
|
||||
}
|
||||
|
||||
function notebook(raw: unknown, scope: ResearchScope, sources: ReadonlySet<string>,
|
||||
passages: ReadonlySet<string>, citations: ReadonlyMap<string, CitationView>): NotebookView | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || value.schema !== "hux.research_notebook.v1" || !isId(value.id, "nb") ||
|
||||
value.conversation_id !== scope.conversationId || !boundedText(value.question, 1, 1_000) ||
|
||||
!STATUSES.has(value.status as NotebookStatus) || !isUtc(value.updated_at)) return null;
|
||||
const sourceIds = idList(value.source_ids, "src", sources);
|
||||
const passageIds = idList(value.passage_ids, "psg", passages);
|
||||
const citationIds = idList(value.citation_ids, "cit", new Set(citations.keys()));
|
||||
const assumptions = textList(value.assumptions);
|
||||
const unresolved = textList(value.unresolved_questions);
|
||||
if (!sourceIds || !passageIds || !citationIds || !assumptions || !unresolved) return null;
|
||||
const notes = citationIds.map((id) => citations.get(id)?.note || "").filter(Boolean);
|
||||
return {id: value.id, question: value.question, status: value.status as NotebookStatus,
|
||||
sourceIds, passageIds, citationIds, assumptions, unresolvedQuestions: unresolved,
|
||||
notes, updatedAt: value.updated_at};
|
||||
}
|
||||
|
||||
function unique<T extends {id: string}>(values: Array<T | null>): {items: T[]; rejected: number} {
|
||||
const seen = new Set<string>();
|
||||
const items = values.filter((item): item is T => Boolean(item && !seen.has(item.id) && seen.add(item.id)));
|
||||
return {items, rejected: values.length - items.length};
|
||||
}
|
||||
|
||||
export function normalizeResearchPage(raw: RawResearchEnvelope, expected: HuxIdentity,
|
||||
scope: ResearchScope): ResearchPage | null {
|
||||
if (!raw || typeof raw !== "object" || raw.schema !== "hux.research_page.v1" || raw.api_version !== "hux.v1" ||
|
||||
!sameIdentity(normalizeIdentity(raw.identity), expected) || !sameScope(normalizeScope(raw.binding), scope) ||
|
||||
!Array.isArray(raw.sources) || raw.sources.length > MAX_RECORDS || !Array.isArray(raw.passages) ||
|
||||
raw.passages.length > MAX_RECORDS || !Array.isArray(raw.citations) || raw.citations.length > MAX_RECORDS ||
|
||||
!Array.isArray(raw.notebooks) || raw.notebooks.length > 100) return null;
|
||||
const sourceResult = unique(raw.sources.map((item) => source(item, scope.conversationId)));
|
||||
const sourceIds = new Set(sourceResult.items.map((item) => item.id));
|
||||
const passageResult = unique(raw.passages.map((item) => passage(item, sourceIds)));
|
||||
const passageIds = new Set(passageResult.items.map((item) => item.id));
|
||||
const citationResult = unique(raw.citations.map((item) => citation(item, passageIds)));
|
||||
const citationMap = new Map(citationResult.items.map((item) => [item.id, item]));
|
||||
const notebookResult = unique(raw.notebooks.map((item) => notebook(item, scope, sourceIds, passageIds, citationMap)));
|
||||
return {sources: sourceResult.items, passages: passageResult.items, citations: citationResult.items,
|
||||
notebooks: notebookResult.items, rejected: sourceResult.rejected + passageResult.rejected +
|
||||
citationResult.rejected + notebookResult.rejected};
|
||||
}
|
||||
76
dockerfiles/hermes-webui-hux/research/security.ts
Normal file
76
dockerfiles/hermes-webui-hux/research/security.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/** Identity, scope, URL, text, and record primitives for HUX-08. */
|
||||
|
||||
import type { HuxIdentity, HuxSurface, ResearchScope } 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 HASH = /^sha256:[0-9a-f]{64}$/;
|
||||
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
||||
const SURFACES = new Set<HuxSurface>(["chat", "worker", "telegram", "voice", "api"]);
|
||||
|
||||
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 isHash(value: unknown): value is string {
|
||||
return typeof value === "string" && HASH.test(value);
|
||||
}
|
||||
|
||||
export function boundedText(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): HuxIdentity | null {
|
||||
const value = asRecord(raw);
|
||||
if (!value || typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
|
||||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) || !SURFACES.has(value.surface as HuxSurface)) return null;
|
||||
return {tenantRef: value.tenant_ref, userRef: value.user_ref, surface: value.surface as HuxSurface};
|
||||
}
|
||||
|
||||
export function normalizeScope(raw: unknown): ResearchScope | null {
|
||||
const value = asRecord(raw);
|
||||
return value && isId(value.project_id, "prj") && isId(value.conversation_id, "conv") ?
|
||||
{projectId: value.project_id, conversationId: value.conversation_id} : 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: ResearchScope | null, right: ResearchScope): boolean {
|
||||
return Boolean(left && left.projectId === right.projectId && left.conversationId === right.conversationId);
|
||||
}
|
||||
|
||||
/** Allow HTTPS sources and same-origin HUX paths only. */
|
||||
export function safeSourceUri(value: unknown): string | null {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
if (typeof value !== "string" || value.length > 2_000 || CONTROL.test(value)) return null;
|
||||
if (/^\/hux\/v1\/(?!.*(?:\.\.|[?#\\]))[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/.test(value)) return value;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" && !parsed.username && !parsed.password ? parsed.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeMessageId(value: unknown): string {
|
||||
if (!boundedText(value, 1, 120) || /[/\\?#]/.test(value)) throw new TypeError("Safe message id required");
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
export function safePathId(value: unknown, prefix: string): string {
|
||||
if (!isId(value, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
154
dockerfiles/hermes-webui-hux/research/styles.css
Normal file
154
dockerfiles/hermes-webui-hux/research/styles.css
Normal file
@ -0,0 +1,154 @@
|
||||
.hux-research {
|
||||
--research-border: color-mix(in srgb, currentColor 16%, transparent);
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hux-research h2,
|
||||
.hux-research h3,
|
||||
.hux-research h4,
|
||||
.hux-research p {
|
||||
margin-block: .25rem;
|
||||
}
|
||||
|
||||
.hux-research-strip,
|
||||
.hux-research-view {
|
||||
border: 1px solid var(--research-border);
|
||||
border-radius: .8rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hux-research-strip > header,
|
||||
.hux-research-view > header,
|
||||
.hux-research-notebook > header {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-research-strip ul,
|
||||
.hux-research-layout ul,
|
||||
.hux-research-notebook ol {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hux-research-citation {
|
||||
border-top: 1px solid var(--research-border);
|
||||
}
|
||||
|
||||
.hux-research-citation > button {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
padding-block: .85rem;
|
||||
text-align: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-research-citation > button span,
|
||||
.hux-research-source header,
|
||||
.hux-research-layout nav button {
|
||||
display: grid;
|
||||
gap: .2rem;
|
||||
}
|
||||
|
||||
.hux-research-citation[data-support="contradicts"],
|
||||
.hux-research-citation[data-support="unverified"] {
|
||||
border-inline-start: .2rem solid currentColor;
|
||||
padding-inline-start: .7rem;
|
||||
}
|
||||
|
||||
.hux-research-passages,
|
||||
.hux-research-sources,
|
||||
.hux-research-notebook-grid {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
|
||||
}
|
||||
|
||||
.hux-research-passage,
|
||||
.hux-research-source,
|
||||
.hux-research-notebook-grid > section {
|
||||
border: 1px solid var(--research-border);
|
||||
border-radius: .55rem;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: .8rem;
|
||||
}
|
||||
|
||||
.hux-research-passage blockquote {
|
||||
margin: 0 0 .5rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hux-research-source dl {
|
||||
display: grid;
|
||||
gap: .35rem;
|
||||
}
|
||||
|
||||
.hux-research-source dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.hux-research-source dt {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hux-research-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(12rem, 20rem) minmax(0, 1fr);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hux-research-layout nav {
|
||||
border-inline-end: 1px solid var(--research-border);
|
||||
padding-inline-end: 1rem;
|
||||
}
|
||||
|
||||
.hux-research-layout nav button {
|
||||
padding: .65rem;
|
||||
text-align: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-research-layout nav button[aria-current="page"] {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.hux-research-error,
|
||||
.hux-research-warning {
|
||||
border-inline-start: .25rem solid currentColor;
|
||||
padding: .65rem .85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.hux-research-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hux-research-layout nav {
|
||||
border-block-end: 1px solid var(--research-border);
|
||||
border-inline-end: 0;
|
||||
padding-block-end: 1rem;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.hux-research-notebook > header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-research *,
|
||||
.hux-research *::before,
|
||||
.hux-research *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: .01ms !important;
|
||||
}
|
||||
}
|
||||
110
dockerfiles/hermes-webui-hux/research/types.ts
Normal file
110
dockerfiles/hermes-webui-hux/research/types.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/** Safe frontend contracts for default-off HUX-08 research and citations. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
export type SourceKind = "web" | "document" | "artifact" | "memory" | "tool_output" | "dataset";
|
||||
export type Classification = "primary" | "secondary" | "unknown";
|
||||
export type Support = "supports" | "partially_supports" | "contradicts" | "unverified";
|
||||
export type NotebookStatus = "open" | "answered" | "abandoned";
|
||||
|
||||
export interface HuxIdentity {
|
||||
tenantRef: string;
|
||||
userRef: string;
|
||||
surface: HuxSurface;
|
||||
}
|
||||
|
||||
export interface ResearchScope {
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceView {
|
||||
surface: HuxSurface;
|
||||
actorType: "user" | "assistant" | "tool" | "system" | "operator";
|
||||
recordedAt: string;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
export interface SourceView {
|
||||
id: string;
|
||||
kind: SourceKind;
|
||||
title: string;
|
||||
publisher: string;
|
||||
publishedAt: string | null;
|
||||
retrievedAt: string;
|
||||
classification: Classification;
|
||||
uri: string | null;
|
||||
provenance: ProvenanceView | null;
|
||||
}
|
||||
|
||||
export interface PassageView {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
text: string;
|
||||
locator: string;
|
||||
}
|
||||
|
||||
export interface CitationView {
|
||||
id: string;
|
||||
messageId: string;
|
||||
claim: string;
|
||||
passageIds: string[];
|
||||
support: Support;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface NotebookView {
|
||||
id: string;
|
||||
question: string;
|
||||
status: NotebookStatus;
|
||||
sourceIds: string[];
|
||||
passageIds: string[];
|
||||
citationIds: string[];
|
||||
assumptions: string[];
|
||||
unresolvedQuestions: string[];
|
||||
notes: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ResearchPage {
|
||||
sources: SourceView[];
|
||||
passages: PassageView[];
|
||||
citations: CitationView[];
|
||||
notebooks: NotebookView[];
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface RawResearchEnvelope {
|
||||
schema?: unknown;
|
||||
api_version?: unknown;
|
||||
identity?: unknown;
|
||||
binding?: unknown;
|
||||
sources?: unknown;
|
||||
passages?: unknown;
|
||||
citations?: unknown;
|
||||
notebooks?: unknown;
|
||||
}
|
||||
|
||||
export interface ResearchEndpoint {
|
||||
method: "GET" | "PUT" | "POST";
|
||||
path: string;
|
||||
responseSchema: string;
|
||||
requiredBody?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ResearchEndpointContract {
|
||||
page: ResearchEndpoint;
|
||||
citationsForMessage(messageId: string): ResearchEndpoint;
|
||||
notebook(notebookId: string): ResearchEndpoint;
|
||||
updateNotebook(notebookId: string): ResearchEndpoint;
|
||||
attachCitation(messageId: string): ResearchEndpoint;
|
||||
}
|
||||
|
||||
export interface ResearchWorkspaceProps {
|
||||
flags?: Iterable<string>;
|
||||
identity: HuxIdentity;
|
||||
scope: ResearchScope;
|
||||
page: RawResearchEnvelope;
|
||||
messageId?: string;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
412
testing/tests/test_hermes_hux_ui_activity.mjs
Normal file
412
testing/tests/test_hermes_hux_ui_activity.mjs
Normal file
@ -0,0 +1,412 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ACTIVITY_TIMELINE_FLAG,
|
||||
FOUNDATION_FLAG,
|
||||
activityTimelineEnabled,
|
||||
mergeActivityEvents,
|
||||
normalizeCancellationReceipt,
|
||||
surfacePolicy,
|
||||
} from "../../dockerfiles/hermes-webui-hux/activity/model.ts";
|
||||
import {
|
||||
isOpaqueId,
|
||||
isRfc3339Utc,
|
||||
safeEvidenceLabel,
|
||||
safeText,
|
||||
} from "../../dockerfiles/hermes-webui-hux/activity/security.ts";
|
||||
|
||||
const CONVERSATION = "conv_test1234";
|
||||
const KINDS = JSON.parse(
|
||||
readFileSync("services/hermes/contracts/hux/event.schema.json"),
|
||||
).properties.kind.enum;
|
||||
|
||||
function event(seq, kind = "tool.call", extra = {}) {
|
||||
return {
|
||||
schema: "hux.event.v1",
|
||||
id: `evt_test${String(seq).padStart(4, "0")}`,
|
||||
seq,
|
||||
ts: `2026-08-24T10:${String(Math.floor(seq / 60)).padStart(2, "0")}:${String(seq % 60).padStart(2, "0")}Z`,
|
||||
conversation_id: CONVERSATION,
|
||||
kind,
|
||||
summary: `Safe update ${seq}`,
|
||||
evidence: [],
|
||||
provenance: { surface: "chat" },
|
||||
sensitivity: "personal",
|
||||
redaction: { level: "none" },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("feature remains default-off and depends on the foundation", () => {
|
||||
assert.equal(activityTimelineEnabled(), false);
|
||||
assert.equal(activityTimelineEnabled([]), false);
|
||||
assert.equal(activityTimelineEnabled([ACTIVITY_TIMELINE_FLAG]), false);
|
||||
assert.equal(activityTimelineEnabled([FOUNDATION_FLAG]), false);
|
||||
assert.equal(
|
||||
activityTimelineEnabled([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Chat policy is intentionally calmer and every returned policy is isolated", () => {
|
||||
const chat = surfacePolicy("chat");
|
||||
const worker = surfacePolicy("worker");
|
||||
assert.ok(chat.maxItems < worker.maxItems);
|
||||
assert.ok(chat.initialItems < worker.initialItems);
|
||||
assert.ok(chat.evidenceLimit < worker.evidenceLimit);
|
||||
assert.equal(chat.expandRoutineEvents, false);
|
||||
assert.equal(worker.expandRoutineEvents, true);
|
||||
chat.maxItems = 1;
|
||||
assert.equal(surfacePolicy("chat").maxItems, 80);
|
||||
assert.deepEqual(surfacePolicy("api"), worker);
|
||||
assert.ok(
|
||||
surfacePolicy("voice").maxItems < surfacePolicy("telegram").maxItems,
|
||||
);
|
||||
});
|
||||
|
||||
test("plain-text security redacts credential shapes without serializing objects", () => {
|
||||
assert.equal(safeText({ token: "nope" }, "fallback"), "fallback");
|
||||
assert.equal(safeText(" \n\t ", "fallback"), "fallback");
|
||||
assert.equal(safeText("abc\u0000def", "fallback"), "abc def");
|
||||
assert.equal(safeText("abcdef", "fallback", 4), "abc…");
|
||||
assert.equal(safeText("abcdef", "fallback", 0), "…");
|
||||
const unsafe = [
|
||||
"password=hunter2",
|
||||
"Authorization: Basic-abcd",
|
||||
"Bearer abc.DEF_123",
|
||||
"eyJabcdefgh.abcdefghijkl.abcdefghijkl",
|
||||
"https://user:pass@example.test/path",
|
||||
"Abcdefghijklmnopqrstuvwxyz1234567890ABCD",
|
||||
"-----BEGIN PRIVATE KEY----- top secret -----END PRIVATE KEY-----",
|
||||
].join(" ");
|
||||
const scrubbed = safeText(unsafe, "fallback", 1000);
|
||||
assert.doesNotMatch(
|
||||
scrubbed,
|
||||
/hunter2|Basic-abcd|abc\.DEF_123|user:pass|top secret|1234567890ABCD/,
|
||||
);
|
||||
assert.match(scrubbed, /redacted/);
|
||||
});
|
||||
|
||||
test("only allow-listed evidence labels cross the display boundary", () => {
|
||||
assert.deepEqual(safeEvidenceLabel("tool_result"), {
|
||||
kind: "tool_result",
|
||||
label: "Tool result",
|
||||
});
|
||||
assert.deepEqual(safeEvidenceLabel("url"), { kind: "url", label: "Website" });
|
||||
assert.equal(safeEvidenceLabel("raw_tool_arguments"), null);
|
||||
assert.equal(safeEvidenceLabel(7), null);
|
||||
assert.equal(isOpaqueId("evt_abcd", "evt"), true);
|
||||
assert.equal(isOpaqueId("evt_abcd"), true);
|
||||
assert.equal(isOpaqueId("mem_abcd", "evt"), false);
|
||||
assert.equal(isOpaqueId("not opaque"), false);
|
||||
assert.equal(isOpaqueId(7), false);
|
||||
assert.equal(isRfc3339Utc("2026-08-24T10:00:00.123Z"), true);
|
||||
assert.equal(isRfc3339Utc("today"), false);
|
||||
assert.equal(isRfc3339Utc("2026-99-99T10:00:00Z"), false);
|
||||
assert.equal(isRfc3339Utc(7), false);
|
||||
});
|
||||
|
||||
test("every contract event maps to a compact semantic phase", () => {
|
||||
const records = KINDS.map((kind, seq) =>
|
||||
event(seq, kind, {
|
||||
parent_event_id: kind === "run.started" ? "evt_parent1234" : undefined,
|
||||
}),
|
||||
);
|
||||
const result = mergeActivityEvents([], records, CONVERSATION, "worker");
|
||||
assert.equal(result.invalid, 0);
|
||||
assert.equal(result.items.length, KINDS.length);
|
||||
const phases = new Set(result.items.map((item) => item.phase));
|
||||
assert.deepEqual(
|
||||
phases,
|
||||
new Set([
|
||||
"intent",
|
||||
"action",
|
||||
"evidence",
|
||||
"delegation",
|
||||
"decision",
|
||||
"completion",
|
||||
"failure",
|
||||
]),
|
||||
);
|
||||
assert.equal(
|
||||
result.items.find((item) => item.kind === "run.failed").tone,
|
||||
"warning",
|
||||
);
|
||||
assert.equal(
|
||||
result.items.find((item) => item.kind === "run.completed").tone,
|
||||
"success",
|
||||
);
|
||||
assert.equal(
|
||||
result.items.find((item) => item.kind === "approval.requested").tone,
|
||||
"warning",
|
||||
);
|
||||
assert.equal(
|
||||
result.items.find((item) => item.kind === "tool.call").routine,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
result.items.find((item) => item.kind === "message.user").routine,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("root runs are actions while child runs are explicit delegations", () => {
|
||||
const result = mergeActivityEvents(
|
||||
[],
|
||||
[
|
||||
event(1, "run.started"),
|
||||
event(2, "run.started", { parent_event_id: "evt_parent1234" }),
|
||||
],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => item.phase),
|
||||
["action", "delegation"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => item.tone),
|
||||
["active", "active"],
|
||||
);
|
||||
});
|
||||
|
||||
test("normalization redacts summaries and never projects detail, IDs, or URIs", () => {
|
||||
const raw = event(1, "tool.result", {
|
||||
summary: "token=secret-value result is ready",
|
||||
run_id: "run-safe",
|
||||
detail: { arguments: "--token secret", result: "raw secret result" },
|
||||
evidence: [
|
||||
{
|
||||
kind: "tool_result",
|
||||
id: "secret-id",
|
||||
uri: "https://user:pass@example.test",
|
||||
},
|
||||
{ kind: "file", id: "/secret/path" },
|
||||
{ kind: "bogus", id: "raw" },
|
||||
{ kind: "url", id: "https://secret" },
|
||||
],
|
||||
});
|
||||
const item = mergeActivityEvents([], [raw], CONVERSATION, "chat").items[0];
|
||||
const serialized = JSON.stringify(item);
|
||||
assert.equal(item.runId, "run-safe");
|
||||
assert.deepEqual(
|
||||
item.evidence.map((entry) => entry.label),
|
||||
["Tool result", "File", "Website"],
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/secret-value|raw secret result|secret-id|secret\/path|user:pass/,
|
||||
);
|
||||
assert.equal("detail" in item, false);
|
||||
|
||||
const invalidRun = mergeActivityEvents(
|
||||
[],
|
||||
[event(2, "tool.call", { run_id: { raw: true } })],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
);
|
||||
assert.equal(invalidRun.items[0].runId, undefined);
|
||||
});
|
||||
|
||||
test("full or restricted redaction ignores even an otherwise safe summary", () => {
|
||||
const result = mergeActivityEvents(
|
||||
[],
|
||||
[
|
||||
event(1, "tool.call", {
|
||||
summary: "do not show",
|
||||
redaction: { level: "full" },
|
||||
}),
|
||||
event(2, "run.failed", {
|
||||
summary: "health detail",
|
||||
sensitivity: "restricted",
|
||||
}),
|
||||
event(3, "run.completed", { summary: {}, redaction: null }),
|
||||
event(4, "citation.attached", {
|
||||
summary: "",
|
||||
redaction: { level: "partial" },
|
||||
}),
|
||||
],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => item.summary),
|
||||
[
|
||||
"Hermes used a tool",
|
||||
"The run failed; sensitive detail is hidden",
|
||||
"The run completed",
|
||||
"Activity recorded; sensitive detail is hidden",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("reconnect merge is ordered, idempotent, and fail-closed on conflicts", () => {
|
||||
const first = mergeActivityEvents(
|
||||
[],
|
||||
[event(3), event(1)],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
);
|
||||
const replay = { ...event(3), summary: "attempted rewrite" };
|
||||
const idConflict = { ...event(4), id: first.items[0].id };
|
||||
const seqConflict = { ...event(1), id: "evt_conflict1" };
|
||||
const result = mergeActivityEvents(
|
||||
first.items,
|
||||
[replay, idConflict, seqConflict, event(2)],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => item.seq),
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.equal(result.items.at(-1).summary, "Safe update 3");
|
||||
assert.equal(result.replays, 1);
|
||||
assert.equal(result.conflicts, 2);
|
||||
assert.equal(result.afterSeq, 3);
|
||||
assert.equal(result.invalid, 0);
|
||||
});
|
||||
|
||||
test("an initial same-sequence conflict resolves deterministically", () => {
|
||||
const higher = { ...event(7), id: "evt_zulu1234" };
|
||||
const lower = {
|
||||
...event(7),
|
||||
id: "evt_alpha1234",
|
||||
summary: "Deterministic owner",
|
||||
};
|
||||
const forward = mergeActivityEvents(
|
||||
[],
|
||||
[higher, lower],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
);
|
||||
const reverse = mergeActivityEvents(
|
||||
[],
|
||||
[lower, higher],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
);
|
||||
assert.deepEqual(forward.items, reverse.items);
|
||||
assert.equal(forward.items[0].id, "evt_alpha1234");
|
||||
assert.equal(forward.conflicts, 1);
|
||||
});
|
||||
|
||||
test("malformed and cross-conversation events are rejected", () => {
|
||||
const bad = [
|
||||
{ ...event(1), schema: "hux.event.v0" },
|
||||
{ ...event(2), id: "bad" },
|
||||
{ ...event(3), seq: -1 },
|
||||
{ ...event(4), ts: "later" },
|
||||
{ ...event(5), conversation_id: "conv_other1234" },
|
||||
{ ...event(6), kind: "tool.raw" },
|
||||
];
|
||||
const result = mergeActivityEvents([], bad, CONVERSATION, "chat");
|
||||
assert.deepEqual(result.items, []);
|
||||
assert.equal(result.invalid, bad.length);
|
||||
assert.equal(result.afterSeq, -1);
|
||||
});
|
||||
|
||||
test("retention is bounded per surface while preserving the resume watermark", () => {
|
||||
const records = Array.from({ length: 85 }, (_, seq) => event(seq));
|
||||
const result = mergeActivityEvents([], records, CONVERSATION, "chat");
|
||||
assert.equal(result.items.length, 80);
|
||||
assert.equal(result.items[0].seq, 5);
|
||||
assert.equal(result.afterSeq, 84);
|
||||
assert.equal(result.truncated, 5);
|
||||
const replayed = mergeActivityEvents(
|
||||
result.items,
|
||||
[event(84)],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
);
|
||||
assert.equal(replayed.replays, 1);
|
||||
assert.equal(replayed.truncated, 0);
|
||||
});
|
||||
|
||||
test("evidence display and cancellation receipts obey surface bounds", () => {
|
||||
const evidence = Array.from({ length: 10 }, (_, index) => ({
|
||||
kind: index % 2 ? "file" : "source",
|
||||
id: `secret-${index}`,
|
||||
uri: `https://token-${index}`,
|
||||
}));
|
||||
assert.equal(
|
||||
mergeActivityEvents(
|
||||
[],
|
||||
[event(1, "citation.attached", { evidence })],
|
||||
CONVERSATION,
|
||||
"chat",
|
||||
).items[0].evidence.length,
|
||||
3,
|
||||
);
|
||||
assert.equal(
|
||||
mergeActivityEvents(
|
||||
[],
|
||||
[event(1, "citation.attached", { evidence })],
|
||||
CONVERSATION,
|
||||
"worker",
|
||||
).items[0].evidence.length,
|
||||
8,
|
||||
);
|
||||
|
||||
const receipt = {
|
||||
schema: "hux.cancel_receipt.v1",
|
||||
id: "rcpt_test1234",
|
||||
run_id: "run_1",
|
||||
requested_at: "2026-08-24T10:00:00Z",
|
||||
acknowledged_at: "2026-08-24T10:00:01Z",
|
||||
completed_at: "not-a-time",
|
||||
outcome: "cancelled",
|
||||
side_effects: Array.from({ length: 7 }, (_, index) => ({
|
||||
description: index === 0 ? "token=do-not-show" : `Effect ${index}`,
|
||||
reverted: index % 2 === 0,
|
||||
evidence: { kind: "file", id: "secret" },
|
||||
})),
|
||||
};
|
||||
const safe = normalizeCancellationReceipt(receipt, "chat");
|
||||
assert.equal(safe.sideEffects.length, 4);
|
||||
assert.equal(safe.omittedEffects, 3);
|
||||
assert.equal(safe.completedAt, undefined);
|
||||
assert.equal(safe.acknowledgedAt, receipt.acknowledged_at);
|
||||
assert.doesNotMatch(JSON.stringify(safe), /do-not-show|"evidence"|"secret"/);
|
||||
});
|
||||
|
||||
test("cancellation parsing rejects bad identity, time, run, and outcome", () => {
|
||||
const base = {
|
||||
schema: "hux.cancel_receipt.v1",
|
||||
id: "rcpt_test1234",
|
||||
run_id: "run_1",
|
||||
requested_at: "2026-08-24T10:00:00Z",
|
||||
outcome: "already_complete",
|
||||
side_effects: [{ description: {}, reverted: false }],
|
||||
};
|
||||
const safe = normalizeCancellationReceipt(base, "worker");
|
||||
assert.equal(safe.sideEffects[0].description, "A side effect was recorded");
|
||||
assert.equal(safe.completedAt, undefined);
|
||||
assert.equal(
|
||||
normalizeCancellationReceipt({ ...base, schema: "old" }, "chat"),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCancellationReceipt({ ...base, id: "bad" }, "chat"),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCancellationReceipt({ ...base, run_id: 3 }, "chat"),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCancellationReceipt({ ...base, requested_at: "bad" }, "chat"),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCancellationReceipt({ ...base, outcome: "unknown" }, "chat"),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCancellationReceipt({ ...base, side_effects: null }, "chat")
|
||||
.sideEffects.length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
67
testing/tests/test_hermes_hux_ui_activity.py
Normal file
67
testing/tests/test_hermes_hux_ui_activity.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Executable and static quality gates for the isolated HUX-01 frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ACTIVITY = ROOT / "dockerfiles" / "hermes-webui-hux" / "activity"
|
||||
|
||||
|
||||
def test_hux_activity_model_node_suite_and_coverage():
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/activity/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/activity/security.ts",
|
||||
"testing/tests/test_hermes_hux_ui_activity.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_activity_component_is_flagged_accessible_and_payload_safe():
|
||||
component = (ACTIVITY / "ActivityTimeline.tsx").read_text(encoding="utf-8")
|
||||
model = (ACTIVITY / "model.ts").read_text(encoding="utf-8")
|
||||
styles = (ACTIVITY / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled) return null" in component
|
||||
assert "activityTimelineEnabled(flags)" in component
|
||||
assert "FOUNDATION_FLAG" in model and "ACTIVITY_TIMELINE_FLAG" in model
|
||||
assert 'role="log"' in component and 'aria-live="polite"' in component
|
||||
assert 'aria-busy={reconnecting}' in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert ".detail" not in component
|
||||
assert "JSON.stringify" not in component
|
||||
assert "evidence.uri" not in component and "evidence.id" not in component
|
||||
assert ".hux-activity" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_activity_sources_respect_size_and_scope_boundaries():
|
||||
sources = sorted(ACTIVITY.glob("*"))
|
||||
assert {path.name for path in sources} == {
|
||||
"ActivityTimeline.tsx",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in sources:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
assert "dockerfiles/hermes-webui-hux/activity" not in (
|
||||
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
|
||||
).read_text(encoding="utf-8")
|
||||
361
testing/tests/test_hermes_hux_ui_artifacts.mjs
Normal file
361
testing/tests/test_hermes_hux_ui_artifacts.mjs
Normal file
@ -0,0 +1,361 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { createArtifactEndpointContract } from "../../dockerfiles/hermes-webui-hux/artifacts/endpoints.ts";
|
||||
import {
|
||||
ARTIFACTS_FLAG,
|
||||
FOUNDATION_FLAG,
|
||||
PROJECTS_FLAG,
|
||||
artifactsEnabled,
|
||||
normalizeArtifact,
|
||||
normalizeArtifactWorkspace,
|
||||
normalizePreview,
|
||||
preservesImmutableHistory,
|
||||
} from "../../dockerfiles/hermes-webui-hux/artifacts/model.ts";
|
||||
import {
|
||||
ArtifactContractError,
|
||||
isMime,
|
||||
isOpaqueId,
|
||||
isSha256,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
normalizeScope,
|
||||
safeBlobUrl,
|
||||
safePreviewText,
|
||||
safeText,
|
||||
sameIdentity,
|
||||
sameScope,
|
||||
scopedPath,
|
||||
} from "../../dockerfiles/hermes-webui-hux/artifacts/security.ts";
|
||||
|
||||
const IDENTITY = {
|
||||
tenantRef: "tnt_0123456789abcdef",
|
||||
userRef: "usr_0123456789abcdef",
|
||||
surface: "chat",
|
||||
};
|
||||
const RAW_IDENTITY = {
|
||||
tenant_ref: IDENTITY.tenantRef,
|
||||
user_ref: IDENTITY.userRef,
|
||||
surface: IDENTITY.surface,
|
||||
};
|
||||
const SCOPE = {projectId: "prj_test1234", conversationId: "conv_test1234"};
|
||||
const RAW_SCOPE = {project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId};
|
||||
const FLAGS = [FOUNDATION_FLAG, PROJECTS_FLAG, ARTIFACTS_FLAG];
|
||||
const HASH_A = `sha256:${"a".repeat(64)}`;
|
||||
const HASH_B = `sha256:${"b".repeat(64)}`;
|
||||
const CONTRACT_TYPES = JSON.parse(
|
||||
readFileSync("services/hermes/contracts/hux/artifact.schema.json"),
|
||||
).properties.type.enum;
|
||||
|
||||
function version(number = 1, extra = {}) {
|
||||
return {
|
||||
version: number,
|
||||
created_at: `2026-08-24T10:00:0${number}Z`,
|
||||
created_by: {type: "assistant", id: "hermes", display: "Hermes"},
|
||||
content_ref: {hash: number === 1 ? HASH_A : HASH_B, bytes: 120 * number, mime: "text/markdown"},
|
||||
...(number > 1 ? {diff_from: number - 1} : {}),
|
||||
note: `Draft ${number}`,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function artifact(extra = {}) {
|
||||
return {
|
||||
schema: "hux.artifact.v1",
|
||||
id: "art_test1234",
|
||||
owner: IDENTITY.userRef,
|
||||
conversation_id: SCOPE.conversationId,
|
||||
project_id: SCOPE.projectId,
|
||||
type: "markdown",
|
||||
language: "report",
|
||||
title: "Release report",
|
||||
current_version: 2,
|
||||
versions: [version(1), version(2)],
|
||||
sensitivity: "personal",
|
||||
created_at: "2026-08-24T10:00:01Z",
|
||||
updated_at: "2026-08-24T10:00:02Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function page(artifacts = [artifact()], extra = {}) {
|
||||
return {
|
||||
schema: "hux.artifact_workspace.v1",
|
||||
api_version: "hux.v1",
|
||||
identity: RAW_IDENTITY,
|
||||
binding: RAW_SCOPE,
|
||||
artifacts,
|
||||
attachments: [{artifact_id: "art_test1234", source_ids: ["src_test1234"],
|
||||
citation_ids: ["cit_test1234"]}],
|
||||
authorizations: [{artifact_id: "art_test1234", download: "authorized",
|
||||
share: "requires_approval"}],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("artifacts require the foundation and projects", () => {
|
||||
assert.equal(artifactsEnabled(), false);
|
||||
assert.equal(artifactsEnabled([]), false);
|
||||
assert.equal(artifactsEnabled([ARTIFACTS_FLAG]), false);
|
||||
assert.equal(artifactsEnabled([FOUNDATION_FLAG, ARTIFACTS_FLAG]), false);
|
||||
assert.equal(artifactsEnabled([PROJECTS_FLAG, ARTIFACTS_FLAG]), false);
|
||||
assert.equal(artifactsEnabled(FLAGS), true);
|
||||
});
|
||||
|
||||
test("security primitives reject malformed scope and unsafe display data", () => {
|
||||
assert.equal(isOpaqueId("art_test1234", "art"), true);
|
||||
assert.equal(isOpaqueId("prj_test1234", "art"), false);
|
||||
assert.equal(isOpaqueId("bad"), false);
|
||||
assert.equal(isOpaqueId(4), false);
|
||||
assert.equal(isUtc("2026-08-24T10:00:00.123Z"), true);
|
||||
assert.equal(isUtc("2026-99-24T10:00:00Z"), false);
|
||||
assert.equal(isUtc(4), false);
|
||||
assert.equal(isSha256(HASH_A), true);
|
||||
assert.equal(isSha256(`sha256:${"z".repeat(64)}`), false);
|
||||
assert.equal(isSha256(4), false);
|
||||
assert.equal(isMime("text/plain"), true);
|
||||
assert.equal(isMime("bad"), false);
|
||||
assert.equal(isMime("a/" + "b".repeat(121)), false);
|
||||
assert.equal(isMime(4), false);
|
||||
assert.equal(safeText({}, "fallback", 20), "fallback");
|
||||
assert.equal(safeText(" \n ", "fallback", 20), "fallback");
|
||||
assert.equal(safeText("abc\u0000def", "fallback", 20), "abc def");
|
||||
assert.equal(safeText("abcdef", "fallback", 4), "abc…");
|
||||
assert.equal(safeText("token=secret Bearer abc.DEF", "fallback", 200), "[redacted] [redacted]");
|
||||
assert.match(safeText("-----BEGIN PRIVATE KEY----- secret -----END PRIVATE KEY-----", "x", 200), /redacted/);
|
||||
assert.equal(safePreviewText("line one\nline two"), "line one\nline two");
|
||||
assert.equal(safePreviewText("bad\u0000content"), null);
|
||||
assert.equal(safePreviewText("abc", 2), null);
|
||||
assert.equal(safePreviewText({}), null);
|
||||
assert.equal(safeBlobUrl("blob:https://chat.example/abc"), "blob:https://chat.example/abc");
|
||||
for (const unsafe of ["https://example.test/image.png", "data:image/png;base64,x", "blob:has space", 4]) {
|
||||
assert.equal(safeBlobUrl(unsafe), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("identity and binding normalization fail closed", () => {
|
||||
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
|
||||
assert.deepEqual(normalizeScope(RAW_SCOPE), SCOPE);
|
||||
assert.equal(sameIdentity(IDENTITY, {...IDENTITY}), true);
|
||||
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
|
||||
assert.equal(sameScope(SCOPE, {...SCOPE}), true);
|
||||
assert.equal(sameScope(SCOPE, {...SCOPE, projectId: "prj_other1234"}), false);
|
||||
for (const bad of [null, [], {}, {...RAW_IDENTITY, tenant_ref: "tenant"},
|
||||
{...RAW_IDENTITY, user_ref: "user"}, {...RAW_IDENTITY, surface: "browser"}]) {
|
||||
assert.throws(() => normalizeIdentity(bad), ArtifactContractError);
|
||||
}
|
||||
for (const bad of [null, [], {}, {...RAW_SCOPE, project_id: "bad"},
|
||||
{...RAW_SCOPE, conversation_id: "bad"}]) {
|
||||
assert.throws(() => normalizeScope(bad), ArtifactContractError);
|
||||
}
|
||||
});
|
||||
|
||||
test("schema types select safe renderers without live HTML or SVG", () => {
|
||||
const cases = [
|
||||
["markdown", "text/markdown", "", "document"],
|
||||
["markdown", "text/plain", "research", "report"],
|
||||
["document", "application/pdf", "analysis", "report"],
|
||||
["document", "text/plain", "", "document"],
|
||||
["code", "text/x-python", "python", "code"],
|
||||
["html", "text/html", "html", "code"],
|
||||
["svg", "image/svg+xml", "svg", "code"],
|
||||
["json", "application/json", "json", "data"],
|
||||
["json", "text/plain", "json", "data"],
|
||||
["csv", "text/csv", "csv", "data"],
|
||||
["csv", "text/plain", "csv", "data"],
|
||||
["image", "image/png", "", "image"],
|
||||
["image", "image/jpeg", "", "image"],
|
||||
["image", "image/webp", "", "image"],
|
||||
["image", "image/gif", "", "image"],
|
||||
["audio", "audio/wav", "", "audio"],
|
||||
["audio", "audio/mpeg", "", "audio"],
|
||||
["audio", "audio/ogg", "", "audio"],
|
||||
["audio", "audio/webm", "", "audio"],
|
||||
];
|
||||
assert.deepEqual(new Set(cases.map(([type]) => type)), new Set(CONTRACT_TYPES));
|
||||
for (const [type, mime, language, renderer] of cases) {
|
||||
const raw = artifact({type, language, current_version: 1,
|
||||
versions: [version(1, {content_ref: {hash: HASH_A, bytes: 1, mime}})]});
|
||||
assert.equal(normalizeArtifact(raw, IDENTITY.userRef, SCOPE)?.renderer, renderer);
|
||||
}
|
||||
});
|
||||
|
||||
test("valid artifact preserves immutable content metadata and promotion", () => {
|
||||
const raw = artifact({
|
||||
promotion: {project_id: SCOPE.projectId, version: 1, at: "2026-08-24T10:00:03Z"},
|
||||
versions: [version(1), version(2, {
|
||||
lineage: {artifact_id: "art_source1234", version: 3},
|
||||
created_by: {type: "user", id: "private-user"},
|
||||
})],
|
||||
});
|
||||
const item = normalizeArtifact(raw, IDENTITY.userRef, SCOPE);
|
||||
assert.equal(item.promotedVersion, 1);
|
||||
assert.equal(item.renderer, "report");
|
||||
assert.equal(item.versions[1].createdBy, "user");
|
||||
assert.deepEqual(item.versions[1].lineage, {artifactId: "art_source1234", version: 3});
|
||||
assert.doesNotMatch(JSON.stringify(item), /private-user|Hermes/);
|
||||
});
|
||||
|
||||
test("artifact records reject malformed, rewritten, and cross-scope history", () => {
|
||||
const bad = [
|
||||
artifact({schema: "hux.artifact.v0"}), artifact({id: "bad"}),
|
||||
artifact({owner: "usr_ffffffffffffffff"}), artifact({project_id: "prj_other1234"}),
|
||||
artifact({conversation_id: "conv_other1234"}), artifact({type: "binary"}),
|
||||
artifact({current_version: 0}), artifact({current_version: 3}), artifact({versions: []}),
|
||||
artifact({versions: "no"}), artifact({versions: Array.from({length: 201}, () => version(1))}),
|
||||
artifact({sensitivity: "secret"}), artifact({created_at: "today"}),
|
||||
artifact({updated_at: "today"}), artifact({updated_at: "2026-08-24T09:00:00Z"}),
|
||||
artifact({title: ""}), artifact({title: {private: true}}),
|
||||
artifact({versions: [version(1), version(1)]}),
|
||||
artifact({versions: [version(1), version(2, {created_at: "2026-08-24T09:00:00Z"})]}),
|
||||
artifact({versions: [version(1), version(2, {diff_from: 2})]}),
|
||||
artifact({versions: [version(1), version(2, {lineage: {artifact_id: "art_test1234", version: 2}})]}),
|
||||
artifact({promotion: null}),
|
||||
artifact({promotion: {project_id: "prj_other1234", version: 1, at: "2026-08-24T10:00:03Z"}}),
|
||||
artifact({promotion: {project_id: SCOPE.projectId, version: 3, at: "2026-08-24T10:00:03Z"}}),
|
||||
artifact({promotion: {project_id: SCOPE.projectId, version: 1, at: "today"}}),
|
||||
artifact({type: "image", current_version: 1, versions: [version(1)]}),
|
||||
];
|
||||
for (const raw of bad) assert.equal(normalizeArtifact(raw, IDENTITY.userRef, SCOPE), null);
|
||||
|
||||
const badVersions = [
|
||||
version(1, {version: 0}), version(1, {created_at: "today"}), version(1, {created_by: null}),
|
||||
version(1, {created_by: {type: "intruder", id: "x"}}),
|
||||
version(1, {created_by: {type: "user", id: ""}}),
|
||||
version(1, {content_ref: null}), version(1, {content_ref: {hash: "bad", bytes: 1, mime: "text/markdown"}}),
|
||||
version(1, {content_ref: {hash: HASH_A, bytes: -1, mime: "text/markdown"}}),
|
||||
version(1, {content_ref: {hash: HASH_A, bytes: 1, mime: "bad"}}),
|
||||
version(1, {diff_from: 0}), version(1, {lineage: null}),
|
||||
version(1, {lineage: {artifact_id: "bad", version: 1}}),
|
||||
version(1, {lineage: {artifact_id: "art_source1234", version: 0}}),
|
||||
];
|
||||
for (const invalid of badVersions) {
|
||||
assert.equal(normalizeArtifact(artifact({current_version: 1, versions: [invalid]}),
|
||||
IDENTITY.userRef, SCOPE), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace envelope enforces tenant, user, project, and conversation binding", () => {
|
||||
const result = normalizeArtifactWorkspace(page(), IDENTITY, SCOPE);
|
||||
assert.equal(result.rejected, 0);
|
||||
assert.equal(result.artifacts.length, 1);
|
||||
assert.deepEqual(result.attachments, [{artifactId: "art_test1234", sourceCount: 1, citationCount: 1}]);
|
||||
assert.deepEqual(result.authorizations, [{artifactId: "art_test1234", download: "authorized",
|
||||
share: "requires_approval"}]);
|
||||
for (const bad of [null, {schema: "old"}, page([], {api_version: "hux.v0"}),
|
||||
page([], {identity: {...RAW_IDENTITY, tenant_ref: "tnt_ffffffffffffffff"}}),
|
||||
page([], {identity: {...RAW_IDENTITY, user_ref: "usr_ffffffffffffffff"}}),
|
||||
page([], {identity: {...RAW_IDENTITY, surface: "worker"}}),
|
||||
page([], {binding: {...RAW_SCOPE, project_id: "prj_other1234"}}),
|
||||
page([], {binding: {...RAW_SCOPE, conversation_id: "conv_other1234"}})]) {
|
||||
assert.throws(() => normalizeArtifactWorkspace(bad, IDENTITY, SCOPE), /scope boundary|identity/);
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace rejects bad entries and ignores unsafe attachment metadata", () => {
|
||||
const duplicate = artifact();
|
||||
const result = normalizeArtifactWorkspace(page([artifact(), duplicate, artifact({id: "bad"})], {
|
||||
attachments: [
|
||||
{artifact_id: "art_test1234", source_ids: ["src_test1234"], citation_ids: []},
|
||||
{artifact_id: "art_test1234", source_ids: [], citation_ids: []},
|
||||
{artifact_id: "art_unknown1", source_ids: [], citation_ids: []},
|
||||
{artifact_id: "art_other1234", source_ids: ["bad"], citation_ids: []},
|
||||
null,
|
||||
],
|
||||
authorizations: [
|
||||
{artifact_id: "art_test1234", download: "authorized", share: "unavailable"},
|
||||
{artifact_id: "art_test1234", download: "authorized", share: "authorized"},
|
||||
{artifact_id: "art_unknown1", download: "authorized", share: "authorized"},
|
||||
{artifact_id: "art_other1234", download: "yes", share: "authorized"},
|
||||
null,
|
||||
],
|
||||
}), IDENTITY, SCOPE);
|
||||
assert.equal(result.artifacts.length, 1);
|
||||
assert.equal(result.rejected, 2);
|
||||
assert.equal(result.attachments.length, 1);
|
||||
assert.equal(result.authorizations.length, 1);
|
||||
assert.deepEqual(normalizeArtifactWorkspace(page([], {artifacts: null, attachments: {},
|
||||
authorizations: {}}), IDENTITY, SCOPE).artifacts, []);
|
||||
});
|
||||
|
||||
test("previews require matching identity, binding, hash, MIME, and local object URLs", () => {
|
||||
const item = normalizeArtifact(artifact(), IDENTITY.userRef, SCOPE);
|
||||
const base = {schema: "hux.artifact_preview.v1", identity: RAW_IDENTITY, binding: RAW_SCOPE,
|
||||
artifact_id: item.id, version: 2, hash: HASH_B, mime: "text/markdown", text: "# Safe report"};
|
||||
assert.equal(normalizePreview(base, item, IDENTITY, SCOPE)?.text, "# Safe report");
|
||||
const invalid = [null, {...base, schema: "old"}, {...base, identity: {...RAW_IDENTITY, surface: "worker"}},
|
||||
{...base, binding: {...RAW_SCOPE, project_id: "prj_other1234"}}, {...base, artifact_id: "art_other1234"},
|
||||
{...base, version: 3}, {...base, hash: HASH_A}, {...base, mime: "text/plain"},
|
||||
{...base, text: "bad\u0000text"}, {...base, blob_url: "blob:local"}];
|
||||
for (const raw of invalid) assert.equal(normalizePreview(raw, item, IDENTITY, SCOPE), null);
|
||||
assert.equal(normalizePreview(base, item, {...IDENTITY, userRef: "bad"}, SCOPE), null);
|
||||
|
||||
const image = normalizeArtifact(artifact({type: "image", language: "", current_version: 1,
|
||||
versions: [version(1, {content_ref: {hash: HASH_A, bytes: 5, mime: "image/png"}})]}),
|
||||
IDENTITY.userRef, SCOPE);
|
||||
const imagePreview = {...base, artifact_id: image.id, version: 1, hash: HASH_A, mime: "image/png",
|
||||
text: undefined, blob_url: "blob:https://chat.example/image"};
|
||||
assert.equal(normalizePreview(imagePreview, image, IDENTITY, SCOPE)?.blobUrl,
|
||||
"blob:https://chat.example/image");
|
||||
assert.equal(normalizePreview({...imagePreview, blob_url: "https://remote/image"}, image,
|
||||
IDENTITY, SCOPE), null);
|
||||
assert.equal(normalizePreview({...imagePreview, text: "inline"}, image, IDENTITY, SCOPE), null);
|
||||
});
|
||||
|
||||
test("immutable history permits append-only versions but not rewrites or orphaning", () => {
|
||||
const before = normalizeArtifact(artifact(), IDENTITY.userRef, SCOPE);
|
||||
const appendedRaw = artifact({current_version: 3, updated_at: "2026-08-24T10:00:03Z",
|
||||
versions: [...artifact().versions, version(3, {content_ref: {hash: HASH_A, bytes: 360,
|
||||
mime: "text/markdown"}})]});
|
||||
const appended = normalizeArtifact(appendedRaw, IDENTITY.userRef, SCOPE);
|
||||
assert.equal(preservesImmutableHistory(before, before), true);
|
||||
assert.equal(preservesImmutableHistory(before, appended), true);
|
||||
const changes = [
|
||||
{...before, id: "art_other1234"}, {...before, owner: "usr_ffffffffffffffff"},
|
||||
{...before, projectId: "prj_other1234"}, {...before, conversationId: "conv_other1234"},
|
||||
{...before, type: "document"}, {...before, versions: before.versions.slice(1), currentVersion: 1},
|
||||
{...before, versions: [{...before.versions[0], note: "rewritten"}, before.versions[1]]},
|
||||
{...before, currentVersion: 1},
|
||||
];
|
||||
for (const changed of changes) assert.equal(preservesImmutableHistory(before, changed), false);
|
||||
});
|
||||
|
||||
test("endpoint contract is default-off, scoped, immutable, and optimistic", () => {
|
||||
const client = {apiVersion: "hux.v1", identity: IDENTITY,
|
||||
endpoint(path) { assert.match(path, /^\/projects\/prj_[^/]+\/conversations\/conv_[^/]+\/artifacts/);
|
||||
return `/hux/v1${path}`; }};
|
||||
assert.equal(createArtifactEndpointContract(client, SCOPE), null);
|
||||
assert.equal(createArtifactEndpointContract(client, SCOPE, [FOUNDATION_FLAG, ARTIFACTS_FLAG]), null);
|
||||
const contract = createArtifactEndpointContract(client, SCOPE, FLAGS);
|
||||
assert.equal(contract.list.path, "/hux/v1/projects/prj_test1234/conversations/conv_test1234/artifacts");
|
||||
assert.equal(contract.item("art_test1234").method, "GET");
|
||||
assert.match(contract.preview("art_test1234", 2).path, /versions\/2\/preview$/);
|
||||
assert.deepEqual(contract.diff("art_test1234", 1, 2).requiredBody,
|
||||
["from_version", "to_version", "expected_current_version"]);
|
||||
assert.match(contract.appendVersion("art_test1234").path, /versions$/);
|
||||
assert.match(contract.continueEdit("art_test1234").path, /continue$/);
|
||||
assert.match(contract.authorize("art_test1234", "download").path, /authorizations\/download$/);
|
||||
assert.match(contract.download("art_test1234", 1).path, /versions\/1\/download$/);
|
||||
assert.match(contract.share("art_test1234", 1).path, /versions\/1\/shares$/);
|
||||
assert.match(contract.promote("art_test1234").path, /promotions$/);
|
||||
assert.match(contract.sources("art_test1234").path, /sources$/);
|
||||
for (const call of [() => contract.item("bad"), () => contract.preview("art_test1234", 0),
|
||||
() => contract.diff("art_test1234", 2, 1), () => contract.authorize("art_test1234", "execute")]) {
|
||||
assert.throws(call, TypeError);
|
||||
}
|
||||
assert.throws(() => createArtifactEndpointContract({...client, apiVersion: "hux.v2"}, SCOPE, FLAGS),
|
||||
/HUX v1/);
|
||||
assert.throws(() => createArtifactEndpointContract({...client,
|
||||
identity: {...IDENTITY, tenantRef: "bad"}}, SCOPE, FLAGS), /identity/);
|
||||
});
|
||||
|
||||
test("scoped path rejects traversal and malformed identifiers", () => {
|
||||
assert.equal(scopedPath("/projects", SCOPE, "art_test1234"),
|
||||
"/projects/prj_test1234/conversations/conv_test1234/artifacts/art_test1234");
|
||||
for (const call of [() => scopedPath("https://remote", SCOPE), () => scopedPath("//remote", SCOPE),
|
||||
() => scopedPath("/../projects", SCOPE), () => scopedPath("/projects?x", SCOPE),
|
||||
() => scopedPath("/projects", {...SCOPE, projectId: "bad"}),
|
||||
() => scopedPath("/projects", {...SCOPE, conversationId: "bad"}),
|
||||
() => scopedPath("/projects", SCOPE, "bad")]) assert.throws(call, TypeError);
|
||||
});
|
||||
76
testing/tests/test_hermes_hux_ui_artifacts.py
Normal file
76
testing/tests/test_hermes_hux_ui_artifacts.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Executable and static quality gates for isolated HUX-04 artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ARTIFACTS = ROOT / "dockerfiles" / "hermes-webui-hux" / "artifacts"
|
||||
|
||||
|
||||
def test_hux_artifact_model_node_suite_and_coverage():
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-branches=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/artifacts/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/artifacts/security.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/artifacts/endpoints.ts",
|
||||
"testing/tests/test_hermes_hux_ui_artifacts.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_artifact_component_is_flagged_accessible_and_inert():
|
||||
component = (ARTIFACTS / "ArtifactWorkspace.tsx").read_text(encoding="utf-8")
|
||||
model = (ARTIFACTS / "model.ts").read_text(encoding="utf-8")
|
||||
endpoints = (ARTIFACTS / "endpoints.ts").read_text(encoding="utf-8")
|
||||
styles = (ARTIFACTS / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled || !page) return null" in component
|
||||
assert "artifactsEnabled(props.flags)" in component
|
||||
assert all(flag in model for flag in ("FOUNDATION_FLAG", "PROJECTS_FLAG", "ARTIFACTS_FLAG"))
|
||||
assert 'aria-label="Artifact workspace"' in component
|
||||
assert 'aria-live="polite"' in component
|
||||
assert "Immutable version history" in component
|
||||
assert "Continue editing" in component
|
||||
assert "Promote version to project" in component
|
||||
assert "Sources attached" in component and "Citations attached" in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert "<iframe" not in component and "<object" not in component
|
||||
assert "fetch(" not in endpoints and "axios" not in endpoints
|
||||
assert "expected_current_version" in endpoints
|
||||
assert '"PATCH"' not in endpoints and '"DELETE"' not in endpoints
|
||||
assert ".hux-artifacts" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_artifact_sources_respect_size_and_isolated_scope():
|
||||
sources = sorted(ARTIFACTS.glob("*"))
|
||||
assert {path.name for path in sources} == {
|
||||
"ArtifactWorkspace.tsx",
|
||||
"endpoints.ts",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in sources:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
assert "dockerfiles/hermes-webui-hux/artifacts" not in (
|
||||
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
|
||||
).read_text(encoding="utf-8")
|
||||
313
testing/tests/test_hermes_hux_ui_autonomy.mjs
Normal file
313
testing/tests/test_hermes_hux_ui_autonomy.mjs
Normal file
@ -0,0 +1,313 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createAutonomyEndpointContract } from "../../dockerfiles/hermes-webui-hux/autonomy/endpoints.ts";
|
||||
import {
|
||||
ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, BUDGET_MAX, CAPABILITIES,
|
||||
FOUNDATION_FLAG, actionAuthorization, autonomyControlsEnabled,
|
||||
buildApprovalDecision, buildPolicyUpdate, buildStopIntent,
|
||||
normalizeAutonomyPage, normalizeStopReceipt,
|
||||
} from "../../dockerfiles/hermes-webui-hux/autonomy/model.ts";
|
||||
import {
|
||||
boundedInteger, exactKeys, identityKey, isOpaqueId, isUtc, normalizeIdentity,
|
||||
safeText, sameIdentity,
|
||||
} from "../../dockerfiles/hermes-webui-hux/autonomy/security.ts";
|
||||
|
||||
const IDENTITY = {
|
||||
tenantRef: "tnt_0123456789abcdef",
|
||||
userRef: "usr_0123456789abcdef",
|
||||
surface: "chat",
|
||||
};
|
||||
const CONVERSATION = "conv_test1234";
|
||||
const NOW = Date.parse("2026-08-24T10:05:00Z");
|
||||
|
||||
const grants = (decision = "ask") => CAPABILITIES.map((capability) => ({capability, decision}));
|
||||
|
||||
function policy(extra = {}) {
|
||||
return {
|
||||
schema: "hux.policy.v1",
|
||||
id: "pol_test1234",
|
||||
owner: IDENTITY.userRef,
|
||||
scope: {level: "conversation", scope_id: CONVERSATION},
|
||||
autonomy: "safe",
|
||||
grants: grants(),
|
||||
budgets: {
|
||||
tokens_per_run: 200000,
|
||||
tool_calls_per_run: 40,
|
||||
wall_clock_seconds: 900,
|
||||
delegations_per_run: 4,
|
||||
},
|
||||
provenance: {surface: "chat"},
|
||||
updated_at: "2026-08-24T10:00:00Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function approval(id = "apr_test1234", extra = {}) {
|
||||
return {
|
||||
schema: "hux.approval.v1",
|
||||
id,
|
||||
run_id: "run_test1234",
|
||||
conversation_id: CONVERSATION,
|
||||
capability: "send_message",
|
||||
request: {summary: "Send the completed report", risk: "high"},
|
||||
status: "pending",
|
||||
requested_at: "2026-08-24T10:00:00Z",
|
||||
expires_at: "2026-08-24T10:10:00Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(extra = {}) {
|
||||
return {
|
||||
schema: "hux.autonomy_page.v1",
|
||||
api_version: "hux.v1",
|
||||
identity: {tenant_ref: IDENTITY.tenantRef, user_ref: IDENTITY.userRef, surface: "chat"},
|
||||
conversation_id: CONVERSATION,
|
||||
policy: policy(),
|
||||
approvals: [approval()],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("HUX-05 is inert unless all three dependency flags are present", () => {
|
||||
assert.equal(autonomyControlsEnabled(), false);
|
||||
assert.equal(autonomyControlsEnabled([]), false);
|
||||
assert.equal(autonomyControlsEnabled([AUTONOMY_FLAG]), false);
|
||||
assert.equal(autonomyControlsEnabled([FOUNDATION_FLAG, AUTONOMY_FLAG]), false);
|
||||
assert.equal(autonomyControlsEnabled([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG]), false);
|
||||
assert.equal(autonomyControlsEnabled([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]), true);
|
||||
});
|
||||
|
||||
test("security helpers reject unscoped identity and redact display text", () => {
|
||||
assert.deepEqual(normalizeIdentity({tenant_ref: IDENTITY.tenantRef,
|
||||
user_ref: IDENTITY.userRef, surface: "chat"}), IDENTITY);
|
||||
assert.deepEqual(normalizeIdentity(IDENTITY), IDENTITY);
|
||||
assert.equal(normalizeIdentity(null), null);
|
||||
assert.equal(normalizeIdentity([]), null);
|
||||
assert.equal(normalizeIdentity({...IDENTITY, tenantRef: "tenant"}), null);
|
||||
assert.equal(normalizeIdentity({...IDENTITY, userRef: "brad@example.test"}), null);
|
||||
assert.equal(normalizeIdentity({...IDENTITY, surface: "browser"}), null);
|
||||
assert.equal(sameIdentity(IDENTITY, {...IDENTITY}), true);
|
||||
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
|
||||
assert.match(identityKey(IDENTITY), /^tnt_.*:usr_.*:chat$/);
|
||||
assert.equal(isOpaqueId("apr_test1234", "apr"), true);
|
||||
assert.equal(isOpaqueId("pol_test1234", "apr"), false);
|
||||
assert.equal(isOpaqueId(4), false);
|
||||
assert.equal(isUtc("2026-08-24T10:00:00.123Z"), true);
|
||||
assert.equal(isUtc("2026-99-24T10:00:00Z"), false);
|
||||
assert.equal(isUtc(4), false);
|
||||
assert.equal(boundedInteger(5, 5), 5);
|
||||
assert.equal(boundedInteger(-1, 5), null);
|
||||
assert.equal(boundedInteger(6, 5), null);
|
||||
assert.equal(boundedInteger(1.5, 5), null);
|
||||
assert.equal(exactKeys({b: 1, a: 2}, ["a", "b"]), true);
|
||||
assert.equal(exactKeys({a: 1, extra: 2}, ["a"]), false);
|
||||
assert.equal(safeText({}, "fallback"), "fallback");
|
||||
assert.equal(safeText(" \n ", "fallback"), "fallback");
|
||||
assert.equal(safeText("token=secret Bearer abc.def\u0000", "fallback"),
|
||||
"token=[redacted] Bearer [redacted]");
|
||||
assert.equal(safeText("abcdef", "fallback", 4), "abc…");
|
||||
});
|
||||
|
||||
test("policy and pending approvals normalize against exact identity and scope", () => {
|
||||
const page = normalizeAutonomyPage(envelope(), IDENTITY, CONVERSATION, NOW);
|
||||
assert.ok(page);
|
||||
assert.equal(page.policy.autonomy, "safe");
|
||||
assert.deepEqual(page.policy.scope, {level: "conversation", scopeId: CONVERSATION});
|
||||
assert.deepEqual(page.policy.budgets, {
|
||||
tokensPerRun: 200000, toolCallsPerRun: 40, wallClockSeconds: 900,
|
||||
delegationsPerRun: 4,
|
||||
});
|
||||
assert.equal(page.approvals.length, 1);
|
||||
assert.equal(page.approvals[0].risk, "high");
|
||||
assert.equal(page.rejectedApprovals, 0);
|
||||
|
||||
const partial = normalizeAutonomyPage(envelope({approvals: [
|
||||
approval(),
|
||||
approval("apr_expired1", {expires_at: "2026-08-24T10:04:59Z"}),
|
||||
approval("apr_cross123", {conversation_id: "conv_other1234"}),
|
||||
approval("apr_secret12", {request: {summary: "token=do-not-show", risk: "low"}}),
|
||||
]}), IDENTITY, CONVERSATION, NOW);
|
||||
assert.equal(partial.approvals.length, 2);
|
||||
assert.equal(partial.rejectedApprovals, 2);
|
||||
assert.doesNotMatch(partial.approvals[1].summary, /do-not-show/);
|
||||
});
|
||||
|
||||
test("page normalization rejects malformed envelopes, policies, and budgets", () => {
|
||||
const invalidEnvelopes = [
|
||||
null,
|
||||
{...envelope(), schema: "hux.autonomy_page.v0"},
|
||||
{...envelope(), api_version: "hux.v0"},
|
||||
{...envelope(), conversation_id: "bad"},
|
||||
{...envelope(), identity: {...envelope().identity, user_ref: "usr_ffffffffffffffff"}},
|
||||
{...envelope(), identity: null},
|
||||
{...envelope(), approvals: {}},
|
||||
{...envelope(), approvals: Array(101).fill(approval())},
|
||||
{...envelope(), policy: {...policy(), surprise: true}},
|
||||
{...envelope(), policy: {...policy(), schema: "old"}},
|
||||
{...envelope(), policy: {...policy(), id: "bad"}},
|
||||
{...envelope(), policy: {...policy(), owner: "usr_ffffffffffffffff"}},
|
||||
{...envelope(), policy: {...policy(), autonomy: "reckless"}},
|
||||
{...envelope(), policy: {...policy(), updated_at: "today"}},
|
||||
{...envelope(), policy: {...policy(), scope: null}},
|
||||
{...envelope(), policy: {...policy(), scope: {level: "global", scope_id: CONVERSATION}}},
|
||||
{...envelope(), policy: {...policy(), scope: {level: "project", scope_id: "bad"}}},
|
||||
{...envelope(), policy: {...policy(), scope: {level: "planet"}}},
|
||||
{...envelope(), policy: {...policy(), grants: {}}},
|
||||
{...envelope(), policy: {...policy(), grants: Array(65).fill({capability: "shell", decision: "ask"})}},
|
||||
{...envelope(), policy: {...policy(), grants: [{capability: "unknown", decision: "ask"}]}},
|
||||
{...envelope(), policy: {...policy(), grants: [{capability: "shell", decision: "maybe"}]}},
|
||||
{...envelope(), policy: {...policy(), grants: [{capability: "shell", decision: "ask"}, {capability: "shell", decision: "deny"}]}},
|
||||
{...envelope(), policy: {...policy(), budgets: null}},
|
||||
{...envelope(), policy: {...policy(), budgets: {unknown: 1}}},
|
||||
{...envelope(), policy: {...policy(), budgets: {tokens_per_run: BUDGET_MAX.tokensPerRun + 1}}},
|
||||
];
|
||||
for (const value of invalidEnvelopes) {
|
||||
assert.equal(normalizeAutonomyPage(value, IDENTITY, CONVERSATION, NOW), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("approval rows fail closed when malformed, resolved, stale, or reordered", () => {
|
||||
const invalid = [
|
||||
null,
|
||||
{...approval(), extra: true},
|
||||
{...approval(), schema: "old"},
|
||||
{...approval(), id: "bad"},
|
||||
{...approval(), run_id: ""},
|
||||
{...approval(), run_id: "x".repeat(121)},
|
||||
{...approval(), capability: "unknown"},
|
||||
{...approval(), status: "approved"},
|
||||
{...approval(), requested_at: "today"},
|
||||
{...approval(), expires_at: "today"},
|
||||
{...approval(), requested_at: "2026-08-24T10:11:00Z"},
|
||||
{...approval(), request: null},
|
||||
{...approval(), request: {summary: "x", risk: "extreme"}},
|
||||
{...approval(), request: {summary: "x", risk: "low", injected: true}},
|
||||
];
|
||||
const page = normalizeAutonomyPage(envelope({approvals: invalid}), IDENTITY, CONVERSATION, NOW);
|
||||
assert.deepEqual(page.approvals, []);
|
||||
assert.equal(page.rejectedApprovals, invalid.length);
|
||||
});
|
||||
|
||||
test("policy updates preserve canonical budgets and require bounded spend and scope", () => {
|
||||
const page = normalizeAutonomyPage(envelope(), IDENTITY, CONVERSATION, NOW);
|
||||
const draft = {...page.policy.budgets, spendCentsPerRun: 2500, scopeLimit: "conversation"};
|
||||
const intent = buildPolicyUpdate(page.policy, IDENTITY, "autonomous", page.policy.grants, draft);
|
||||
assert.equal(intent.expectedPolicyId, page.policy.id);
|
||||
assert.equal(intent.guardrails.spendCentsPerRun, 2500);
|
||||
assert.equal("spendCentsPerRun" in intent.budgets, false);
|
||||
assert.deepEqual(Object.keys(intent.grants), CAPABILITIES);
|
||||
const invalidDrafts = [
|
||||
{...draft, tokensPerRun: -1}, {...draft, toolCallsPerRun: 501},
|
||||
{...draft, wallClockSeconds: 86401}, {...draft, delegationsPerRun: 33},
|
||||
{...draft, spendCentsPerRun: 100001}, {...draft, scopeLimit: "internet"},
|
||||
];
|
||||
for (const bad of invalidDrafts) assert.throws(() =>
|
||||
buildPolicyUpdate(page.policy, IDENTITY, "safe", page.policy.grants, bad), /bounds/);
|
||||
assert.throws(() => buildPolicyUpdate(page.policy, {...IDENTITY, userRef: "usr_ffffffffffffffff"},
|
||||
"safe", page.policy.grants, draft), /bounds/);
|
||||
assert.throws(() => buildPolicyUpdate(page.policy, IDENTITY, "turbo", page.policy.grants, draft), /bounds/);
|
||||
assert.throws(() => buildPolicyUpdate(page.policy, IDENTITY, "safe",
|
||||
{...page.policy.grants, shell: "maybe"}, draft), /bounds/);
|
||||
});
|
||||
|
||||
test("authorization requires exact live approval whenever policy says ask", () => {
|
||||
const page = normalizeAutonomyPage(envelope(), IDENTITY, CONVERSATION, NOW);
|
||||
const pending = page.approvals[0];
|
||||
assert.equal(actionAuthorization(null, "read_files", null, IDENTITY, NOW), "deny");
|
||||
assert.equal(actionAuthorization({...page.policy, owner: "usr_ffffffffffffffff"},
|
||||
"read_files", null, IDENTITY, NOW), "deny");
|
||||
assert.equal(actionAuthorization(page.policy, "unknown", null, IDENTITY, NOW), "deny");
|
||||
assert.equal(actionAuthorization(page.policy, "send_message", null, IDENTITY, NOW), "approval_required");
|
||||
assert.equal(actionAuthorization(page.policy, "send_message", pending, IDENTITY, NOW), "approval_required");
|
||||
assert.equal(actionAuthorization(page.policy, "shell", pending, IDENTITY, NOW), "approval_required");
|
||||
assert.equal(actionAuthorization(page.policy, "send_message", {...pending, status: "denied"}, IDENTITY, NOW), "approval_required");
|
||||
assert.equal(actionAuthorization(page.policy, "send_message", {...pending, expiresAt: "2026-08-24T10:04:00Z"}, IDENTITY, NOW), "approval_required");
|
||||
assert.equal(actionAuthorization(page.policy, "send_message", {...pending, identityKey: "cross"}, IDENTITY, NOW), "approval_required");
|
||||
const allow = {...page.policy, autonomy: "autonomous",
|
||||
grants: {...page.policy.grants, read_files: "allow", send_message: "allow"}};
|
||||
assert.equal(actionAuthorization(allow, "read_files", null, IDENTITY, NOW), "allow");
|
||||
assert.equal(actionAuthorization(allow, "send_message", null, IDENTITY, NOW), "allow");
|
||||
const safe = {...allow, autonomy: "safe"};
|
||||
assert.equal(actionAuthorization(safe, "read_files", null, IDENTITY, NOW), "allow");
|
||||
assert.equal(actionAuthorization(safe, "send_message", null, IDENTITY, NOW), "approval_required");
|
||||
assert.equal(actionAuthorization({...allow, grants: {...allow.grants, deploy: "deny"}},
|
||||
"deploy", null, IDENTITY, NOW), "deny");
|
||||
});
|
||||
|
||||
test("approval decision proves identity and original timestamp", () => {
|
||||
const item = normalizeAutonomyPage(envelope(), IDENTITY, CONVERSATION, NOW).approvals[0];
|
||||
for (const choice of ["once", "session", "always", "deny"]) {
|
||||
const intent = buildApprovalDecision(item, choice, IDENTITY, item.requestedAt, NOW);
|
||||
assert.equal(intent.choice, choice);
|
||||
assert.equal(intent.expectedRunId, item.runId);
|
||||
assert.equal(intent.expectedConversationId, CONVERSATION);
|
||||
}
|
||||
assert.throws(() => buildApprovalDecision(item, "once", IDENTITY, "2026-08-24T10:00:01Z", NOW), /denied/);
|
||||
assert.throws(() => buildApprovalDecision(item, "once", {...IDENTITY, surface: "worker"}, item.requestedAt, NOW), /denied/);
|
||||
assert.throws(() => buildApprovalDecision({...item, status: "denied"}, "once", IDENTITY, item.requestedAt, NOW), /denied/);
|
||||
assert.throws(() => buildApprovalDecision({...item, expiresAt: "2026-08-24T10:04:00Z"}, "once", IDENTITY, item.requestedAt, NOW), /denied/);
|
||||
assert.throws(() => buildApprovalDecision(item, "later", IDENTITY, item.requestedAt, NOW), /denied/);
|
||||
});
|
||||
|
||||
test("stop intent and receipt require exact ownership and remain bounded", () => {
|
||||
const run = {runId: "run_test1234", conversationId: CONVERSATION,
|
||||
tenantRef: IDENTITY.tenantRef, owner: IDENTITY.userRef};
|
||||
assert.deepEqual(buildStopIntent(run, IDENTITY, run.runId), {
|
||||
runId: run.runId, conversationId: CONVERSATION, identity: IDENTITY,
|
||||
});
|
||||
for (const bad of [
|
||||
{...run, runId: ""}, {...run, runId: "x".repeat(121)},
|
||||
{...run, conversationId: "bad"}, {...run, owner: "usr_ffffffffffffffff"},
|
||||
{...run, tenantRef: "tnt_ffffffffffffffff"},
|
||||
]) assert.throws(() => buildStopIntent(bad, IDENTITY, run.runId), /ownership/);
|
||||
assert.throws(() => buildStopIntent(run, IDENTITY, "run_stale1234"), /ownership/);
|
||||
|
||||
const raw = {
|
||||
schema: "hux.cancel_receipt.v1", id: "rcpt_test1234", run_id: run.runId,
|
||||
requested_by: {type: "user", id: IDENTITY.userRef},
|
||||
requested_at: "2026-08-24T10:05:00Z", outcome: "cancelled",
|
||||
side_effects: Array.from({length: 7}, (_, index) => ({
|
||||
description: index ? `Effect ${index}` : "token=do-not-show", reverted: index % 2 === 0,
|
||||
})),
|
||||
};
|
||||
const receipt = normalizeStopReceipt(raw, run, IDENTITY);
|
||||
assert.equal(receipt.effects.length, 4);
|
||||
assert.equal(receipt.omittedEffects, 3);
|
||||
assert.equal(receipt.stopped, 2);
|
||||
assert.equal(receipt.remaining, 2);
|
||||
assert.doesNotMatch(JSON.stringify(receipt), /do-not-show/);
|
||||
assert.equal(normalizeStopReceipt({...raw, run_id: "run_other1234"}, run, IDENTITY), null);
|
||||
assert.equal(normalizeStopReceipt({...raw, requested_by: null}, run, IDENTITY), null);
|
||||
assert.equal(normalizeStopReceipt({...raw, requested_by: {type: "system", id: IDENTITY.userRef}}, run, IDENTITY), null);
|
||||
assert.equal(normalizeStopReceipt({...raw, requested_by: {type: "user", id: "usr_ffffffffffffffff"}}, run, IDENTITY), null);
|
||||
assert.equal(normalizeStopReceipt({...raw, schema: "old"}, run, IDENTITY), null);
|
||||
assert.equal(normalizeStopReceipt(raw, {...run, owner: "usr_ffffffffffffffff"}, IDENTITY), null);
|
||||
assert.equal(normalizeStopReceipt(raw, {...run, tenantRef: "tnt_ffffffffffffffff"}, IDENTITY), null);
|
||||
});
|
||||
|
||||
test("endpoint adapter is inert, same-origin, versioned, and release-aware", () => {
|
||||
const enabled = new Set([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]);
|
||||
const client = {
|
||||
apiVersion: "hux.v1", identity: IDENTITY,
|
||||
enabled: (flag) => enabled.has(flag), endpoint: (path) => `/hux/v1${path}`,
|
||||
};
|
||||
const contract = createAutonomyEndpointContract(client);
|
||||
assert.equal(contract.policy.path, "/hux/v1/autonomy/policy");
|
||||
assert.equal(contract.updatePolicy.responseSchema, "hux.policy.v1");
|
||||
assert.equal(contract.approvals.path, "/hux/v1/approvals?status=pending&limit=100");
|
||||
assert.match(contract.decideApproval("apr_test1234").path, /\/approvals\/apr_test1234\/decision$/);
|
||||
assert.match(contract.stop("run_test1234").path, /\/runs\/run_test1234\/stop$/);
|
||||
assert.deepEqual(contract.release, {schema: "hux.release.v1", requiredState: "live_verified",
|
||||
featureFlags: [FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]});
|
||||
assert.throws(() => contract.decideApproval("bad"), /Opaque/);
|
||||
assert.throws(() => contract.stop("bad"), /Exact/);
|
||||
for (const flag of enabled) {
|
||||
assert.equal(createAutonomyEndpointContract({...client, enabled: (candidate) =>
|
||||
candidate !== flag && enabled.has(candidate)}), null);
|
||||
}
|
||||
assert.equal(createAutonomyEndpointContract({...client, apiVersion: "hux.v0"}), null);
|
||||
assert.throws(() => createAutonomyEndpointContract({...client,
|
||||
identity: {...IDENTITY, userRef: "raw-email"}}), /identity/);
|
||||
});
|
||||
95
testing/tests/test_hermes_hux_ui_autonomy.py
Normal file
95
testing/tests/test_hermes_hux_ui_autonomy.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Executable and static quality gates for isolated HUX-05 frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
AUTONOMY = ROOT / "dockerfiles" / "hermes-webui-hux" / "autonomy"
|
||||
|
||||
|
||||
def test_hux_autonomy_model_node_suite_and_coverage():
|
||||
"""Fail-closed policy decisions retain at least 95% executable coverage."""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/autonomy/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/autonomy/security.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/autonomy/endpoints.ts",
|
||||
"testing/tests/test_hermes_hux_ui_autonomy.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_autonomy_component_is_accessible_flagged_and_inert():
|
||||
"""Static checks preserve accessible controls and callback-only effects."""
|
||||
|
||||
component = (AUTONOMY / "AutonomyControls.tsx").read_text(encoding="utf-8")
|
||||
model = (AUTONOMY / "model.ts").read_text(encoding="utf-8")
|
||||
endpoints = (AUTONOMY / "endpoints.ts").read_text(encoding="utf-8")
|
||||
styles = (AUTONOMY / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled) return null" in component
|
||||
assert "autonomyControlsEnabled(props.flags)" in component
|
||||
for flag in ("hux.foundation", "hux.activity_timeline", "hux.autonomy"):
|
||||
assert flag in model
|
||||
assert "<fieldset" in component and "<legend" in component
|
||||
assert 'type="radio"' in component and '<table>' in component
|
||||
assert "Stop current run" in component and "Review external side effects" in component
|
||||
assert 'role="status"' in component and 'aria-live="polite"' in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert "fetch(" not in component and "fetch(" not in endpoints
|
||||
assert "hux.v1" in endpoints and "hux.release.v1" in endpoints
|
||||
assert "live_verified" in endpoints and "hux.cancel_receipt.v1" in endpoints
|
||||
assert ".hux-autonomy" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "focus-visible" in styles and "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_autonomy_matches_permission_contract_and_stays_bounded():
|
||||
"""Canonical values match the source schema and stay out of live images."""
|
||||
|
||||
schema = json.loads(
|
||||
(ROOT / "services/hermes/contracts/hux/permission.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
model = (AUTONOMY / "model.ts").read_text(encoding="utf-8")
|
||||
types = (AUTONOMY / "types.ts").read_text(encoding="utf-8")
|
||||
for value in schema["$defs"]["capability"]["enum"]:
|
||||
assert f'"{value}"' in model
|
||||
for value in schema["$defs"]["policy"]["properties"]["autonomy"]["enum"]:
|
||||
assert f'"{value}"' in model
|
||||
for value in schema["$defs"]["decision"]["enum"]:
|
||||
assert f'"{value}"' in model
|
||||
for value in schema["$defs"]["approval"]["properties"]["decision"][
|
||||
"properties"
|
||||
]["choice"]["enum"]:
|
||||
assert f'"{value}"' in types
|
||||
assert {path.name for path in AUTONOMY.iterdir()} == {
|
||||
"AutonomyControls.tsx",
|
||||
"endpoints.ts",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in AUTONOMY.iterdir():
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
dockerfiles = list((ROOT / "dockerfiles").glob("Dockerfile.hermes*"))
|
||||
assert all("hermes-webui-hux/autonomy" not in path.read_text(encoding="utf-8") for path in dockerfiles)
|
||||
68
testing/tests/test_hermes_hux_ui_foundation.py
Normal file
68
testing/tests/test_hermes_hux_ui_foundation.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Execute the dependency-free HUX browser foundation contract tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
NODE_TEST = ROOT / "testing/tests/test_hermes_hux_ui_foundation_node.js"
|
||||
HUX_UI = ROOT / "dockerfiles/hermes-webui-hux"
|
||||
|
||||
|
||||
def test_hux_ui_foundation_node_contracts():
|
||||
"""The browser modules pass their functional and isolation checks."""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "--test", str(NODE_TEST)],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "fail 0" in result.stdout
|
||||
|
||||
|
||||
def test_hux_ui_foundation_is_inert_and_bounded():
|
||||
"""The foundation stays separate from the active image patch surface."""
|
||||
|
||||
production = sorted(path for path in HUX_UI.iterdir() if path.suffix in {".js", ".css"})
|
||||
assert {path.name for path in production} == {
|
||||
"bootstrap.css",
|
||||
"bootstrap.js",
|
||||
"foundation.css",
|
||||
"foundation.js",
|
||||
"shell.js",
|
||||
}
|
||||
for path in production:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) < 500
|
||||
|
||||
dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text(encoding="utf-8")
|
||||
# The WebUI image ships the HUX browser surface, but only from sources that
|
||||
# exist in this tree, and it never bakes activated HUX flags into the image:
|
||||
# the single HUX_FLAGS occurrence is the empty-flag build-time router check.
|
||||
copies = [
|
||||
line.split()[1]
|
||||
for line in dockerfile.splitlines()
|
||||
if line.startswith("COPY dockerfiles/hermes-webui-hux")
|
||||
]
|
||||
assert copies
|
||||
for source in copies:
|
||||
assert (ROOT / source).is_file(), source
|
||||
assert dockerfile.count("HUX_FLAGS") == 1
|
||||
assert '"HUX_FLAGS": ""' in dockerfile
|
||||
|
||||
|
||||
def test_hux_ui_foundation_never_persists_or_renders_raw_payloads():
|
||||
"""Static guardrails forbid browser storage and raw-record display paths."""
|
||||
|
||||
source = (HUX_UI / "foundation.js").read_text(encoding="utf-8")
|
||||
assert "localStorage" not in source
|
||||
assert "sessionStorage" not in source
|
||||
assert "innerHTML" not in source
|
||||
assert "record.detail" not in source
|
||||
assert "item.uri" not in source
|
||||
assert "item.hash" not in source
|
||||
assert "credentials: 'same-origin'" in source
|
||||
276
testing/tests/test_hermes_hux_ui_foundation_node.js
Normal file
276
testing/tests/test_hermes_hux_ui_foundation_node.js
Normal file
@ -0,0 +1,276 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const foundation = require(path.join(ROOT, 'dockerfiles/hermes-webui-hux/foundation.js'));
|
||||
const shellApi = require(path.join(ROOT, 'dockerfiles/hermes-webui-hux/shell.js'));
|
||||
|
||||
const IDENTITY = Object.freeze({
|
||||
tenant_ref: 'tnt_0123456789abcdef',
|
||||
user_ref: 'usr_0123456789abcdef',
|
||||
surface: 'chat',
|
||||
});
|
||||
|
||||
function capability(flags, identity = IDENTITY) {
|
||||
return {schema: 'hux.capabilities.v1', api_version: 'hux.v1', identity, flags};
|
||||
}
|
||||
|
||||
function response(status, body) {
|
||||
return {status, ok: status >= 200 && status < 300, json: async () => body};
|
||||
}
|
||||
|
||||
test('capability negotiation is versioned, dependency-aware, and immutable', () => {
|
||||
const result = foundation.normalizeCapabilities(capability([
|
||||
'hux.foundation', 'hux.activity_timeline', 'hux.autonomy', 'hux.multimodal', 'hux.bogus',
|
||||
]), foundation.normalizeIdentity(IDENTITY));
|
||||
assert.deepEqual(result.flags, ['hux.activity_timeline', 'hux.autonomy', 'hux.foundation']);
|
||||
assert.equal(result.apiVersion, 'hux.v1');
|
||||
assert(Object.isFrozen(result));
|
||||
assert(Object.isFrozen(result.identity));
|
||||
assert.throws(() => foundation.normalizeCapabilities({}, result.identity), /Unsupported/);
|
||||
assert.throws(() => foundation.normalizeCapabilities([], result.identity), /not an object/);
|
||||
assert.throws(() => foundation.normalizeCapabilities(capability('bad'), result.identity), /flags/);
|
||||
assert.throws(() => foundation.normalizeCapabilities(capability([], {...IDENTITY, surface: 'api'}), result.identity), /does not match/);
|
||||
});
|
||||
|
||||
test('browser script path exports only the versioned namespace', () => {
|
||||
const context = {globalThis: {}};
|
||||
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'dockerfiles/hermes-webui-hux/foundation.js'), 'utf8'), context);
|
||||
assert.equal(context.globalThis.HermesHuxFoundation.API_VERSION, 'hux.v1');
|
||||
context.globalThis.HermesHuxFoundation = context.globalThis.HermesHuxFoundation;
|
||||
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'dockerfiles/hermes-webui-hux/shell.js'), 'utf8'), context);
|
||||
assert.equal(typeof context.globalThis.HermesHuxShell.createShell, 'function');
|
||||
});
|
||||
|
||||
test('identity accepts only opaque tenant and user references', () => {
|
||||
assert.deepEqual(foundation.normalizeIdentity(IDENTITY), {
|
||||
tenantRef: IDENTITY.tenant_ref, userRef: IDENTITY.user_ref, surface: 'chat',
|
||||
});
|
||||
assert.throws(() => foundation.normalizeIdentity(null), /Missing/);
|
||||
assert.throws(() => foundation.normalizeIdentity({...IDENTITY, tenant_ref: 'tenant-0'}), /tenant/);
|
||||
assert.throws(() => foundation.normalizeIdentity({...IDENTITY, user_ref: 'brad@example.com'}), /user/);
|
||||
assert.throws(() => foundation.normalizeIdentity({...IDENTITY, surface: 'browser'}), /surface/);
|
||||
});
|
||||
|
||||
test('client stays off for 404 and reports safe states without response bodies', async () => {
|
||||
const calls = [];
|
||||
let result = response(404, {});
|
||||
const client = foundation.createClient({
|
||||
expectedIdentity: IDENTITY,
|
||||
fetcher: async (...args) => { calls.push(args); return result; },
|
||||
});
|
||||
const states = [];
|
||||
const unsubscribe = client.subscribe((state) => states.push(state.phase));
|
||||
assert.equal(client.getState().phase, 'disabled');
|
||||
await client.negotiate();
|
||||
assert.deepEqual(states, ['disabled', 'loading', 'disabled']);
|
||||
assert.equal(calls[0][0], '/hux/v1/capabilities');
|
||||
assert.equal(calls[0][1].credentials, 'same-origin');
|
||||
assert.equal(client.enabled('hux.foundation'), false);
|
||||
|
||||
result = response(200, capability([]));
|
||||
await client.negotiate();
|
||||
assert.equal(client.getState().phase, 'disabled');
|
||||
|
||||
result = response(500, {secret: 'must not escape'});
|
||||
await client.negotiate();
|
||||
assert.equal(client.getState().phase, 'error');
|
||||
assert.equal(client.getState().error.includes('secret'), false);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('client enables verified flags and rejects unsafe endpoint construction', async () => {
|
||||
const client = foundation.createClient({
|
||||
expectedIdentity: IDENTITY,
|
||||
baseUrl: '/hux/v1/',
|
||||
fetcher: async () => response(200, capability(['hux.foundation', 'hux.projects'])),
|
||||
});
|
||||
await client.negotiate();
|
||||
assert.equal(client.getState().phase, 'ready');
|
||||
assert.equal(client.enabled('hux.projects'), true);
|
||||
assert.equal(client.enabled('hux.bogus'), false);
|
||||
assert.equal(client.endpoint('/projects'), '/hux/v1/projects');
|
||||
assert.throws(() => client.endpoint('https://evil.example/x'), /scoped/);
|
||||
assert.throws(() => client.endpoint('//evil.example/x'), /scoped/);
|
||||
assert.throws(() => client.endpoint('/../other-tenant'), /scoped/);
|
||||
assert.throws(() => foundation.createClient({expectedIdentity: IDENTITY, baseUrl: 'https://evil.example'}), /same-origin/);
|
||||
assert.throws(() => foundation.createClient({expectedIdentity: IDENTITY, fetcher: null}), /fetch/);
|
||||
assert.throws(() => client.subscribe('nope'), /Listener/);
|
||||
});
|
||||
|
||||
test('client discards late negotiation results', async () => {
|
||||
const resolvers = [];
|
||||
const client = foundation.createClient({
|
||||
expectedIdentity: IDENTITY,
|
||||
fetcher: () => new Promise((resolve) => resolvers.push(resolve)),
|
||||
});
|
||||
const first = client.negotiate();
|
||||
const second = client.negotiate();
|
||||
resolvers[1](response(200, capability(['hux.foundation'])));
|
||||
await second;
|
||||
resolvers[0](response(404, {}));
|
||||
await first;
|
||||
assert.equal(client.getState().phase, 'ready');
|
||||
});
|
||||
|
||||
test('event adapter exposes summaries and references, never details or URIs', () => {
|
||||
const adapters = foundation.createAdapters({identity: IDENTITY, conversationId: 'conv_0001abcd'});
|
||||
const rawEvent = {
|
||||
schema: 'hux.event.v1', id: 'evt_0001aaaa', seq: 2,
|
||||
ts: '2026-08-24T00:00:00Z', conversation_id: 'conv_0001abcd',
|
||||
kind: 'tool.result', summary: 'Checked service health', detail: {raw: 'private output'},
|
||||
evidence: [{kind: 'tool_result', id: 'call-1', uri: 'https://secret.example', hash: 'sha256:no'}],
|
||||
sensitivity: 'personal', redaction: {level: 'none'},
|
||||
};
|
||||
const event = adapters.adaptEvent(rawEvent);
|
||||
assert.equal(event.summary, 'Checked service health');
|
||||
assert.deepEqual(event.evidence, [{kind: 'tool_result', id: 'call-1'}]);
|
||||
assert.equal('detail' in event, false);
|
||||
assert(Object.isFrozen(event.evidence[0]));
|
||||
|
||||
const hidden = adapters.adaptEvent({...rawEvent, redaction: {level: 'full'}});
|
||||
assert.equal(hidden.summary, 'Details hidden by privacy controls.');
|
||||
assert.equal(hidden.redacted, true);
|
||||
assert.throws(() => adapters.adaptEvent({...rawEvent, conversation_id: 'conv_other'}), /conversation/);
|
||||
assert.throws(() => adapters.adaptEvent({...rawEvent, seq: -1}), /activity/);
|
||||
assert.throws(() => adapters.adaptEvent({...rawEvent, summary: ''}), /summary/);
|
||||
assert.throws(() => adapters.adaptEvent({...rawEvent, ts: 'today'}), /timestamp/);
|
||||
assert.throws(() => adapters.adaptEvent({...rawEvent, kind: 'tool.raw'}), /classification/);
|
||||
assert.throws(() => adapters.adaptEvent({...rawEvent, redaction: {level: 'maybe'}}), /redaction/);
|
||||
const withoutConversation = {...rawEvent};
|
||||
delete withoutConversation.conversation_id;
|
||||
assert.throws(() => adapters.adaptEvent(withoutConversation), /matching conversation/);
|
||||
assert.throws(() => adapters.adaptEvent(null), /not an object/);
|
||||
});
|
||||
|
||||
test('object adapter is display-only and enforces owner and conversation', () => {
|
||||
const adapters = foundation.createAdapters({identity: IDENTITY, conversationId: 'conv_0001abcd'});
|
||||
const view = adapters.adaptObject({
|
||||
schema: 'hux.artifact.v1', id: 'art_0001aaaa', owner: IDENTITY.user_ref,
|
||||
conversation_id: 'conv_0001abcd', project_id: 'prj_0001aaaa', title: 'Plan',
|
||||
type: 'markdown', status: 'ready', sensitivity: 'personal', content: 'never expose me',
|
||||
updated_at: '2026-08-24T00:00:00Z',
|
||||
});
|
||||
assert.deepEqual(view, {
|
||||
schema: 'hux.artifact.v1', id: 'art_0001aaaa', title: 'Plan', kind: 'markdown',
|
||||
status: 'ready', sensitivity: 'personal', conversationId: 'conv_0001abcd',
|
||||
projectId: 'prj_0001aaaa', updatedAt: '2026-08-24T00:00:00Z',
|
||||
});
|
||||
assert.equal('content' in view, false);
|
||||
assert.throws(() => adapters.adaptObject({...view, owner: 'usr_ffffffffffffffff'}), /user boundary/);
|
||||
assert.throws(() => adapters.adaptObject({...view, schema: 'hux.artifact.v2'}), /version/);
|
||||
assert.throws(() => foundation.createAdapters({identity: IDENTITY, conversationId: 'bad'}), /conversation id/);
|
||||
});
|
||||
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.attributes = {};
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.hidden = false;
|
||||
this.parentNode = null;
|
||||
this.listeners = {};
|
||||
this.textContent = '';
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
if (name.startsWith('data-')) {
|
||||
const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
||||
this.dataset[key] = String(value);
|
||||
}
|
||||
}
|
||||
getAttribute(name) { return this.attributes[name]; }
|
||||
appendChild(node) { node.parentNode = this; this.children.push(node); return node; }
|
||||
removeChild(node) { this.children.splice(this.children.indexOf(node), 1); node.parentNode = null; }
|
||||
replaceChildren(...nodes) { this.children.forEach((node) => { node.parentNode = null; }); this.children = []; nodes.forEach((node) => this.appendChild(node)); }
|
||||
addEventListener(name, listener) { this.listeners[name] = listener; }
|
||||
click() { if (this.listeners.click) this.listeners.click({target: this}); }
|
||||
focus() { this.focused = true; }
|
||||
keydown(key) { if (this.listeners.keydown) this.listeners.keydown({key, preventDefault() {}}); }
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
let state = Object.freeze({phase: 'disabled', capabilities: null, error: null});
|
||||
let listener = null;
|
||||
let retries = 0;
|
||||
return {
|
||||
enabled: (flag) => state.phase === 'ready' && state.capabilities.flags.includes(flag),
|
||||
getState: () => state,
|
||||
negotiate: () => { retries += 1; },
|
||||
retries: () => retries,
|
||||
subscribe: (next) => { listener = next; next(state); return () => { listener = null; }; },
|
||||
setState: (next) => { state = Object.freeze(next); if (listener) listener(state); },
|
||||
};
|
||||
}
|
||||
|
||||
function descendants(node) {
|
||||
return node.children.flatMap((child) => [child, ...descendants(child)]);
|
||||
}
|
||||
|
||||
test('accessible shell stays hidden until an extension capability is verified', () => {
|
||||
const client = fakeClient();
|
||||
const host = new FakeNode('main');
|
||||
const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}});
|
||||
shell.register({id: 'projects', flag: 'hux.projects', label: 'Projects', order: 20, render: ({container}) => { container.textContent = 'Projects view'; }});
|
||||
shell.register({id: 'activity', flag: 'hux.activity_timeline', label: 'Activity', order: 10, render: ({container}) => { container.textContent = 'Activity view'; }});
|
||||
const root = shell.mount(host);
|
||||
assert.equal(root.hidden, true);
|
||||
assert.equal(root.getAttribute('data-hux-version'), 'hux.v1');
|
||||
|
||||
client.setState({phase: 'loading', capabilities: null, error: null});
|
||||
assert.equal(root.hidden, false);
|
||||
assert.equal(descendants(root)[0].getAttribute('role'), 'status');
|
||||
client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation', 'hux.activity_timeline', 'hux.projects']}, error: null});
|
||||
const tabs = descendants(root).filter((node) => node.getAttribute('role') === 'tab');
|
||||
const panels = descendants(root).filter((node) => node.getAttribute('role') === 'tabpanel');
|
||||
assert.deepEqual(tabs.map((tab) => tab.textContent), ['Activity', 'Projects']);
|
||||
assert.equal(tabs[0].getAttribute('aria-selected'), 'true');
|
||||
assert.equal(panels[0].hidden, false);
|
||||
tabs[1].click();
|
||||
assert.equal(tabs[1].getAttribute('aria-selected'), 'true');
|
||||
assert.equal(panels[0].hidden, true);
|
||||
assert.equal(panels[1].hidden, false);
|
||||
tabs[1].keydown('ArrowLeft');
|
||||
assert.equal(tabs[0].focused, true);
|
||||
assert.equal(tabs[0].getAttribute('aria-selected'), 'true');
|
||||
|
||||
client.setState({phase: 'error', capabilities: null, error: 'Safe error'});
|
||||
const retry = descendants(root).find((node) => node.textContent === 'Try again');
|
||||
retry.click();
|
||||
assert.equal(client.retries(), 1);
|
||||
shell.destroy();
|
||||
assert.equal(host.children.length, 0);
|
||||
});
|
||||
|
||||
test('shell rejects malformed and duplicate extensions', () => {
|
||||
const client = fakeClient();
|
||||
const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}});
|
||||
assert.throws(() => shell.register({id: 'X', flag: 'hux.projects', label: 'x', render() {}}), /id/);
|
||||
assert.throws(() => shell.register({id: 'base', flag: 'hux.foundation', label: 'x', render() {}}), /feature flag/);
|
||||
assert.throws(() => shell.register({id: 'unknown', flag: 'hux.nope', label: 'x', render() {}}), /feature flag/);
|
||||
assert.throws(() => shell.register({id: 'projects', flag: 'hux.projects', label: '', render() {}}), /label/);
|
||||
const remove = shell.register({id: 'projects', flag: 'hux.projects', label: 'Projects', render() {}});
|
||||
assert.throws(() => shell.register({id: 'projects', flag: 'hux.projects', label: 'Again', render() {}}), /already/);
|
||||
remove();
|
||||
assert.throws(() => shellApi.createShell({}), /client and document/);
|
||||
});
|
||||
|
||||
test('shell hides empty ready state and contains extension renderer failure', () => {
|
||||
const client = fakeClient();
|
||||
const host = new FakeNode('main');
|
||||
const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}});
|
||||
const root = shell.mount(host);
|
||||
client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation']}, error: null});
|
||||
assert.equal(root.hidden, true);
|
||||
shell.register({id: 'broken-panel', flag: 'hux.projects', label: 'Broken', render() { throw new Error('raw private error'); }});
|
||||
client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation', 'hux.projects']}, error: null});
|
||||
const safe = descendants(root).find((node) => node.textContent.includes('temporarily unavailable'));
|
||||
assert(safe);
|
||||
assert.equal(safe.textContent.includes('private error'), false);
|
||||
});
|
||||
334
testing/tests/test_hermes_hux_ui_memory.mjs
Normal file
334
testing/tests/test_hermes_hux_ui_memory.mjs
Normal file
@ -0,0 +1,334 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { createMemoryEndpointContract } from "../../dockerfiles/hermes-webui-hux/memory/endpoints.ts";
|
||||
import {
|
||||
FOUNDATION_FLAG,
|
||||
MEMORY_CONTROL_FLAG,
|
||||
MEMORY_TOPICS,
|
||||
PRIVACY_FLAG,
|
||||
memoryControlEnabled,
|
||||
normalizeMemoryPage,
|
||||
} from "../../dockerfiles/hermes-webui-hux/memory/model.ts";
|
||||
import {
|
||||
MemoryContractError,
|
||||
isControlReference,
|
||||
isOpaqueMemoryId,
|
||||
isUtcTimestamp,
|
||||
normalizeIdentity,
|
||||
safeMemoryText,
|
||||
sameIdentity,
|
||||
} from "../../dockerfiles/hermes-webui-hux/memory/security.ts";
|
||||
|
||||
const IDENTITY = {
|
||||
tenantRef: "tnt_0123456789abcdef",
|
||||
userRef: "usr_0123456789abcdef",
|
||||
surface: "chat",
|
||||
};
|
||||
const FLAGS = [FOUNDATION_FLAG, PRIVACY_FLAG, MEMORY_CONTROL_FLAG];
|
||||
|
||||
function entry(index = 1, extra = {}) {
|
||||
return {
|
||||
schema: "hux.memory.v1",
|
||||
id: `mem_test${String(index).padStart(4, "0")}`,
|
||||
owner: IDENTITY.userRef,
|
||||
scope: { level: "conversation", scope_id: "conv_test1234" },
|
||||
kind: "preference",
|
||||
content: `Concise answer preference ${index}`,
|
||||
status: "active",
|
||||
approval_mode: "ask",
|
||||
sensitivity: "personal",
|
||||
topic: "general",
|
||||
ttl: { policy: "decay", decay_days: 180 },
|
||||
source: { kind: "message", id: `msg-${index}`, uri: "https://secret" },
|
||||
provenance: {
|
||||
surface: "chat",
|
||||
actor: { type: "assistant", id: "hermes", display: "do not copy" },
|
||||
recorded_at: "2026-08-24T10:00:00Z",
|
||||
route: { requested: "thoughtful", resolved_target: "secret-model" },
|
||||
},
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
updated_at: `2026-08-24T10:00:${String(index % 60).padStart(2, "0")}Z`,
|
||||
audit: [
|
||||
{
|
||||
at: "2026-08-24T10:00:00Z",
|
||||
action: "approved",
|
||||
actor: { type: "user", id: "private-subject" },
|
||||
note: "Useful response preference",
|
||||
},
|
||||
],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function page(entries, extra = {}) {
|
||||
return {
|
||||
schema: "hux.memory_page.v1",
|
||||
identity: {
|
||||
tenant_ref: IDENTITY.tenantRef,
|
||||
user_ref: IDENTITY.userRef,
|
||||
surface: IDENTITY.surface,
|
||||
},
|
||||
entries,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("memory control requires foundation, privacy, and memory flags", () => {
|
||||
assert.equal(memoryControlEnabled(), false);
|
||||
assert.equal(memoryControlEnabled([]), false);
|
||||
assert.equal(memoryControlEnabled([MEMORY_CONTROL_FLAG]), false);
|
||||
assert.equal(memoryControlEnabled([FOUNDATION_FLAG, MEMORY_CONTROL_FLAG]), false);
|
||||
assert.equal(memoryControlEnabled([FOUNDATION_FLAG, PRIVACY_FLAG]), false);
|
||||
assert.equal(memoryControlEnabled(FLAGS), true);
|
||||
});
|
||||
|
||||
test("memory topics stay aligned with the existing memory and privacy schemas", () => {
|
||||
const memory = JSON.parse(
|
||||
readFileSync("services/hermes/contracts/hux/memory.schema.json"),
|
||||
);
|
||||
const privacy = JSON.parse(
|
||||
readFileSync("services/hermes/contracts/hux/privacy.schema.json"),
|
||||
);
|
||||
assert.deepEqual(MEMORY_TOPICS, memory.properties.topic.enum);
|
||||
assert.deepEqual(
|
||||
MEMORY_TOPICS.filter((topic) => topic !== "general"),
|
||||
privacy.$defs.topic.enum,
|
||||
);
|
||||
});
|
||||
|
||||
test("display text scrubs credentials, controls, and object payloads", () => {
|
||||
assert.equal(safeMemoryText({}, "fallback"), "fallback");
|
||||
assert.equal(safeMemoryText(" \n ", "fallback"), "fallback");
|
||||
assert.equal(safeMemoryText("abc\u0000def", "fallback"), "abc def");
|
||||
assert.equal(safeMemoryText("abcdef", "fallback", 4), "abc…");
|
||||
assert.equal(safeMemoryText("abcdef", "fallback", 0), "…");
|
||||
const scrubbed = safeMemoryText(
|
||||
[
|
||||
"password=hunter2",
|
||||
"Bearer abc.DEF_123",
|
||||
"eyJabcdefgh.abcdefghijkl.abcdefghijkl",
|
||||
"https://user:pass@example.test",
|
||||
"Abcdefghijklmnopqrstuvwxyz1234567890ABCD",
|
||||
"-----BEGIN PRIVATE KEY----- nope -----END PRIVATE KEY-----",
|
||||
].join(" "),
|
||||
"fallback",
|
||||
1000,
|
||||
);
|
||||
assert.doesNotMatch(scrubbed, /hunter2|abc\.DEF|user:pass|1234567890ABCD|nope/);
|
||||
assert.match(scrubbed, /redacted/);
|
||||
});
|
||||
|
||||
test("scope primitives reject malformed identities, ids, and timestamps", () => {
|
||||
assert.equal(isOpaqueMemoryId("mem_abcd"), true);
|
||||
assert.equal(isOpaqueMemoryId("evt_abcd"), false);
|
||||
assert.equal(isOpaqueMemoryId(4), false);
|
||||
assert.equal(isControlReference("msg-42"), true);
|
||||
assert.equal(isControlReference("conv_test1234", "conv"), true);
|
||||
assert.equal(isControlReference("msg-42", "conv"), false);
|
||||
assert.equal(isControlReference("../message"), false);
|
||||
assert.equal(isControlReference(4), false);
|
||||
assert.equal(isUtcTimestamp("2026-08-24T10:00:00.123Z"), true);
|
||||
assert.equal(isUtcTimestamp("2026-90-24T10:00:00Z"), false);
|
||||
assert.equal(isUtcTimestamp(4), false);
|
||||
assert.deepEqual(normalizeIdentity(IDENTITY), IDENTITY);
|
||||
assert.equal(sameIdentity(IDENTITY, { ...IDENTITY }), true);
|
||||
assert.equal(sameIdentity(IDENTITY, { ...IDENTITY, surface: "worker" }), false);
|
||||
for (const bad of [null, [], {}, { ...IDENTITY, tenantRef: "tenant" }, { ...IDENTITY, userRef: "user" }, { ...IDENTITY, surface: "web" }]) {
|
||||
assert.throws(() => normalizeIdentity(bad), MemoryContractError);
|
||||
}
|
||||
});
|
||||
|
||||
test("a valid ledger exposes useful labels but no raw provenance or source ids", () => {
|
||||
const result = normalizeMemoryPage(
|
||||
page([
|
||||
entry(1),
|
||||
entry(2, {
|
||||
scope: { level: "global" },
|
||||
ttl: { policy: "never" },
|
||||
kind: "fact",
|
||||
topic: "finance",
|
||||
source: { kind: "file", id: "/private/path" },
|
||||
provenance: {
|
||||
surface: "chat",
|
||||
actor: { type: "user", id: "private-user" },
|
||||
recorded_at: "2026-08-24T10:00:00Z",
|
||||
},
|
||||
audit: [{ action: "approved", actor: { type: "user", id: "user" } }],
|
||||
}),
|
||||
entry(3, {
|
||||
scope: { level: "project", scope_id: "prj_test1234" },
|
||||
ttl: { policy: "expires_at", expires_at: "2026-09-24T10:00:00Z" },
|
||||
kind: "instruction",
|
||||
status: "proposed",
|
||||
source: { kind: "approval", id: "apr-secret" },
|
||||
audit: [{ note: "token=do-not-display" }],
|
||||
}),
|
||||
], { next_cursor: "cursor.2" }),
|
||||
IDENTITY,
|
||||
);
|
||||
assert.equal(result.invalid, 0);
|
||||
assert.equal(result.nextCursor, "cursor.2");
|
||||
assert.deepEqual(result.entries.map((item) => item.id), ["mem_test0003", "mem_test0002", "mem_test0001"]);
|
||||
assert.equal(result.entries[0].scopeLabel, "This project");
|
||||
assert.equal(result.entries[0].ttlLabel, "Expires 2026-09-24T10:00:00Z");
|
||||
assert.equal(result.entries[0].reason, "token=[redacted]");
|
||||
assert.equal(result.entries[1].scopeLabel, "All conversations");
|
||||
assert.equal(result.entries[1].ttlLabel, "Kept until you forget it");
|
||||
assert.equal(result.entries[1].reason, "Saved for future help");
|
||||
assert.equal(result.entries[1].sourceLabel, "Workspace file");
|
||||
assert.equal(result.entries[1].sourceIsMessage, false);
|
||||
assert.equal(result.entries[2].sourceIsMessage, true);
|
||||
assert.equal(result.entries[1].provenanceLabel, "user via chat");
|
||||
const serialized = JSON.stringify(result.entries);
|
||||
assert.doesNotMatch(serialized, /private\/path|private-user|private-subject|secret-model|apr-secret|do-not-display/);
|
||||
});
|
||||
|
||||
test("forgotten and sensitive values fail closed without leaking reasons", () => {
|
||||
const forbidden = "kidney stone and account secret";
|
||||
const result = normalizeMemoryPage(
|
||||
page([
|
||||
entry(1, { status: "forgotten", content: forbidden, audit: [{ note: forbidden }] }),
|
||||
entry(2, { sensitivity: "sensitive", topic: "health", content: forbidden, audit: [{ note: forbidden }] }),
|
||||
entry(3, { sensitivity: "restricted", topic: "credentials", content: forbidden }),
|
||||
entry(4, { sensitivity: "public", content: "visible" }),
|
||||
]),
|
||||
IDENTITY,
|
||||
);
|
||||
const [publicItem, restricted, sensitive, forgotten] = result.entries;
|
||||
assert.equal(publicItem.content, "visible");
|
||||
assert.equal(restricted.content, null);
|
||||
assert.equal(sensitive.content, null);
|
||||
assert.equal(forgotten.content, null);
|
||||
assert.equal(forgotten.contentNotice, "Content permanently removed");
|
||||
assert.match(sensitive.contentNotice, /privacy controls/);
|
||||
assert.doesNotMatch(JSON.stringify(result), new RegExp(forbidden));
|
||||
assert.equal(sensitive.reason, "Reason hidden by privacy controls");
|
||||
});
|
||||
|
||||
test("an omitted optional topic defaults to general without changing approval", () => {
|
||||
const item = normalizeMemoryPage(
|
||||
page([
|
||||
entry(8, {
|
||||
topic: undefined,
|
||||
status: "proposed",
|
||||
audit: [{ action: "proposed", actor: { type: "assistant", id: "hermes" } }],
|
||||
}),
|
||||
]),
|
||||
IDENTITY,
|
||||
).entries[0];
|
||||
assert.equal(item.topic, "general");
|
||||
assert.equal(item.approvalMode, "ask");
|
||||
assert.equal(item.reason, "Hermes suggested this for your approval");
|
||||
});
|
||||
|
||||
test("malformed or cross-owner entries are withheld independently", () => {
|
||||
const invalid = [
|
||||
entry(1, { schema: "hux.memory.v0" }),
|
||||
entry(2, { id: "evt_bad" }),
|
||||
entry(3, { owner: "usr_ffffffffffffffff" }),
|
||||
entry(4, { kind: "password" }),
|
||||
entry(5, { status: "deleted" }),
|
||||
entry(6, { sensitivity: "secret" }),
|
||||
entry(7, { topic: "politics" }),
|
||||
entry(8, { approval_mode: "yes" }),
|
||||
entry(9, { created_at: "today" }),
|
||||
entry(10, { updated_at: "today" }),
|
||||
entry(11, { provenance: null }),
|
||||
entry(12, { provenance: { surface: "worker", actor: { type: "assistant" }, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||||
entry(13, { provenance: { surface: "chat", actor: { type: "assistant" }, recorded_at: "today" } }),
|
||||
entry(14, { provenance: { surface: "chat", actor: null, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||||
entry(15, { provenance: { surface: "chat", actor: { type: "intruder" }, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||||
entry(16, { source: null }),
|
||||
entry(17, { source: { kind: "raw_tool_log", id: "secret" } }),
|
||||
entry(18, { source: { kind: "message", id: "" } }),
|
||||
entry(19, { source: { kind: "message", id: "x".repeat(201) } }),
|
||||
entry(20, { ttl: { policy: "expires_at", expires_at: "today" } }),
|
||||
entry(21, { ttl: { policy: "decay", decay_days: 0 } }),
|
||||
entry(22, { ttl: { policy: "decay", decay_days: 3651 } }),
|
||||
entry(23, { ttl: {} }),
|
||||
entry(24, { scope: null }),
|
||||
entry(25, { scope: { level: "global", scope_id: "should-not-exist" } }),
|
||||
entry(26, { scope: { level: "project" } }),
|
||||
entry(27, { scope: { level: "conversation" } }),
|
||||
entry(28, { content: {} }),
|
||||
entry(29, { content: "x".repeat(2001) }),
|
||||
entry(30, { audit: [] }),
|
||||
entry(31, { audit: null }),
|
||||
entry(32, { provenance: { surface: "chat", actor: { type: "assistant", id: "" }, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||||
entry(33, { provenance: { surface: "chat", actor: { type: "assistant", id: "x".repeat(121) }, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||||
];
|
||||
const result = normalizeMemoryPage(page([entry(99), ...invalid]), IDENTITY);
|
||||
assert.equal(result.entries.length, 1);
|
||||
assert.equal(result.invalid, invalid.length);
|
||||
assert.equal(result.nextCursor, null);
|
||||
});
|
||||
|
||||
test("response identity mismatches fail the whole page closed", () => {
|
||||
assert.throws(() => normalizeMemoryPage(null, IDENTITY), MemoryContractError);
|
||||
assert.throws(() => normalizeMemoryPage({ schema: "old" }, IDENTITY), MemoryContractError);
|
||||
assert.throws(
|
||||
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, tenant_ref: "tnt_ffffffffffffffff" } }), IDENTITY),
|
||||
/scope boundary/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, user_ref: "usr_ffffffffffffffff" } }), IDENTITY),
|
||||
/scope boundary/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, surface: "worker" } }), IDENTITY),
|
||||
/scope boundary/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeMemoryPage(page(null), IDENTITY),
|
||||
/entries are missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("page bounds retain newest valid ledger entries", () => {
|
||||
const many = Array.from({ length: 205 }, (_, index) => entry(index + 1));
|
||||
const result = normalizeMemoryPage(page(many, { next_cursor: 7 }), IDENTITY);
|
||||
assert.equal(result.entries.length, 200);
|
||||
assert.equal(result.truncated, 5);
|
||||
assert.equal(result.invalid, 0);
|
||||
assert.equal(result.nextCursor, null);
|
||||
assert.ok(result.entries[0].updatedAt >= result.entries.at(-1).updatedAt);
|
||||
});
|
||||
|
||||
test("endpoint contract is inert by default and describes same-origin calls only", () => {
|
||||
const client = {
|
||||
apiVersion: "hux.v1",
|
||||
identity: IDENTITY,
|
||||
endpoint(path) {
|
||||
assert.match(path, /^\/[a-z0-9/_-]+$/);
|
||||
return `/hux/v1${path}`;
|
||||
},
|
||||
};
|
||||
assert.equal(createMemoryEndpointContract(client), null);
|
||||
assert.equal(createMemoryEndpointContract(client, [MEMORY_CONTROL_FLAG]), null);
|
||||
const contract = createMemoryEndpointContract(client, FLAGS);
|
||||
assert.deepEqual(contract.list, {
|
||||
method: "GET",
|
||||
path: "/hux/v1/memories",
|
||||
responseSchema: "hux.memory_page.v1",
|
||||
});
|
||||
assert.deepEqual(contract.identity, IDENTITY);
|
||||
assert.throws(() => { contract.identity.surface = "worker"; }, TypeError);
|
||||
assert.equal(contract.suggestOnly.method, "PUT");
|
||||
assert.equal(contract.doNotRemember.path, "/hux/v1/memory-blocks");
|
||||
assert.equal(contract.action("mem_abcd", "approve").path, "/hux/v1/memories/mem_abcd/approve");
|
||||
assert.equal(contract.action("mem_abcd", "reject").method, "POST");
|
||||
assert.equal(contract.action("mem_abcd", "forget").responseSchema, "hux.memory.v1");
|
||||
assert.equal(contract.revise("mem_abcd").path, "/hux/v1/memories/mem_abcd/revisions");
|
||||
assert.throws(() => contract.action("bad", "approve"), /Opaque/);
|
||||
assert.throws(() => contract.action("mem_abcd", "delete"), /Unsupported/);
|
||||
assert.throws(
|
||||
() => createMemoryEndpointContract({ ...client, apiVersion: "hux.v2" }, FLAGS),
|
||||
/HUX v1/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createMemoryEndpointContract({ ...client, identity: { ...IDENTITY, userRef: "bad" } }, FLAGS),
|
||||
MemoryContractError,
|
||||
);
|
||||
});
|
||||
76
testing/tests/test_hermes_hux_ui_memory.py
Normal file
76
testing/tests/test_hermes_hux_ui_memory.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Executable and static quality gates for isolated HUX-02 memory controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MEMORY = ROOT / "dockerfiles" / "hermes-webui-hux" / "memory"
|
||||
|
||||
|
||||
def test_hux_memory_model_node_suite_and_coverage():
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-branches=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/memory/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/memory/security.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/memory/endpoints.ts",
|
||||
"testing/tests/test_hermes_hux_ui_memory.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_memory_component_is_flagged_accessible_and_privacy_safe():
|
||||
component = (MEMORY / "MemoryControlCenter.tsx").read_text(encoding="utf-8")
|
||||
model = (MEMORY / "model.ts").read_text(encoding="utf-8")
|
||||
endpoints = (MEMORY / "endpoints.ts").read_text(encoding="utf-8")
|
||||
styles = (MEMORY / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled) return null" in component
|
||||
assert "workspace scope could not be verified" in component
|
||||
assert "memoryControlEnabled(props.flags)" in component
|
||||
assert all(name in model for name in ("FOUNDATION_FLAG", "PRIVACY_FLAG", "MEMORY_CONTROL_FLAG"))
|
||||
assert 'aria-label="Memory ledger"' in component
|
||||
assert 'role="status"' in component
|
||||
assert "Suggest only" in component
|
||||
assert "Edit as new entry" in component and "Delete & forget" in component
|
||||
assert "View source message" in component
|
||||
assert "This message" in component and "Block topic" in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert "JSON.stringify" not in component
|
||||
assert "source.id" not in component and "actor.id" not in component
|
||||
assert "fetch(" not in endpoints and "axios" not in endpoints
|
||||
assert ".hux-memory" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_memory_sources_respect_size_and_isolated_scope():
|
||||
sources = sorted(MEMORY.glob("*"))
|
||||
assert {path.name for path in sources} == {
|
||||
"MemoryControlCenter.tsx",
|
||||
"endpoints.ts",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in sources:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
assert "dockerfiles/hermes-webui-hux/memory" not in (
|
||||
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
|
||||
).read_text(encoding="utf-8")
|
||||
305
testing/tests/test_hermes_hux_ui_modes.mjs
Normal file
305
testing/tests/test_hermes_hux_ui_modes.mjs
Normal file
@ -0,0 +1,305 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
FOUNDATION_FLAG,
|
||||
FRIENDLY_MODES_FLAG,
|
||||
MODE_ORDER,
|
||||
buildModeRequest,
|
||||
createFriendlyModesAdapter,
|
||||
friendlyModesEnabled,
|
||||
normalizeAdvancedRoutes,
|
||||
normalizeMode,
|
||||
normalizeModeCatalog,
|
||||
normalizeResolvedRoute,
|
||||
} from "../../dockerfiles/hermes-webui-hux/modes/model.ts";
|
||||
|
||||
const constraints = (providers, localOnly = false) => ({
|
||||
providers,
|
||||
local_only: localOnly,
|
||||
effort: { min: "low", max: "high" },
|
||||
tools: {
|
||||
web: "allowed",
|
||||
shell: "allowed",
|
||||
artifacts: "allowed",
|
||||
delegate: "allowed",
|
||||
},
|
||||
memory: { read: true, write: true },
|
||||
citations_required: false,
|
||||
retention: "default",
|
||||
});
|
||||
|
||||
const labels = {
|
||||
fast: "Fast",
|
||||
thoughtful: "Thoughtful",
|
||||
research: "Research",
|
||||
create: "Create",
|
||||
private: "Private",
|
||||
};
|
||||
|
||||
function mode(name, extra = {}) {
|
||||
const isPrivate = name === "private";
|
||||
return {
|
||||
schema: "hux.mode.v1",
|
||||
mode: name,
|
||||
label: labels[name],
|
||||
intent: `${labels[name]} response intent`,
|
||||
constraints: constraints(
|
||||
isPrivate ? ["local"] : ["claude", "codex"],
|
||||
isPrivate,
|
||||
),
|
||||
switchyard: { route_id: `atlas/auto/${name}` },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const catalog = () => MODE_ORDER.map((name) => mode(name));
|
||||
|
||||
test("friendly modes remain default-off and depend on HUX foundation", () => {
|
||||
assert.equal(friendlyModesEnabled(), false);
|
||||
assert.equal(friendlyModesEnabled([]), false);
|
||||
assert.equal(friendlyModesEnabled([FOUNDATION_FLAG]), false);
|
||||
assert.equal(friendlyModesEnabled([FRIENDLY_MODES_FLAG]), false);
|
||||
assert.equal(
|
||||
friendlyModesEnabled([FOUNDATION_FLAG, FRIENDLY_MODES_FLAG]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("valid catalog is canonical and automatic modes preserve both pools", () => {
|
||||
const normalized = normalizeModeCatalog(catalog());
|
||||
assert.ok(normalized);
|
||||
assert.deepEqual(normalized.map((item) => item.mode), MODE_ORDER);
|
||||
for (const intent of normalized.slice(0, -1)) {
|
||||
assert.deepEqual(intent.constraints.providers, ["codex", "claude"]);
|
||||
assert.equal(intent.constraints.local_only, false);
|
||||
assert.match(intent.switchyard.route_id, /^atlas\/auto\//);
|
||||
assert.equal("override_route_id" in intent.switchyard, false);
|
||||
}
|
||||
assert.deepEqual(normalized.at(-1).constraints.providers, ["local"]);
|
||||
assert.equal(normalized.at(-1).constraints.local_only, true);
|
||||
});
|
||||
|
||||
test("catalog rejects missing, duplicate, and malformed entries", () => {
|
||||
assert.equal(normalizeModeCatalog({}), null);
|
||||
assert.equal(normalizeModeCatalog(catalog().slice(1)), null);
|
||||
assert.equal(normalizeModeCatalog([...catalog().slice(0, 4), mode("fast")]), null);
|
||||
assert.equal(normalizeModeCatalog([...catalog().slice(0, 4), null]), null);
|
||||
});
|
||||
|
||||
test("mode validation is exact and fail-closed", () => {
|
||||
assert.equal(normalizeMode(null), null);
|
||||
assert.equal(normalizeMode({ ...mode("fast"), surprise: true }), null);
|
||||
assert.equal(normalizeMode({ ...mode("fast"), schema: "hux.mode.v0" }), null);
|
||||
assert.equal(normalizeMode({ ...mode("fast"), mode: "turbo" }), null);
|
||||
assert.equal(normalizeMode({ ...mode("fast"), label: "Claude Fast" }), null);
|
||||
assert.equal(normalizeMode({ ...mode("fast"), intent: "" }), null);
|
||||
assert.equal(normalizeMode({ ...mode("fast"), intent: "x".repeat(401) }), null);
|
||||
assert.equal(
|
||||
normalizeMode({ ...mode("fast"), switchyard: { route_id: "vendor/default" } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeMode({
|
||||
...mode("fast"),
|
||||
switchyard: { route_id: "atlas/auto/claude/fast" },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeMode({ ...mode("fast"), intent: "Prefer OpenAI for this mode" }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeMode({
|
||||
...mode("fast"),
|
||||
switchyard: {
|
||||
route_id: "atlas/auto/fast",
|
||||
override_route_id: "atlas/manual/codex/high",
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("constraint validation rejects provider bias and malformed nested policy", () => {
|
||||
const invalid = [
|
||||
constraints([], false),
|
||||
constraints(["codex"], false),
|
||||
constraints(["claude"], false),
|
||||
constraints(["codex", "codex"], false),
|
||||
constraints(["codex", "unknown"], false),
|
||||
{ ...constraints(["codex", "claude"]), local_only: true },
|
||||
{ ...constraints(["codex", "claude"]), effort: { min: "high", max: "low" } },
|
||||
{ ...constraints(["codex", "claude"]), tools: null },
|
||||
{
|
||||
...constraints(["codex", "claude"]),
|
||||
tools: { ...constraints([]).tools, web: "sometimes" },
|
||||
},
|
||||
{ ...constraints(["codex", "claude"]), memory: { read: true } },
|
||||
{ ...constraints(["codex", "claude"]), citations_required: "no" },
|
||||
{ ...constraints(["codex", "claude"]), retention: "forever" },
|
||||
{ ...constraints(["codex", "claude"]), secret: "value" },
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.equal(normalizeMode({ ...mode("fast"), constraints: value }), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("Private is strictly local-only while non-private can include local", () => {
|
||||
assert.equal(
|
||||
normalizeMode({ ...mode("private"), constraints: constraints(["codex"], true) }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeMode({ ...mode("private"), constraints: constraints(["local"], false) }),
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
normalizeMode({
|
||||
...mode("research"),
|
||||
constraints: constraints(["local", "claude", "codex"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("advanced catalog keeps only bounded exact manual route choices", () => {
|
||||
const raw = [
|
||||
{
|
||||
route_id: "atlas/manual/codex/high",
|
||||
label: "Deep route",
|
||||
provider: "codex",
|
||||
effort: "high",
|
||||
local_only: false,
|
||||
},
|
||||
{
|
||||
route_id: "atlas/manual/local/private",
|
||||
label: "Local route",
|
||||
provider: "local",
|
||||
effort: "medium",
|
||||
local_only: true,
|
||||
},
|
||||
{
|
||||
route_id: "atlas/auto/claude/high",
|
||||
label: "Not a pin",
|
||||
provider: "claude",
|
||||
effort: "high",
|
||||
local_only: false,
|
||||
},
|
||||
{
|
||||
route_id: "atlas/manual/codex/high",
|
||||
label: "Duplicate",
|
||||
provider: "codex",
|
||||
effort: "high",
|
||||
local_only: false,
|
||||
},
|
||||
null,
|
||||
];
|
||||
assert.deepEqual(normalizeAdvancedRoutes({}), []);
|
||||
assert.deepEqual(
|
||||
normalizeAdvancedRoutes(raw).map((choice) => choice.routeId),
|
||||
["atlas/manual/codex/high", "atlas/manual/local/private"],
|
||||
);
|
||||
});
|
||||
|
||||
test("exact pin is created only from an explicit compatible advanced choice", () => {
|
||||
const automatic = normalizeMode(mode("fast"));
|
||||
const privateMode = normalizeMode(mode("private"));
|
||||
const choices = normalizeAdvancedRoutes([
|
||||
{
|
||||
route_id: "atlas/manual/codex/high",
|
||||
label: "OpenAI high",
|
||||
provider: "codex",
|
||||
effort: "high",
|
||||
local_only: false,
|
||||
},
|
||||
{
|
||||
route_id: "atlas/manual/local/private",
|
||||
label: "Local",
|
||||
provider: "local",
|
||||
effort: "medium",
|
||||
local_only: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(buildModeRequest(automatic), automatic);
|
||||
assert.equal(buildModeRequest(null), null);
|
||||
assert.equal(buildModeRequest(automatic, "atlas/manual/unknown", choices), null);
|
||||
assert.equal(
|
||||
buildModeRequest(automatic, "atlas/manual/local/private", choices),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
buildModeRequest(privateMode, "atlas/manual/codex/high", choices),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
buildModeRequest(privateMode, "atlas/manual/local/private", choices)
|
||||
.switchyard.override_route_id,
|
||||
"atlas/manual/local/private",
|
||||
);
|
||||
assert.equal(
|
||||
buildModeRequest(automatic, "atlas/manual/codex/high", choices).switchyard
|
||||
.override_route_id,
|
||||
"atlas/manual/codex/high",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolved route disclosure validates the shared hux.v1 route shape", () => {
|
||||
assert.deepEqual(
|
||||
normalizeResolvedRoute({
|
||||
requested: "fast",
|
||||
resolved_target: "route/codex/luna/low",
|
||||
provider: "codex",
|
||||
effort: "low",
|
||||
}),
|
||||
{
|
||||
requested: "fast",
|
||||
target: "route/codex/luna/low",
|
||||
provider: "codex",
|
||||
effort: "low",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(normalizeResolvedRoute({ requested: "private" }), {
|
||||
requested: "private",
|
||||
target: undefined,
|
||||
provider: undefined,
|
||||
effort: undefined,
|
||||
});
|
||||
for (const invalid of [
|
||||
null,
|
||||
{},
|
||||
{ requested: "" },
|
||||
{ requested: "fast", provider: "gemini" },
|
||||
{ requested: "fast", effort: "ultra" },
|
||||
{ requested: "fast", raw: "secret" },
|
||||
{ requested: "fast", resolved_target: 7 },
|
||||
]) {
|
||||
assert.equal(normalizeResolvedRoute(invalid), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("foundation adapter preserves version, flags, endpoint, and validators", () => {
|
||||
const calls = [];
|
||||
const foundation = {
|
||||
apiVersion: "hux.v1",
|
||||
enabled: (flag) => flag === FOUNDATION_FLAG || flag === FRIENDLY_MODES_FLAG,
|
||||
endpoint: (path) => (calls.push(path), `/hux/v1${path}`),
|
||||
};
|
||||
const adapter = createFriendlyModesAdapter(foundation);
|
||||
assert.equal(adapter.apiVersion, "hux.v1");
|
||||
assert.equal(adapter.enabled(), true);
|
||||
assert.equal(adapter.endpoint(), "/hux/v1/modes");
|
||||
assert.deepEqual(calls, ["/modes"]);
|
||||
assert.ok(adapter.adaptCatalog(catalog()));
|
||||
assert.equal(adapter.adaptResolvedRoute({}), null);
|
||||
assert.ok(adapter.buildRequest(normalizeMode(mode("fast"))));
|
||||
assert.throws(() => createFriendlyModesAdapter(null), TypeError);
|
||||
assert.throws(
|
||||
() => createFriendlyModesAdapter({ ...foundation, apiVersion: "hux.v0" }),
|
||||
TypeError,
|
||||
);
|
||||
assert.equal(
|
||||
createFriendlyModesAdapter({ ...foundation, enabled: () => false }).enabled(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
83
testing/tests/test_hermes_hux_ui_modes.py
Normal file
83
testing/tests/test_hermes_hux_ui_modes.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""Executable and static quality gates for the isolated HUX-06 frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODES = ROOT / "dockerfiles" / "hermes-webui-hux" / "modes"
|
||||
|
||||
|
||||
def test_hux_modes_model_node_suite_and_coverage():
|
||||
"""Mode intent validation is covered above the repository threshold."""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/modes/model.ts",
|
||||
"testing/tests/test_hermes_hux_ui_modes.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_modes_component_is_flagged_accessible_and_provider_neutral():
|
||||
"""Static UI guardrails preserve keyboard access and neutral routing."""
|
||||
|
||||
component = (MODES / "FriendlyModeSelector.tsx").read_text(encoding="utf-8")
|
||||
model = (MODES / "model.ts").read_text(encoding="utf-8")
|
||||
styles = (MODES / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled) return null" in component
|
||||
assert "friendlyModesEnabled(flags)" in component
|
||||
assert "<fieldset" in component and "<legend" in component
|
||||
assert 'type="radio"' in component and "aria-describedby" in component
|
||||
assert "<details" in component and "<summary" in component
|
||||
assert "<select" in component and 'aria-live="polite"' in component
|
||||
assert "Automatic — use all allowed pools" in component
|
||||
assert "codex" in model and "claude" in model and "local" in model
|
||||
assert "preferred_provider" not in model
|
||||
assert "default_provider" not in model
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert ".hux-modes" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "focus-visible" in styles and "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_modes_matches_contract_and_stays_inert_and_bounded():
|
||||
"""HUX-06 uses the declared schema without entering the live build."""
|
||||
|
||||
schema = json.loads(
|
||||
(ROOT / "services/hermes/contracts/hux/mode.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
model = (MODES / "model.ts").read_text(encoding="utf-8")
|
||||
assert schema["properties"]["schema"]["const"] == "hux.mode.v1"
|
||||
for mode in schema["properties"]["mode"]["enum"]:
|
||||
assert f'"{mode}"' in model
|
||||
assert "hux.mode.v1" in model and "hux.v1" in model
|
||||
assert {path.name for path in MODES.iterdir()} == {
|
||||
"FriendlyModeSelector.tsx",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in MODES.iterdir():
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
assert "hermes-webui-hux/modes" not in (
|
||||
ROOT / "dockerfiles/Dockerfile.hermes-webui"
|
||||
).read_text(encoding="utf-8")
|
||||
269
testing/tests/test_hermes_hux_ui_multimodal.mjs
Normal file
269
testing/tests/test_hermes_hux_ui_multimodal.mjs
Normal file
@ -0,0 +1,269 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {createMultimodalEndpointContract} from "../../dockerfiles/hermes-webui-hux/multimodal/endpoints.ts";
|
||||
import {
|
||||
ARTIFACTS_FLAG, AUTONOMY_FLAG, FOUNDATION_FLAG, MULTIMODAL_FLAG, PROJECTS_FLAG,
|
||||
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
|
||||
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
|
||||
} from "../../dockerfiles/hermes-webui-hux/multimodal/model.ts";
|
||||
import {
|
||||
classifyMime, isOpaqueId, isUtc, normalizeIdentity, normalizeScope, safeBlobUrl,
|
||||
safeEndpointPart, safePreviewText, safeText, sameIdentity, sameScope, validateUpload,
|
||||
} from "../../dockerfiles/hermes-webui-hux/multimodal/security.ts";
|
||||
|
||||
const IDENTITY = {tenantRef: "tnt_0123456789abcdef", userRef: "usr_0123456789abcdef", surface: "chat"};
|
||||
const RAW_IDENTITY = {tenant_ref: IDENTITY.tenantRef, user_ref: IDENTITY.userRef, surface: "chat"};
|
||||
const SCOPE = {projectId: "prj_test1234", conversationId: "conv_test1234"};
|
||||
const RAW_SCOPE = {project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId};
|
||||
const FLAGS = [FOUNDATION_FLAG, PROJECTS_FLAG, ARTIFACTS_FLAG, AUTONOMY_FLAG, MULTIMODAL_FLAG];
|
||||
|
||||
function media(extra = {}) {
|
||||
return {schema: "hux.media.v1", id: "med_image1234", owner: IDENTITY.userRef,
|
||||
project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId,
|
||||
artifact_id: "art_image1234", artifact_version: 1, kind: "image", file_name: "garden.png",
|
||||
mime: "image/png", bytes: 1200, preview_url: "blob:https://chat.bstein.dev/123",
|
||||
preview_text: null, preview_authorized: true, alt_text: "A garden", annotations: [], lineage: null, ...extra};
|
||||
}
|
||||
|
||||
function document(extra = {}) {
|
||||
return media({id: "med_document1", artifact_id: "art_document1", kind: "document",
|
||||
file_name: "notes.pdf", mime: "application/pdf", preview_url: null,
|
||||
preview_text: "Safe document\npreview", preview_authorized: true,
|
||||
annotations: [{id: "ann_note1234", page: 2, note: "Check this paragraph"}], ...extra});
|
||||
}
|
||||
|
||||
function variant(extra = {}) {
|
||||
return {id: "var_first1234", media_id: "med_image1234", artifact_id: "art_image1234",
|
||||
artifact_version: 1, parent_variant_id: null, created_at: "2026-08-24T10:00:00Z", ...extra};
|
||||
}
|
||||
|
||||
function thread(extra = {}) {
|
||||
return {id: "thr_garden123", owner: IDENTITY.userRef, project_id: SCOPE.projectId,
|
||||
conversation_id: SCOPE.conversationId, prompt: "A calm garden", variants: [variant()], ...extra};
|
||||
}
|
||||
|
||||
function transcript(extra = {}) {
|
||||
return {schema: "hux.voice_transcript.v1", id: "trn_voice1234", turn_id: "turn_voice123",
|
||||
owner: IDENTITY.userRef, project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId,
|
||||
revision: 2, language: "en", text: "A clean transcript", finalized: true, ...extra};
|
||||
}
|
||||
|
||||
function page(extra = {}) {
|
||||
return {schema: "hux.multimodal_page.v1", api_version: "hux.v1", identity: RAW_IDENTITY,
|
||||
binding: RAW_SCOPE, media: [media(), document()], threads: [thread()],
|
||||
transcript: transcript(), upload_authorization: "authorized", ...extra};
|
||||
}
|
||||
|
||||
function policy(decision = "allow", autonomy = "autonomous") {
|
||||
return {id: "pol_policy123", owner: IDENTITY.userRef, scope: {level: "conversation",
|
||||
scopeId: SCOPE.conversationId}, autonomy, grants: {network: decision}, budgets: {
|
||||
tokensPerRun: 1, toolCallsPerRun: 1, wallClockSeconds: 1, delegationsPerRun: 0},
|
||||
updatedAt: "2026-08-24T10:00:00Z"};
|
||||
}
|
||||
|
||||
test("HUX-07 is inert unless every dependency is active", () => {
|
||||
assert.equal(multimodalEnabled(), false);
|
||||
assert.equal(multimodalEnabled([]), false);
|
||||
for (const flag of FLAGS) assert.equal(multimodalEnabled(FLAGS.filter((item) => item !== flag)), false);
|
||||
assert.equal(multimodalEnabled(FLAGS), true);
|
||||
});
|
||||
|
||||
test("security primitives validate identity, binding, ids, time, URLs, and text", () => {
|
||||
assert.equal(isOpaqueId("med_image1234", "med"), true);
|
||||
assert.equal(isOpaqueId("art_image1234", "med"), false);
|
||||
assert.equal(isOpaqueId(4), false);
|
||||
assert.equal(isUtc("2026-08-24T10:00:00.12Z"), true);
|
||||
assert.equal(isUtc("today"), false);
|
||||
assert.equal(isUtc(4), false);
|
||||
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
|
||||
assert.deepEqual(normalizeIdentity(IDENTITY), IDENTITY);
|
||||
assert.equal(normalizeIdentity(null), null);
|
||||
assert.equal(normalizeIdentity([]), null);
|
||||
for (const bad of [{...RAW_IDENTITY, tenant_ref: "bad"}, {...RAW_IDENTITY, user_ref: "bad"},
|
||||
{...RAW_IDENTITY, surface: "browser"}]) assert.equal(normalizeIdentity(bad), null);
|
||||
assert.deepEqual(normalizeScope(RAW_SCOPE), SCOPE);
|
||||
assert.deepEqual(normalizeScope(SCOPE), SCOPE);
|
||||
assert.equal(normalizeScope(null), null);
|
||||
assert.equal(normalizeScope([]), null);
|
||||
assert.equal(normalizeScope({...RAW_SCOPE, project_id: "bad"}), null);
|
||||
assert.equal(normalizeScope({...RAW_SCOPE, conversation_id: "bad"}), null);
|
||||
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
|
||||
assert.equal(sameIdentity(null, IDENTITY), false);
|
||||
assert.equal(sameIdentity({...IDENTITY, surface: "voice"}, IDENTITY), false);
|
||||
assert.equal(sameScope(SCOPE, SCOPE), true);
|
||||
assert.equal(sameScope(null, SCOPE), false);
|
||||
assert.equal(sameScope({...SCOPE, projectId: "prj_other1234"}, SCOPE), false);
|
||||
assert.equal(safeBlobUrl("blob:https://chat.bstein.dev/a"), "blob:https://chat.bstein.dev/a");
|
||||
assert.equal(safeBlobUrl("blob:null/a"), "blob:null/a");
|
||||
for (const bad of ["data:image/png;base64,a", "https://remote/a", "blob:data:image/png", "blob:has space", 4]) {
|
||||
assert.equal(safeBlobUrl(bad), null);
|
||||
}
|
||||
assert.equal(safePreviewText("line one\nline two"), "line one\nline two");
|
||||
assert.equal(safePreviewText("bad\u0000text"), null);
|
||||
assert.equal(safePreviewText("abc", 2), null);
|
||||
assert.equal(safePreviewText({}), null);
|
||||
assert.equal(safeText(null, "fallback"), "fallback");
|
||||
assert.equal(safeText(" \n ", "fallback"), "fallback");
|
||||
assert.equal(safeText("token=private\u0000 next", "fallback"), "token=[redacted] next");
|
||||
assert.equal(safeText("abcdef", "x", 4), "abc…");
|
||||
assert.equal(safeEndpointPart("med_image1234", "med"), "med_image1234");
|
||||
assert.throws(() => safeEndpointPart("../bad", "med"), TypeError);
|
||||
});
|
||||
|
||||
test("upload validation is MIME, size, name, and authorization bounded", () => {
|
||||
const cases = [["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"]];
|
||||
for (const [mime, kind] of cases) {
|
||||
assert.equal(classifyMime(mime), kind);
|
||||
assert.deepEqual(validateUpload({name: "safe.file", type: mime.toUpperCase(), size: 1}, "authorized"),
|
||||
{ok: true, kind, file: {name: "safe.file", type: mime, size: 1}});
|
||||
}
|
||||
assert.equal(classifyMime("image/svg+xml"), null);
|
||||
assert.equal(classifyMime(null), null);
|
||||
assert.match(validateUpload({name: "x", type: "image/png", size: 1}, "requires_approval").reason, /approval/);
|
||||
assert.match(validateUpload({name: "x", type: "image/png", size: 1}, "denied").reason, /denied/);
|
||||
assert.match(validateUpload({name: "x.svg", type: "image/svg+xml", size: 1}, "authorized").reason, /not supported/);
|
||||
for (const file of [{name: "x.png", type: "image/png", size: 0},
|
||||
{name: "x.png", type: "image/png", size: 20 * 1024 * 1024 + 1},
|
||||
{name: "../x.png", type: "image/png", size: 1}, {name: "", type: "image/png", size: 1},
|
||||
{name: "x.png", type: "image/png", size: 1.2}]) {
|
||||
assert.equal(validateUpload(file, "authorized").ok, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("page normalization preserves safe media, variants, annotations, and transcript", () => {
|
||||
const result = normalizeMultimodalPage(page(), IDENTITY, SCOPE);
|
||||
assert.equal(result.rejected, 0);
|
||||
assert.equal(result.media.length, 2);
|
||||
assert.equal(result.media[0].previewUrl, "blob:https://chat.bstein.dev/123");
|
||||
assert.equal(result.media[1].previewText, "Safe document\npreview");
|
||||
assert.deepEqual(result.media[1].annotations, [{id: "ann_note1234", page: 2, note: "Check this paragraph"}]);
|
||||
assert.equal(result.threads[0].variants[0].mediaId, "med_image1234");
|
||||
assert.equal(result.transcript.text, "A clean transcript");
|
||||
assert.equal(normalizeMultimodalPage(page({transcript: null}), IDENTITY, SCOPE).transcript, null);
|
||||
});
|
||||
|
||||
test("envelope and records cannot cross tenant, user, project, or conversation scope", () => {
|
||||
const invalidPages = [null, {}, page({schema: "old"}), page({api_version: "hux.v0"}),
|
||||
page({identity: {...RAW_IDENTITY, tenant_ref: "tnt_ffffffffffffffff"}}),
|
||||
page({identity: {...RAW_IDENTITY, user_ref: "usr_ffffffffffffffff"}}),
|
||||
page({binding: {...RAW_SCOPE, project_id: "prj_other1234"}}),
|
||||
page({binding: {...RAW_SCOPE, conversation_id: "conv_other1234"}}),
|
||||
page({media: null}), page({media: Array(201).fill(media())}), page({threads: null}),
|
||||
page({threads: Array(101).fill(thread())}), page({upload_authorization: "maybe"})];
|
||||
for (const raw of invalidPages) assert.equal(normalizeMultimodalPage(raw, IDENTITY, SCOPE), null);
|
||||
|
||||
const invalidMedia = [null, media({schema: "old"}), media({id: "bad"}),
|
||||
media({owner: "usr_ffffffffffffffff"}), media({project_id: "prj_other1234"}),
|
||||
media({conversation_id: "conv_other1234"}), media({artifact_id: "bad"}),
|
||||
media({artifact_version: 0}), media({kind: "video"}), media({file_name: ""}),
|
||||
media({mime: "image/svg+xml"}), media({mime: "application/pdf"}), media({bytes: 0}),
|
||||
media({annotations: null}), media({annotations: Array(501).fill({})}),
|
||||
media({annotations: [null]}), media({annotations: [{id: "bad", page: null, note: "x"}]}),
|
||||
media({annotations: [{id: "ann_note1234", page: 0, note: "x"}]}),
|
||||
media({annotations: [{id: "ann_note1234", page: null, note: ""}]}),
|
||||
media({preview_url: "data:image/png;base64,a"}), media({lineage: {}}),
|
||||
media({preview_authorized: false}), media({preview_text: "text"}),
|
||||
document({preview_text: "bad\u0000text"}), document({preview_authorized: false}),
|
||||
document({preview_url: "blob:https://chat.bstein.dev/doc"}),
|
||||
media({preview_url: null, preview_text: null, preview_authorized: false, bytes: "1200"}),
|
||||
media({lineage: {media_id: "bad", artifact_id: "art_image1234", artifact_version: 1}}),
|
||||
media({lineage: {media_id: "med_image1234", artifact_id: "art_image1234", artifact_version: 1}})];
|
||||
for (const raw of invalidMedia) {
|
||||
assert.equal(normalizeMultimodalPage(page({media: [raw]}), IDENTITY, SCOPE).media.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("lineage, thread cohesion, and transcript failures are withheld clearly", () => {
|
||||
const child = media({id: "med_child1234", artifact_id: "art_child1234", artifact_version: 2,
|
||||
lineage: {media_id: "med_image1234", artifact_id: "art_image1234", artifact_version: 1}});
|
||||
assert.equal(normalizeMultimodalPage(page({media: [media(), child]}), IDENTITY, SCOPE).media.length, 2);
|
||||
const orphan = {...child, lineage: {...child.lineage, artifact_version: 9}};
|
||||
assert.equal(normalizeMultimodalPage(page({media: [media(), orphan]}), IDENTITY, SCOPE).media.length, 1);
|
||||
const duplicate = normalizeMultimodalPage(page({media: [media(), media()]}), IDENTITY, SCOPE);
|
||||
assert.equal(duplicate.media.length, 1);
|
||||
assert.equal(duplicate.rejected, 1);
|
||||
|
||||
const badThreads = [null, thread({id: "bad"}), thread({owner: "usr_ffffffffffffffff"}),
|
||||
thread({project_id: "prj_other1234"}), thread({conversation_id: "conv_other1234"}),
|
||||
thread({prompt: ""}), thread({variants: null}), thread({variants: []}),
|
||||
thread({variants: Array(201).fill(variant())}), thread({variants: [variant({id: "bad"})]}),
|
||||
thread({variants: [variant({media_id: "med_missing1"})]}),
|
||||
thread({variants: [variant({artifact_id: "art_other1234"})]}),
|
||||
thread({variants: [variant({artifact_version: 2})]}),
|
||||
thread({variants: [variant({parent_variant_id: "bad"})]}),
|
||||
thread({variants: [variant({created_at: "today"})]}),
|
||||
thread({variants: [variant(), variant()]}),
|
||||
thread({variants: [variant({parent_variant_id: "var_missing12"})]})];
|
||||
for (const raw of badThreads) {
|
||||
assert.equal(normalizeMultimodalPage(page({threads: [raw]}), IDENTITY, SCOPE).threads.length, 0);
|
||||
}
|
||||
const second = variant({id: "var_second123", parent_variant_id: "var_first1234",
|
||||
created_at: "2026-08-24T10:00:01Z"});
|
||||
assert.equal(normalizeMultimodalPage(page({threads: [thread({variants: [variant(), second]})]}),
|
||||
IDENTITY, SCOPE).threads[0].variants.length, 2);
|
||||
|
||||
const badTranscripts = [{}, transcript({schema: "old"}), transcript({owner: "usr_ffffffffffffffff"}),
|
||||
transcript({project_id: "prj_other1234"}), transcript({conversation_id: "conv_other1234"}),
|
||||
transcript({id: "bad"}), transcript({turn_id: "bad"}), transcript({revision: 0}),
|
||||
transcript({language: ""}), transcript({text: ""}), transcript({finalized: "yes"})];
|
||||
for (const raw of badTranscripts) {
|
||||
const result = normalizeMultimodalPage(page({transcript: raw}), IDENTITY, SCOPE);
|
||||
assert.equal(result.transcript, null);
|
||||
assert.equal(result.rejected, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("edit, annotation, transcript, and capture intents are bounded and optimistic", () => {
|
||||
const normalized = normalizeMultimodalPage(page(), IDENTITY, SCOPE);
|
||||
const image = normalized.media[0];
|
||||
const doc = normalized.media[1];
|
||||
assert.deepEqual(buildImageEdit(image, SCOPE, " Make it warmer "), {
|
||||
artifactId: image.artifactId, expectedVersion: 1, scope: SCOPE, mediaId: image.id,
|
||||
instruction: "Make it warmer", lineage: {artifactId: image.artifactId, version: 1}});
|
||||
assert.throws(() => buildImageEdit(doc, SCOPE, "edit"), TypeError);
|
||||
assert.throws(() => buildImageEdit(image, SCOPE, ""), TypeError);
|
||||
assert.deepEqual(buildAnnotation(doc, SCOPE, " Note this ", 2).page, 2);
|
||||
assert.equal(buildAnnotation(doc, SCOPE, "Note", null).page, null);
|
||||
for (const args of [[image, "note", 1], [doc, "", 1], [doc, "note", 0],
|
||||
[doc, "note", 100001], [doc, "note", 1.2]]) assert.throws(() => buildAnnotation(args[0], SCOPE, args[1], args[2]), TypeError);
|
||||
assert.equal(buildTranscriptCorrection(normalized.transcript, " corrected ", IDENTITY, SCOPE).correctedText, "corrected");
|
||||
assert.throws(() => buildTranscriptCorrection({...normalized.transcript, finalized: false}, "x", IDENTITY, SCOPE), TypeError);
|
||||
assert.throws(() => buildTranscriptCorrection(normalized.transcript, "", IDENTITY, SCOPE), TypeError);
|
||||
|
||||
assert.equal(captureAuthorization(null, null, IDENTITY), "deny");
|
||||
assert.equal(captureAuthorization(policy("deny"), null, IDENTITY), "deny");
|
||||
assert.equal(captureAuthorization(policy("allow"), null, IDENTITY), "approval_required");
|
||||
assert.equal(captureAuthorization(policy("ask", "ask_first"), null, IDENTITY), "approval_required");
|
||||
assert.deepEqual(buildCaptureIntent("camera", policy(), IDENTITY, SCOPE), {kind: "camera",
|
||||
autonomyCapability: "network", requiredDecision: "ask", identity: IDENTITY, scope: SCOPE});
|
||||
assert.equal(buildCaptureIntent("screen", policy(), IDENTITY, SCOPE).kind, "screen");
|
||||
assert.throws(() => buildCaptureIntent("microphone", policy(), IDENTITY, SCOPE), TypeError);
|
||||
assert.throws(() => buildCaptureIntent("camera", policy("deny"), IDENTITY, SCOPE), TypeError);
|
||||
});
|
||||
|
||||
test("endpoint descriptions remain same-origin, scoped, default-off, and non-executing", () => {
|
||||
const client = {apiVersion: "hux.v1", identity: IDENTITY,
|
||||
endpoint(path) { assert.match(path, /^\/projects\/prj_[^/]+\/conversations\/conv_[^/]+\/multimodal/);
|
||||
return `/hux/v1${path}`; }};
|
||||
assert.equal(createMultimodalEndpointContract(client, SCOPE), null);
|
||||
assert.equal(createMultimodalEndpointContract(client, SCOPE, FLAGS.slice(1)), null);
|
||||
const contract = createMultimodalEndpointContract(client, SCOPE, FLAGS);
|
||||
assert.equal(contract.page.path, "/hux/v1/projects/prj_test1234/conversations/conv_test1234/multimodal");
|
||||
assert.equal(contract.beginUpload.method, "POST");
|
||||
assert.match(contract.finalizeUpload("upl_upload123").path, /uploads\/upl_upload123\/finalize$/);
|
||||
assert.match(contract.editImage("med_image1234").path, /media\/med_image1234\/edits$/);
|
||||
assert.match(contract.annotate("med_image1234").path, /annotations$/);
|
||||
assert.match(contract.correctTranscript("trn_voice1234").path, /corrections$/);
|
||||
assert.match(contract.requestCapture("camera").path, /captures\/camera\/approvals$/);
|
||||
assert.match(contract.requestCapture("screen").path, /captures\/screen\/approvals$/);
|
||||
assert.match(contract.createVariant("thr_garden123").path, /threads\/thr_garden123\/variants$/);
|
||||
for (const call of [() => contract.finalizeUpload("bad"), () => contract.editImage("bad"),
|
||||
() => contract.correctTranscript("bad"), () => contract.requestCapture("audio"),
|
||||
() => contract.createVariant("bad")]) assert.throws(call, TypeError);
|
||||
assert.throws(() => createMultimodalEndpointContract({...client, apiVersion: "hux.v2"}, SCOPE, FLAGS), TypeError);
|
||||
assert.throws(() => createMultimodalEndpointContract({...client, identity: {...IDENTITY, userRef: "bad"}}, SCOPE, FLAGS), TypeError);
|
||||
assert.throws(() => createMultimodalEndpointContract(client, {...SCOPE, projectId: "bad"}, FLAGS), TypeError);
|
||||
});
|
||||
94
testing/tests/test_hermes_hux_ui_multimodal.py
Normal file
94
testing/tests/test_hermes_hux_ui_multimodal.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""Executable and static quality gates for isolated HUX-07 multimodal chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MULTIMODAL = ROOT / "dockerfiles" / "hermes-webui-hux" / "multimodal"
|
||||
|
||||
|
||||
def test_hux_multimodal_model_node_suite_and_coverage():
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-branches=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/multimodal/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/multimodal/security.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/multimodal/endpoints.ts",
|
||||
"testing/tests/test_hermes_hux_ui_multimodal.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_multimodal_component_is_flagged_accessible_safe_and_inert():
|
||||
component = (MULTIMODAL / "MultimodalChat.tsx").read_text(encoding="utf-8")
|
||||
model = (MULTIMODAL / "model.ts").read_text(encoding="utf-8")
|
||||
endpoints = (MULTIMODAL / "endpoints.ts").read_text(encoding="utf-8")
|
||||
styles = (MULTIMODAL / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled || !page) return null" in component
|
||||
assert "multimodalEnabled(props.flags)" in component
|
||||
for flag in (
|
||||
"FOUNDATION_FLAG",
|
||||
"PROJECTS_FLAG",
|
||||
"ARTIFACTS_FLAG",
|
||||
"AUTONOMY_FLAG",
|
||||
"MULTIMODAL_FLAG",
|
||||
):
|
||||
assert flag in model
|
||||
assert 'aria-label="Multimodal chat"' in component
|
||||
assert 'aria-live="polite"' in component
|
||||
assert 'aria-label="Live media permissions"' in component
|
||||
assert "Ask to use" in component and "Ask before uploading" in component
|
||||
assert "Create linked variant" in component
|
||||
assert "Correct transcript" in component
|
||||
assert "Content is not executed" in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert "<iframe" not in component and "<object" not in component and "<embed" not in component
|
||||
assert "getUserMedia" not in component and "getDisplayMedia" not in component
|
||||
assert "fetch(" not in endpoints and "axios" not in endpoints
|
||||
assert '"PATCH"' not in endpoints and '"DELETE"' not in endpoints
|
||||
assert ".hux-multimodal" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_multimodal_sources_are_bounded_and_isolated():
|
||||
sources = sorted(MULTIMODAL.glob("*"))
|
||||
assert {path.name for path in sources} == {
|
||||
"MultimodalChat.tsx",
|
||||
"endpoints.ts",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in sources:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
assert "dockerfiles/hermes-webui-hux/multimodal" not in (
|
||||
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_hux_multimodal_forbids_inline_and_executable_media():
|
||||
security = (MULTIMODAL / "security.ts").read_text(encoding="utf-8")
|
||||
component = (MULTIMODAL / "MultimodalChat.tsx").read_text(encoding="utf-8")
|
||||
assert "image/svg+xml" not in security
|
||||
assert "text/html" not in security
|
||||
assert "data:" not in component
|
||||
assert "URL.createObjectURL" not in component
|
||||
assert "blob:" in security
|
||||
413
testing/tests/test_hermes_hux_ui_onboarding.mjs
Normal file
413
testing/tests/test_hermes_hux_ui_onboarding.mjs
Normal file
@ -0,0 +1,413 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createOnboardingClient,
|
||||
OnboardingContractError,
|
||||
} from "../../dockerfiles/hermes-webui-hux/onboarding/client.ts";
|
||||
import {
|
||||
FOUNDATION_FLAG,
|
||||
FRIENDLY_MODES_FLAG,
|
||||
ONBOARDING_FLAG,
|
||||
PRIVACY_FLAG,
|
||||
PROJECTS_FLAG,
|
||||
buildActionIntent,
|
||||
normalizeDecisionState,
|
||||
normalizeEvaluation,
|
||||
normalizeState,
|
||||
normalizeSuggestion,
|
||||
onboardingEnabled,
|
||||
} from "../../dockerfiles/hermes-webui-hux/onboarding/model.ts";
|
||||
import {
|
||||
asRecord,
|
||||
exactKeys,
|
||||
isId,
|
||||
isSafeText,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
normalizeScope,
|
||||
normalizeTrigger,
|
||||
sameIdentity,
|
||||
sameTrigger,
|
||||
scopedSuggestionPath,
|
||||
} from "../../dockerfiles/hermes-webui-hux/onboarding/security.ts";
|
||||
|
||||
const IDENTITY = Object.freeze({
|
||||
tenantRef: "tnt_0123456789abcdef",
|
||||
userRef: "usr_0123456789abcdef",
|
||||
surface: "chat",
|
||||
});
|
||||
const RAW_IDENTITY = Object.freeze({
|
||||
tenant_ref: IDENTITY.tenantRef,
|
||||
user_ref: IDENTITY.userRef,
|
||||
surface: IDENTITY.surface,
|
||||
});
|
||||
const SCOPE = Object.freeze({
|
||||
sessionId: "ses_alpha1234",
|
||||
conversationId: "conv_alpha1234",
|
||||
trigger: {surface: "chat", context: "after_approval"},
|
||||
});
|
||||
const FLAGS = [
|
||||
FOUNDATION_FLAG, PROJECTS_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG,
|
||||
PRIVACY_FLAG,
|
||||
];
|
||||
|
||||
function suggestion(extra = {}) {
|
||||
return {
|
||||
schema: "hux.suggestion.v1",
|
||||
id: "sug_alpha1234",
|
||||
kind: "workflow",
|
||||
trigger: SCOPE.trigger,
|
||||
title: "Run this every week?",
|
||||
body: "Hermes can prepare the same report each week after you confirm it.",
|
||||
action: {type: "start_workflow", payload: {intent: "schedule_weekly"}},
|
||||
priority: 35,
|
||||
suppression: {
|
||||
dismissable: true,
|
||||
max_shows: 2,
|
||||
cooldown_seconds: 86400,
|
||||
never_again_supported: true,
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function state(extra = {}) {
|
||||
return {
|
||||
schema: "hux.suggestion_state.v1",
|
||||
owner: IDENTITY.userRef,
|
||||
suggestion_id: "sug_alpha1234",
|
||||
shows: 1,
|
||||
last_shown_at: "2026-08-24T10:00:00Z",
|
||||
never_again: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function evaluation(extra = {}) {
|
||||
return {
|
||||
schema: "hux.suggestion_evaluation.v1",
|
||||
api_version: "hux.v1",
|
||||
identity: RAW_IDENTITY,
|
||||
session_id: SCOPE.sessionId,
|
||||
conversation_id: SCOPE.conversationId,
|
||||
trigger: SCOPE.trigger,
|
||||
privacy: {
|
||||
schema: "hux.onboarding_privacy.v1",
|
||||
no_store: false,
|
||||
memory_suggestions_allowed: true,
|
||||
},
|
||||
claim_id: "clm_alpha1234",
|
||||
issued_at: "2026-08-24T10:00:01Z",
|
||||
suggestion: suggestion(),
|
||||
state: state(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function decisionEnvelope(item, decision = "dismiss", extra = {}) {
|
||||
const timestamp = "2026-08-24T10:01:00Z";
|
||||
return {
|
||||
schema: "hux.suggestion_decision.v1",
|
||||
api_version: "hux.v1",
|
||||
identity: RAW_IDENTITY,
|
||||
session_id: SCOPE.sessionId,
|
||||
conversation_id: SCOPE.conversationId,
|
||||
claim_id: "clm_alpha1234",
|
||||
suggestion_id: "sug_alpha1234",
|
||||
state: {
|
||||
...state(),
|
||||
...(decision === "acted" ? {acted_at: timestamp} : {dismissed_at: timestamp}),
|
||||
never_again: decision === "never_again",
|
||||
},
|
||||
...extra,
|
||||
...(item ? {state: item} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function harness(responses, flags = FLAGS) {
|
||||
const calls = [];
|
||||
const enabled = new Set(flags);
|
||||
const foundation = {
|
||||
apiVersion: "hux.v1",
|
||||
identity: IDENTITY,
|
||||
enabled: (flag) => enabled.has(flag),
|
||||
endpoint: (path) => `/hux/v1${path}`,
|
||||
};
|
||||
const fetcher = async (url, init) => {
|
||||
calls.push({url, init});
|
||||
const response = responses.shift();
|
||||
if (!response) throw new Error("unexpected request");
|
||||
return {
|
||||
ok: response.ok ?? true,
|
||||
status: response.status ?? 200,
|
||||
json: async () => response.body,
|
||||
};
|
||||
};
|
||||
return {calls, foundation,
|
||||
client: createOnboardingClient({client: foundation, fetcher})};
|
||||
}
|
||||
|
||||
test("HUX-09 is default-off and requires all four dependencies", () => {
|
||||
assert.equal(onboardingEnabled(), false);
|
||||
assert.equal(onboardingEnabled([]), false);
|
||||
for (const flag of FLAGS.slice(0, 4)) {
|
||||
assert.equal(onboardingEnabled(FLAGS.slice(0, 4).filter((item) => item !== flag)), false);
|
||||
}
|
||||
assert.equal(onboardingEnabled(FLAGS.slice(0, 4)), true);
|
||||
});
|
||||
|
||||
test("security primitives bind exact identity, session and trigger", () => {
|
||||
assert.deepEqual(asRecord({a: 1}), {a: 1});
|
||||
assert.equal(asRecord(null), null);
|
||||
assert.equal(asRecord([]), null);
|
||||
assert.equal(exactKeys({a: 1, b: 2}, ["a"], ["b"]), true);
|
||||
assert.equal(exactKeys({a: 1, x: 2}, ["a"]), false);
|
||||
assert.equal(isId("sug_alpha1234", "sug"), true);
|
||||
assert.equal(isId("bad", "sug"), false);
|
||||
assert.equal(isId("clm_alpha1234", "sug"), false);
|
||||
assert.equal(isUtc("2026-08-24T10:00:00Z"), true);
|
||||
assert.equal(isUtc("2026-99-99T10:00:00Z"), false);
|
||||
assert.equal(isSafeText("hello", 1, 10), true);
|
||||
assert.equal(isSafeText("bad\u0000", 1, 10), false);
|
||||
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
|
||||
for (const raw of [null, [], {}, {...RAW_IDENTITY, extra: true},
|
||||
{...RAW_IDENTITY, tenant_ref: "bad"}, {...RAW_IDENTITY, user_ref: "bad"},
|
||||
{...RAW_IDENTITY, surface: "browser"}]) assert.equal(normalizeIdentity(raw), null);
|
||||
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
|
||||
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
|
||||
assert.deepEqual(normalizeTrigger(SCOPE.trigger), SCOPE.trigger);
|
||||
for (const raw of [null, {}, {...SCOPE.trigger, extra: true},
|
||||
{...SCOPE.trigger, surface: "browser"}, {...SCOPE.trigger, context: "often"}])
|
||||
assert.equal(normalizeTrigger(raw), null);
|
||||
assert.equal(sameTrigger(SCOPE.trigger, SCOPE.trigger), true);
|
||||
assert.equal(sameTrigger(SCOPE.trigger, {...SCOPE.trigger, context: "idle"}), false);
|
||||
assert.deepEqual(normalizeScope(SCOPE, IDENTITY), SCOPE);
|
||||
for (const raw of [{...SCOPE, extra: true}, {...SCOPE, sessionId: "bad"},
|
||||
{...SCOPE, conversationId: "bad"},
|
||||
{...SCOPE, trigger: {...SCOPE.trigger, surface: "worker"}}])
|
||||
assert.equal(normalizeScope(raw, IDENTITY), null);
|
||||
assert.equal(scopedSuggestionPath("/onboarding/suggestions/", "sug_alpha1234"),
|
||||
"/onboarding/suggestions/sug_alpha1234/decisions");
|
||||
for (const [base, id] of [["//evil", "sug_alpha1234"], ["/x?y", "sug_alpha1234"],
|
||||
["/../x", "sug_alpha1234"], ["/safe", "bad"]])
|
||||
assert.throws(() => scopedSuggestionPath(base, id), TypeError);
|
||||
});
|
||||
|
||||
test("suggestion schema normalizes all four contextual actions", () => {
|
||||
const workflow = normalizeSuggestion(suggestion(), SCOPE.trigger);
|
||||
assert.equal(workflow.action.payload.intent, "schedule_weekly");
|
||||
assert.equal(workflow.suppression.cooldownSeconds, 86400);
|
||||
const save = normalizeSuggestion(suggestion({
|
||||
action: {type: "start_workflow", payload: {intent: "save_reusable"}},
|
||||
}), SCOPE.trigger);
|
||||
assert.equal(save.action.payload.intent, "save_reusable");
|
||||
const project = normalizeSuggestion(suggestion({kind: "project",
|
||||
action: {type: "create_project", payload: {prefill_title: "Launch plan"}}}), SCOPE.trigger);
|
||||
assert.equal(project.action.payload.prefill_title, "Launch plan");
|
||||
const memory = normalizeSuggestion(suggestion({kind: "feature",
|
||||
action: {type: "open_memory", payload: {view: "suggestions"}}}), SCOPE.trigger);
|
||||
assert.equal(memory.action.type, "open_memory");
|
||||
const mode = normalizeSuggestion(suggestion({kind: "feature",
|
||||
action: {type: "open_mode", payload: {mode: "research"}}}), SCOPE.trigger);
|
||||
assert.equal(mode.action.payload.mode, "research");
|
||||
const artifacts = normalizeSuggestion(suggestion({kind: "feature",
|
||||
action: {type: "open_artifacts", payload: {view: "current"}}}), SCOPE.trigger);
|
||||
assert.equal(artifacts.action.type, "open_artifacts");
|
||||
const tip = normalizeSuggestion(suggestion({kind: "tip", action: undefined}), SCOPE.trigger);
|
||||
assert.equal(tip.action.type, "none");
|
||||
assert.deepEqual(normalizeSuggestion(suggestion({kind: "project",
|
||||
action: {type: "create_project"}}), SCOPE.trigger).action.payload, {});
|
||||
assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature",
|
||||
action: {type: "open_mode"}}), SCOPE.trigger).action.payload, {});
|
||||
assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature",
|
||||
action: {type: "open_memory"}}), SCOPE.trigger).action.payload, {});
|
||||
assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature",
|
||||
action: {type: "open_artifacts"}}), SCOPE.trigger).action.payload, {});
|
||||
});
|
||||
|
||||
test("malformed, mismatched, or pushy suggestions fail closed", () => {
|
||||
const invalid = [
|
||||
null, {...suggestion(), extra: true}, {...suggestion(), schema: "hux.suggestion.v0"},
|
||||
{...suggestion(), id: "bad"}, {...suggestion(), kind: "ad"},
|
||||
{...suggestion(), title: ""}, {...suggestion(), title: "x".repeat(81)},
|
||||
{...suggestion(), body: "bad\u0000"}, {...suggestion(), priority: 101},
|
||||
{...suggestion(), trigger: {...SCOPE.trigger, context: "idle"}},
|
||||
{...suggestion(), suppression: null},
|
||||
{...suggestion(), suppression: {...suggestion().suppression, dismissable: false}},
|
||||
{...suggestion(), suppression: {...suggestion().suppression, max_shows: 6}},
|
||||
{...suggestion(), suppression: {...suggestion().suppression, cooldown_seconds: 30}},
|
||||
{...suggestion(), suppression: {...suggestion().suppression, never_again_supported: false}},
|
||||
{...suggestion(), action: null},
|
||||
{...suggestion(), action: {type: "unknown"}},
|
||||
{...suggestion(), action: {type: "start_workflow", payload: {intent: "daily"}}},
|
||||
{...suggestion(), action: {type: "start_workflow", payload: {intent: "schedule_weekly", extra: 1}}},
|
||||
{...suggestion(), action: {type: "create_project", payload: {prefill_title: ""}}},
|
||||
{...suggestion(), action: {type: "create_project", payload: {prefill_title: "x".repeat(121)}}},
|
||||
{...suggestion(), action: {type: "create_project", payload: {extra: true}}},
|
||||
{...suggestion(), action: {type: "open_mode", payload: {mode: "turbo"}}},
|
||||
{...suggestion(), action: {type: "open_mode", payload: {extra: true}}},
|
||||
{...suggestion(), action: {type: "open_memory", payload: {view: "all"}}},
|
||||
{...suggestion(), action: {type: "open_memory", payload: {extra: true}}},
|
||||
{...suggestion(), action: {type: "open_artifacts", payload: {view: "all"}}},
|
||||
{...suggestion(), action: {type: "open_artifacts", payload: {extra: true}}},
|
||||
{...suggestion(), action: {type: "none", payload: {surprise: true}}},
|
||||
{...suggestion(), kind: "project"},
|
||||
];
|
||||
invalid.forEach((raw) => assert.equal(normalizeSuggestion(raw, SCOPE.trigger), null));
|
||||
});
|
||||
|
||||
test("eligible state is owner-bound and inside the server rate limit", () => {
|
||||
const item = normalizeSuggestion(suggestion(), SCOPE.trigger);
|
||||
assert.equal(normalizeState(state(), item, IDENTITY.userRef).shows, 1);
|
||||
const complete = normalizeState(state({dismissed_at: "2026-08-24T10:01:00Z",
|
||||
acted_at: "2026-08-24T10:02:00Z"}), item, IDENTITY.userRef);
|
||||
assert.equal(complete.dismissedAt, "2026-08-24T10:01:00Z");
|
||||
assert.equal(complete.actedAt, "2026-08-24T10:02:00Z");
|
||||
const invalid = [null, {...state(), extra: true}, {...state(), schema: "old"},
|
||||
{...state(), owner: "usr_aaaaaaaaaaaaaaaa"}, {...state(), suggestion_id: "sug_other1234"},
|
||||
{...state(), shows: 0}, {...state(), shows: 3}, {...state(), last_shown_at: "today"},
|
||||
{...state(), never_again: true}, {...state(), dismissed_at: "today"},
|
||||
{...state(), acted_at: "today"}];
|
||||
invalid.forEach((raw) => assert.equal(normalizeState(raw, item, IDENTITY.userRef), null));
|
||||
});
|
||||
|
||||
test("evaluation binds identity, session, trigger, privacy and one claim", () => {
|
||||
const item = normalizeEvaluation(evaluation(), IDENTITY, SCOPE, true);
|
||||
assert.equal(item.claimId, "clm_alpha1234");
|
||||
assert.equal(item.state.owner, IDENTITY.userRef);
|
||||
assert.equal(normalizeEvaluation(evaluation({privacy: {
|
||||
schema: "hux.onboarding_privacy.v1", no_store: true,
|
||||
memory_suggestions_allowed: false}, claim_id: null, suggestion: null, state: null}),
|
||||
IDENTITY, SCOPE, true), null);
|
||||
assert.equal(normalizeEvaluation(evaluation({suggestion: null, claim_id: null, state: null}),
|
||||
IDENTITY, SCOPE, true), null);
|
||||
const memoryRaw = evaluation({suggestion: suggestion({kind: "feature",
|
||||
action: {type: "open_memory", payload: {view: "suggestions"}}})});
|
||||
assert.equal(normalizeEvaluation(memoryRaw, IDENTITY, SCOPE, false), null);
|
||||
assert.equal(normalizeEvaluation({...memoryRaw, privacy: {...memoryRaw.privacy,
|
||||
memory_suggestions_allowed: false}}, IDENTITY, SCOPE, true), null);
|
||||
assert.ok(normalizeEvaluation(memoryRaw, IDENTITY, SCOPE, true));
|
||||
const invalid = [null, {...evaluation(), extra: true}, {...evaluation(), schema: "old"},
|
||||
{...evaluation(), api_version: "hux.v0"},
|
||||
{...evaluation(), identity: {...RAW_IDENTITY, surface: "worker"}},
|
||||
{...evaluation(), session_id: "ses_other1234"},
|
||||
{...evaluation(), conversation_id: "conv_other1234"},
|
||||
{...evaluation(), trigger: {...SCOPE.trigger, context: "idle"}},
|
||||
{...evaluation(), privacy: null},
|
||||
{...evaluation(), privacy: {...evaluation().privacy, extra: true}},
|
||||
{...evaluation(), privacy: {...evaluation().privacy, no_store: "no"}},
|
||||
{...evaluation(), claim_id: "bad"}, {...evaluation(), issued_at: "today"},
|
||||
{...evaluation(), state: {...state(), owner: "usr_aaaaaaaaaaaaaaaa"}}];
|
||||
invalid.forEach((raw) => assert.equal(normalizeEvaluation(raw, IDENTITY, SCOPE, true), null));
|
||||
});
|
||||
|
||||
test("actions bridge only validated HUX-03/HUX-06 interfaces", () => {
|
||||
const make = (change) => normalizeEvaluation(evaluation({
|
||||
suggestion: suggestion(change),
|
||||
}), IDENTITY, SCOPE, true);
|
||||
assert.deepEqual(buildActionIntent(make({})),
|
||||
{type: "start_workflow", intent: "schedule_weekly"});
|
||||
assert.deepEqual(buildActionIntent(make({kind: "project", action: {
|
||||
type: "create_project", payload: {prefill_title: "Launch"}}})),
|
||||
{type: "create_project", conversationId: SCOPE.conversationId, prefillTitle: "Launch"});
|
||||
assert.deepEqual(buildActionIntent(make({kind: "feature", action: {
|
||||
type: "open_mode", payload: {mode: "fast"}}})), {type: "open_mode", mode: "fast"});
|
||||
assert.deepEqual(buildActionIntent(make({kind: "feature", action: {
|
||||
type: "open_mode"}})), {type: "open_mode"});
|
||||
assert.deepEqual(buildActionIntent(make({kind: "project", action: {
|
||||
type: "create_project"}})),
|
||||
{type: "create_project", conversationId: SCOPE.conversationId});
|
||||
assert.deepEqual(buildActionIntent(make({kind: "feature", action: {
|
||||
type: "open_memory"}})), {type: "open_memory", view: "suggestions"});
|
||||
assert.deepEqual(buildActionIntent(make({kind: "feature", action: {
|
||||
type: "open_artifacts"}})), {type: "open_artifacts", view: "current"});
|
||||
assert.equal(buildActionIntent(make({kind: "tip", action: {type: "none"}})), null);
|
||||
});
|
||||
|
||||
test("decision states preserve shows and prove the chosen outcome", () => {
|
||||
const item = normalizeEvaluation(evaluation(), IDENTITY, SCOPE, true);
|
||||
for (const decision of ["dismiss", "never_again", "acted"]) {
|
||||
const raw = decisionEnvelope(null, decision).state;
|
||||
const normalized = normalizeDecisionState(raw, item, decision);
|
||||
assert.ok(normalized);
|
||||
assert.equal(normalized.neverAgain, decision === "never_again");
|
||||
}
|
||||
for (const raw of [null, {...state(), extra: true}, {...state(), shows: 2},
|
||||
{...state(), last_shown_at: "2026-08-24T11:00:00Z"},
|
||||
{...state(), never_again: "yes"}, {...state(), dismissed_at: "today"},
|
||||
{...state(), acted_at: "today"}, {...state(), acted_at: "2026-08-24T10:01:00Z"}])
|
||||
assert.equal(normalizeDecisionState(raw, item, "dismiss"), null);
|
||||
assert.ok(normalizeDecisionState({...state(), dismissed_at: "2026-08-24T10:01:00Z",
|
||||
acted_at: "2026-08-24T10:02:00Z"}, item, "dismiss"));
|
||||
});
|
||||
|
||||
test("client evaluates exact triggers with no-store same-origin requests", async () => {
|
||||
const current = harness([{body: evaluation()}]);
|
||||
assert.equal(current.client.enabled(), true);
|
||||
const item = await current.client.evaluate(SCOPE);
|
||||
assert.equal(item.suggestion.title, "Run this every week?");
|
||||
assert.equal(current.calls[0].url, "/hux/v1/onboarding/evaluate");
|
||||
assert.equal(current.calls[0].init.method, "POST");
|
||||
assert.equal(current.calls[0].init.cache, "no-store");
|
||||
assert.equal(current.calls[0].init.credentials, "same-origin");
|
||||
assert.deepEqual(JSON.parse(current.calls[0].init.body), {
|
||||
schema: "hux.suggestion_evaluation_request.v1", session_id: SCOPE.sessionId,
|
||||
conversation_id: SCOPE.conversationId, trigger: SCOPE.trigger,
|
||||
});
|
||||
const replay = harness([{body: evaluation()}, {body: evaluation()}]);
|
||||
assert.ok(await replay.client.evaluate(SCOPE));
|
||||
assert.equal(await replay.client.evaluate(SCOPE), null);
|
||||
const empty = harness([{status: 204}, {status: 404}]);
|
||||
assert.equal(await empty.client.evaluate(SCOPE), null);
|
||||
assert.equal(await empty.client.evaluate(SCOPE), null);
|
||||
});
|
||||
|
||||
test("client persists explicit decisions without performing suggested work", async () => {
|
||||
for (const decision of ["dismiss", "never_again", "acted"]) {
|
||||
const current = harness([{body: evaluation()}, {body: decisionEnvelope(null, decision)}]);
|
||||
const item = await current.client.evaluate(SCOPE);
|
||||
const after = await current.client.decide(item, decision);
|
||||
assert.equal(after.neverAgain, decision === "never_again");
|
||||
assert.equal(current.calls[1].url,
|
||||
"/hux/v1/onboarding/suggestions/sug_alpha1234/decisions");
|
||||
assert.deepEqual(JSON.parse(current.calls[1].init.body), {
|
||||
schema: "hux.suggestion_decision_request.v1", session_id: SCOPE.sessionId,
|
||||
conversation_id: SCOPE.conversationId, claim_id: item.claimId,
|
||||
suggestion_id: item.suggestion.id, expected_shows: 1, decision,
|
||||
});
|
||||
await assert.rejects(current.client.decide(item, decision), /not bound/);
|
||||
}
|
||||
});
|
||||
|
||||
test("client rejects disabled, malformed, cross-scope, stale, and unissued data", async () => {
|
||||
const disabled = harness([], [FOUNDATION_FLAG, PROJECTS_FLAG]);
|
||||
assert.equal(disabled.client.enabled(), false);
|
||||
await assert.rejects(disabled.client.evaluate(SCOPE), /not enabled/);
|
||||
disabled.foundation.apiVersion = "hux.v0";
|
||||
await assert.rejects(disabled.client.evaluate(SCOPE), /not enabled/);
|
||||
const invalidScope = harness([]);
|
||||
await assert.rejects(invalidScope.client.evaluate({...SCOPE, sessionId: "bad"}), /outside/);
|
||||
await assert.rejects(invalidScope.client.decide({claimId: "clm_fake1234"}, "dismiss"), /not bound/);
|
||||
await assert.rejects(invalidScope.client.decide({}, "delete"), /not bound/);
|
||||
for (const response of [{ok: false, status: 409}, {ok: false, status: 503}]) {
|
||||
const current = harness([response]);
|
||||
await assert.rejects(current.client.evaluate(SCOPE), OnboardingContractError);
|
||||
}
|
||||
const malformed = harness([{body: {}}]);
|
||||
assert.equal(await malformed.client.evaluate(SCOPE), null);
|
||||
const wrongDecision = harness([{body: evaluation()}, {body: decisionEnvelope(null, "dismiss", {
|
||||
identity: {...RAW_IDENTITY, surface: "worker"}})}]);
|
||||
const item = await wrongDecision.client.evaluate(SCOPE);
|
||||
await assert.rejects(wrongDecision.client.decide(item, "dismiss"), /crossed/);
|
||||
const missing = harness([{body: evaluation()}, {status: 204}]);
|
||||
await assert.rejects(missing.client.decide(await missing.client.evaluate(SCOPE), "dismiss"), /crossed/);
|
||||
assert.throws(() => createOnboardingClient({client: null, fetcher: async () => ({})}), /requires/);
|
||||
assert.throws(() => createOnboardingClient({client: {...invalidScope.foundation,
|
||||
identity: {...IDENTITY, tenantRef: "bad"}}, fetcher: async () => ({})}), /identity/);
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = undefined;
|
||||
assert.throws(() => createOnboardingClient({client: invalidScope.foundation}), /requires/);
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
});
|
||||
117
testing/tests/test_hermes_hux_ui_onboarding.py
Normal file
117
testing/tests/test_hermes_hux_ui_onboarding.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Executable and static quality gates for the isolated HUX-09 frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ONBOARDING = ROOT / "dockerfiles" / "hermes-webui-hux" / "onboarding"
|
||||
|
||||
|
||||
def test_hux_onboarding_node_suite_and_coverage():
|
||||
"""Suggestion validation and transport remain above the repository gate."""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-branches=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/onboarding/client.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/onboarding/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/onboarding/security.ts",
|
||||
"testing/tests/test_hermes_hux_ui_onboarding.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_onboarding_matches_schema_and_is_inert():
|
||||
"""HUX-09 consumes the shared schema without entering a live image."""
|
||||
|
||||
schema = json.loads(
|
||||
(ROOT / "services/hermes/contracts/hux/suggestion.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
model = (ONBOARDING / "model.ts").read_text(encoding="utf-8")
|
||||
for value in schema["$defs"]["suggestion"]["properties"]["kind"]["enum"]:
|
||||
assert f'"{value}"' in model
|
||||
for value in schema["$defs"]["suggestion"]["properties"]["action"][
|
||||
"properties"
|
||||
]["type"]["enum"]:
|
||||
assert f'"{value}"' in model
|
||||
for value in schema["$defs"]["suggestion"]["properties"]["trigger"][
|
||||
"properties"
|
||||
]["context"]["enum"]:
|
||||
assert f'"{value}"' in (ONBOARDING / "security.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "dockerfiles/hermes-webui-hux/onboarding" not in (
|
||||
ROOT / "dockerfiles/Dockerfile.hermes-webui"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_hux_onboarding_ui_is_accessible_restrained_and_not_persistent():
|
||||
"""The card stays optional, dismissible, scoped and keyboard accessible."""
|
||||
|
||||
component = (ONBOARDING / "OnboardingSuggestions.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
client = (ONBOARDING / "client.ts").read_text(encoding="utf-8")
|
||||
contracts = (ONBOARDING / "contracts.ts").read_text(encoding="utf-8")
|
||||
styles = (ONBOARDING / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled || !evaluation" in component
|
||||
assert "Optional next step" in component
|
||||
assert "Not now" in component and "Don't suggest this again" in component
|
||||
assert "Why am I seeing this?" in component
|
||||
assert 'role="status"' in component and 'aria-live="polite"' in component
|
||||
assert 'role="alert"' in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert all(
|
||||
token not in client
|
||||
for token in ("localStorage", "sessionStorage", "indexedDB", "document.cookie")
|
||||
)
|
||||
assert 'credentials: "same-origin"' in client and 'cache: "no-store"' in client
|
||||
assert "seenClaims" in client and "activeClaims" in client
|
||||
assert "MUST NOT be accepted" in contracts and "single-use claim_id" in contracts
|
||||
assert "never_again" in contracts and "cooldown_seconds" in contracts
|
||||
assert ".hux-onboarding" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "focus-visible" in styles and "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_onboarding_sources_are_bounded_and_endpoint_only():
|
||||
"""The slice defines backend needs but cannot mutate runtime wiring."""
|
||||
|
||||
sources = sorted(ONBOARDING.glob("*"))
|
||||
assert {path.name for path in sources} == {
|
||||
"OnboardingSuggestions.tsx",
|
||||
"client.ts",
|
||||
"contracts.ts",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in sources:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
contracts = (ONBOARDING / "contracts.ts").read_text(encoding="utf-8")
|
||||
assert "POST /hux/v1/onboarding/evaluate" in contracts
|
||||
assert (
|
||||
"POST /hux/v1/onboarding/suggestions/{sug_id}/decisions" in contracts
|
||||
)
|
||||
assert "MUST NOT write suppression state" in contracts
|
||||
assert "A decision never performs" in contracts
|
||||
417
testing/tests/test_hermes_hux_ui_organization.mjs
Normal file
417
testing/tests/test_hermes_hux_ui_organization.mjs
Normal file
@ -0,0 +1,417 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createOrganizationClient,
|
||||
OrganizationContractError,
|
||||
} from "../../dockerfiles/hermes-webui-hux/organization/client.ts";
|
||||
import {
|
||||
FOUNDATION_FLAG,
|
||||
PROJECTS_FLAG,
|
||||
attachmentViews,
|
||||
branchLineage,
|
||||
normalizeConversation,
|
||||
normalizeProject,
|
||||
normalizeSnapshot,
|
||||
preservesConversation,
|
||||
projectsEnabled,
|
||||
} from "../../dockerfiles/hermes-webui-hux/organization/model.ts";
|
||||
import {
|
||||
boundedLimit,
|
||||
isOpaqueId,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
safeCursor,
|
||||
safeTags,
|
||||
safeText,
|
||||
sameIdentity,
|
||||
scopedPath,
|
||||
} from "../../dockerfiles/hermes-webui-hux/organization/security.ts";
|
||||
|
||||
const IDENTITY = Object.freeze({
|
||||
tenantRef: "tnt_0123456789abcdef",
|
||||
userRef: "usr_0123456789abcdef",
|
||||
surface: "chat",
|
||||
});
|
||||
const RAW_IDENTITY = Object.freeze({
|
||||
tenant_ref: IDENTITY.tenantRef,
|
||||
user_ref: IDENTITY.userRef,
|
||||
surface: IDENTITY.surface,
|
||||
});
|
||||
const PROJECT_ID = "prj_alpha1234";
|
||||
|
||||
function project(extra = {}) {
|
||||
return {
|
||||
schema: "hux.project.v1",
|
||||
id: PROJECT_ID,
|
||||
owner: IDENTITY.userRef,
|
||||
name: "Kitchen work",
|
||||
description: "Plans",
|
||||
tags: ["home"],
|
||||
pinned: true,
|
||||
archived: false,
|
||||
default_mode: "research",
|
||||
created_at: "2026-08-20T09:00:00Z",
|
||||
updated_at: "2026-08-24T09:00:00Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function conversation(extra = {}) {
|
||||
return {
|
||||
schema: "hux.conversation.v1",
|
||||
id: "conv_child1234",
|
||||
owner: IDENTITY.userRef,
|
||||
project_id: PROJECT_ID,
|
||||
title: "Compare suppliers",
|
||||
tags: ["suppliers"],
|
||||
pinned: false,
|
||||
archived: false,
|
||||
mode: "research",
|
||||
branch: {
|
||||
parent_conversation_id: "conv_parent1234",
|
||||
branch_point_message_id: "msg-12",
|
||||
},
|
||||
artifact_ids: ["art_quote1234", "art_plan1234"],
|
||||
last_message_at: "2026-08-24T09:00:01Z",
|
||||
created_at: "2026-08-20T09:00:00Z",
|
||||
updated_at: "2026-08-24T09:00:01Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(schema, items, extra = {}) {
|
||||
return {schema, api_version: "hux.v1", identity: RAW_IDENTITY,
|
||||
items, next_cursor: "next_page", total: items.length, ...extra};
|
||||
}
|
||||
|
||||
function mutation(schema, item, extra = {}) {
|
||||
return {schema, api_version: "hux.v1", identity: RAW_IDENTITY, item, ...extra};
|
||||
}
|
||||
|
||||
function harness(responses, flags = [FOUNDATION_FLAG, PROJECTS_FLAG]) {
|
||||
const calls = [];
|
||||
const enabled = new Set(flags);
|
||||
const foundation = {
|
||||
apiVersion: "hux.v1",
|
||||
identity: IDENTITY,
|
||||
enabled: (flag) => enabled.has(flag),
|
||||
endpoint: (path) => `/hux/v1${path}`,
|
||||
};
|
||||
const fetcher = async (url, init) => {
|
||||
calls.push({url, init});
|
||||
const response = responses.shift();
|
||||
if (!response) throw new Error("unexpected request");
|
||||
return {ok: response.ok ?? true, status: response.status ?? 200,
|
||||
json: async () => response.body};
|
||||
};
|
||||
return {calls, client: createOrganizationClient({client: foundation, fetcher}), foundation};
|
||||
}
|
||||
|
||||
test("HUX-03 is default-off and requires the foundation", () => {
|
||||
assert.equal(projectsEnabled(), false);
|
||||
assert.equal(projectsEnabled([]), false);
|
||||
assert.equal(projectsEnabled([PROJECTS_FLAG]), false);
|
||||
assert.equal(projectsEnabled([FOUNDATION_FLAG]), false);
|
||||
assert.equal(projectsEnabled([FOUNDATION_FLAG, PROJECTS_FLAG]), true);
|
||||
});
|
||||
|
||||
test("security primitives reject scope/path/query confusion", () => {
|
||||
assert.equal(isOpaqueId("conv_abcd", "conv"), true);
|
||||
assert.equal(isOpaqueId("prj_abcd", "conv"), false);
|
||||
assert.equal(isOpaqueId("bad"), false);
|
||||
assert.equal(isOpaqueId(4), false);
|
||||
assert.equal(isUtc("2026-08-24T09:00:00.123Z"), true);
|
||||
assert.equal(isUtc("2026-99-24T09:00:00Z"), false);
|
||||
assert.equal(isUtc(4), false);
|
||||
assert.equal(safeText({}, "fallback", 5), "fallback");
|
||||
assert.equal(safeText(" \u0000 ", "fallback", 5), "fallback");
|
||||
assert.equal(safeText("abcdef", "fallback", 4), "abc…");
|
||||
assert.equal(safeText("okay", "fallback", 8), "okay");
|
||||
assert.deepEqual(safeTags(["one", "two-2"]), ["one", "two-2"]);
|
||||
assert.equal(safeTags("one"), null);
|
||||
assert.equal(safeTags(Array(33).fill("x")), null);
|
||||
assert.equal(safeTags(["Bad"]), null);
|
||||
assert.equal(safeTags(["same", "same"]), null);
|
||||
assert.equal(safeTags([4]), null);
|
||||
assert.equal(safeCursor(undefined), null);
|
||||
assert.equal(safeCursor(""), null);
|
||||
assert.equal(safeCursor("next_page"), "next_page");
|
||||
assert.equal(safeCursor("bad/cursor"), undefined);
|
||||
assert.equal(boundedLimit(22), 22);
|
||||
assert.equal(boundedLimit(200), 100);
|
||||
assert.equal(boundedLimit(0), 40);
|
||||
assert.equal(boundedLimit(undefined, 20), 20);
|
||||
assert.equal(scopedPath("/projects/", PROJECT_ID), `/projects/${PROJECT_ID}`);
|
||||
for (const base of ["https://evil", "//evil", "/hux?x", "/hux\\x", "/../x"]) {
|
||||
assert.throws(() => scopedPath(base, PROJECT_ID), /same-origin/);
|
||||
}
|
||||
assert.throws(() => scopedPath("/projects", "../bad"), /identifier/);
|
||||
});
|
||||
|
||||
test("identity normalization is exact and includes the current surface", () => {
|
||||
const normalized = normalizeIdentity(RAW_IDENTITY);
|
||||
assert.deepEqual(normalized, IDENTITY);
|
||||
assert.equal(sameIdentity(normalized, IDENTITY), true);
|
||||
assert.equal(sameIdentity(normalized, {...IDENTITY, surface: "worker"}), false);
|
||||
assert.equal(sameIdentity(normalized, {...IDENTITY, userRef: "usr_aaaaaaaaaaaaaaaa"}), false);
|
||||
assert.equal(sameIdentity(normalized, {...IDENTITY, tenantRef: "tnt_aaaaaaaaaaaaaaaa"}), false);
|
||||
assert.equal(normalizeIdentity(null), null);
|
||||
assert.equal(normalizeIdentity({...RAW_IDENTITY, tenant_ref: "raw"}), null);
|
||||
assert.equal(normalizeIdentity({...RAW_IDENTITY, user_ref: "raw"}), null);
|
||||
assert.equal(normalizeIdentity({...RAW_IDENTITY, surface: "browser"}), null);
|
||||
});
|
||||
|
||||
test("project normalization follows project.schema and owner binding", () => {
|
||||
const item = normalizeProject(project(), IDENTITY.userRef);
|
||||
assert.equal(item.name, "Kitchen work");
|
||||
assert.equal(item.description, "Plans");
|
||||
const invalid = [
|
||||
{schema: "old"}, {id: "bad"}, {owner: "usr_aaaaaaaaaaaaaaaa"},
|
||||
{tags: ["Bad"]}, {pinned: "yes"}, {archived: 0}, {created_at: "today"},
|
||||
{updated_at: "today"}, {default_mode: "turbo"}, {default_mode: 7},
|
||||
{name: ""},
|
||||
];
|
||||
invalid.forEach((change) => assert.equal(normalizeProject(project(change), IDENTITY.userRef), null));
|
||||
assert.equal(normalizeProject(project({description: {}}), IDENTITY.userRef).description, "");
|
||||
assert.equal(normalizeProject(project({default_mode: undefined}), IDENTITY.userRef).id, PROJECT_ID);
|
||||
});
|
||||
|
||||
test("conversation normalization binds project, lineage, and attachments", () => {
|
||||
const projects = new Set([PROJECT_ID]);
|
||||
const item = normalizeConversation(conversation(), IDENTITY.userRef, projects);
|
||||
assert.deepEqual(item.artifactIds, ["art_quote1234", "art_plan1234"]);
|
||||
assert.equal(item.branch.parentConversationId, "conv_parent1234");
|
||||
assert.equal(item.lastMessageAt, "2026-08-24T09:00:01Z");
|
||||
assert.equal(normalizeConversation(conversation({project_id: undefined}), IDENTITY.userRef, projects).projectId, null);
|
||||
assert.equal(normalizeConversation(conversation({branch: undefined}), IDENTITY.userRef, projects).branch, null);
|
||||
assert.equal(normalizeConversation(conversation({last_message_at: undefined, mode: undefined}),
|
||||
IDENTITY.userRef, projects).lastMessageAt, null);
|
||||
const invalid = [
|
||||
{schema: "old"}, {id: "bad"}, {owner: "usr_aaaaaaaaaaaaaaaa"}, {tags: ["Bad"]},
|
||||
{branch: null}, {branch: []}, {branch: {parent_conversation_id: "bad", branch_point_message_id: "x"}},
|
||||
{branch: {parent_conversation_id: "conv_parent1234", branch_point_message_id: 3}},
|
||||
{branch: {parent_conversation_id: "conv_parent1234", branch_point_message_id: ""}},
|
||||
{branch: {parent_conversation_id: "conv_child1234", branch_point_message_id: "x"}},
|
||||
{pinned: "no"}, {archived: "no"}, {artifact_ids: null},
|
||||
{artifact_ids: ["bad"]}, {artifact_ids: ["art_same1234", "art_same1234"]},
|
||||
{artifact_ids: Array.from({length: 501}, (_, index) => `art_${String(index).padStart(4, "0")}`)},
|
||||
{created_at: "today"}, {updated_at: "today"}, {last_message_at: "today"},
|
||||
{mode: "turbo"}, {mode: 7}, {project_id: "prj_other1234"}, {project_id: "bad"},
|
||||
{title: ""},
|
||||
];
|
||||
invalid.forEach((change) => assert.equal(
|
||||
normalizeConversation(conversation(change), IDENTITY.userRef, projects), null));
|
||||
});
|
||||
|
||||
test("snapshot collections are bounded and cross-scope items are counted", () => {
|
||||
const projects = Array.from({length: 101}, (_, index) => project({id: `prj_${String(index).padStart(4, "0")}`}));
|
||||
const conversations = Array.from({length: 501}, (_, index) => conversation({
|
||||
id: `conv_${String(index).padStart(4, "0")}`,
|
||||
project_id: index === 500 ? "prj_0100" : `prj_${String(index % 100).padStart(4, "0")}`,
|
||||
branch: undefined,
|
||||
}));
|
||||
const result = normalizeSnapshot(projects, conversations, IDENTITY.userRef);
|
||||
assert.equal(result.projects.length, 100);
|
||||
assert.equal(result.conversations.length, 500);
|
||||
assert.equal(result.rejected, 2);
|
||||
const rejected = normalizeSnapshot([project({owner: "usr_aaaaaaaaaaaaaaaa"})],
|
||||
[conversation({owner: "usr_aaaaaaaaaaaaaaaa"})], IDENTITY.userRef);
|
||||
assert.equal(rejected.rejected, 2);
|
||||
const duplicated = normalizeSnapshot([project(), project()], [conversation(), conversation()], IDENTITY.userRef);
|
||||
assert.equal(duplicated.projects.length, 1);
|
||||
assert.equal(duplicated.conversations.length, 1);
|
||||
assert.equal(duplicated.rejected, 2);
|
||||
});
|
||||
|
||||
test("attachments never disappear when their metadata is unavailable", () => {
|
||||
const item = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID]));
|
||||
const views = attachmentViews(item, [
|
||||
{id: "art_quote1234", title: " Quote \u0000", kind: "document"},
|
||||
{id: "bad", title: "Bad", kind: "bad"},
|
||||
]);
|
||||
assert.equal(views.length, item.artifactIds.length);
|
||||
assert.deepEqual(views.map((view) => view.missing), [false, true]);
|
||||
assert.equal(views[0].title, "Quote");
|
||||
assert.equal(views[1].title, "Attachment details unavailable");
|
||||
assert.equal(attachmentViews(item, [{id: "art_quote1234", title: {}, kind: {}}])[0].title,
|
||||
"Untitled artifact");
|
||||
});
|
||||
|
||||
test("lineage is visualizable even when an ancestor is absent or cyclic", () => {
|
||||
const child = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID]));
|
||||
const parent = normalizeConversation(conversation({id: "conv_parent1234", title: "Root",
|
||||
branch: undefined}), IDENTITY.userRef, new Set([PROJECT_ID]));
|
||||
const complete = branchLineage(child.id, [child, parent]);
|
||||
assert.deepEqual(complete.map((node) => node.title), ["Root", "Compare suppliers"]);
|
||||
assert.deepEqual(complete.map((node) => node.depth), [0, 1]);
|
||||
const missing = branchLineage(child.id, [child]);
|
||||
assert.equal(missing[0].missing, true);
|
||||
assert.equal(missing[1].branchPointMessageId, "msg-12");
|
||||
const cyclicParent = {...parent, branch: {parentConversationId: child.id,
|
||||
branchPointMessageId: "msg-cycle"}};
|
||||
const cycle = branchLineage(child.id, [child, cyclicParent]);
|
||||
assert.equal(cycle[0].cycle, true);
|
||||
assert.deepEqual(branchLineage("bad", [child]), []);
|
||||
});
|
||||
|
||||
test("moves preserve stable resume identity, branch, and ordered attachments", () => {
|
||||
const before = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID]));
|
||||
assert.equal(preservesConversation(before, {...before, projectId: null, title: "Renamed"}), true);
|
||||
assert.equal(preservesConversation(before, {...before, id: "conv_other1234"}), false);
|
||||
assert.equal(preservesConversation(before, {...before, owner: "usr_aaaaaaaaaaaaaaaa"}), false);
|
||||
assert.equal(preservesConversation(before, {...before, branch: null}), false);
|
||||
assert.equal(preservesConversation(before, {...before, artifactIds: ["art_quote1234"]}), false);
|
||||
assert.equal(preservesConversation(before, {...before,
|
||||
artifactIds: [...before.artifactIds].reverse()}), false);
|
||||
});
|
||||
|
||||
test("list and search calls use bounded encoded same-origin queries", async () => {
|
||||
const responses = [
|
||||
{body: envelope("hux.projects.page.v1", [project()])},
|
||||
{body: envelope("hux.conversations.page.v1", [conversation()])},
|
||||
{body: envelope("hux.conversations.search.v1", [conversation()], {next_cursor: null})},
|
||||
];
|
||||
const {client, calls} = harness(responses);
|
||||
const projects = await client.listProjects("cursor_1", 500);
|
||||
assert.equal(projects.items[0].id, PROJECT_ID);
|
||||
assert.equal(projects.nextCursor, "next_page");
|
||||
assert.equal(projects.total, 1);
|
||||
const ids = new Set([PROJECT_ID]);
|
||||
await client.listConversations(PROJECT_ID, ids, null, 12);
|
||||
await client.searchConversations({query: " cabinets & quotes ", projectId: PROJECT_ID,
|
||||
tags: ["home", "suppliers"], pinned: false, limit: 20}, ids);
|
||||
assert.match(calls[0].url, /^\/hux\/v1\/projects\?cursor=cursor_1&limit=100$/);
|
||||
assert.match(calls[1].url, /\/projects\/prj_alpha1234\/conversations\?limit=12$/);
|
||||
assert.match(calls[2].url, /q=cabinets\+%26\+quotes/);
|
||||
assert.match(calls[2].url, /project_id=prj_alpha1234/);
|
||||
assert.match(calls[2].url, /tag=home&tag=suppliers/);
|
||||
assert.match(calls[2].url, /pinned=false/);
|
||||
calls.forEach(({init}) => {
|
||||
assert.equal(init.credentials, "same-origin");
|
||||
assert.equal(init.cache, "no-store");
|
||||
assert.equal(init.headers.Accept, "application/vnd.hermes.hux+json; version=1");
|
||||
});
|
||||
});
|
||||
|
||||
test("transport rejects invalid capability, identity, pagination, records, and boundaries", async () => {
|
||||
const disabled = harness([{body: {}}], [FOUNDATION_FLAG]);
|
||||
await assert.rejects(disabled.client.listProjects(), /not enabled/);
|
||||
disabled.foundation.apiVersion = "hux.v0";
|
||||
await assert.rejects(disabled.client.listProjects(), /not enabled/);
|
||||
assert.throws(() => createOrganizationClient({client: null, fetcher: async () => ({})}), /requires/);
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = undefined;
|
||||
assert.throws(() => createOrganizationClient({client: disabled.foundation}), /requires/);
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
|
||||
const failures = [
|
||||
{body: null},
|
||||
{body: []},
|
||||
{body: envelope("wrong", [])},
|
||||
{body: envelope("hux.projects.page.v1", [], {api_version: "hux.v0"})},
|
||||
{body: envelope("hux.projects.page.v1", [], {identity: {...RAW_IDENTITY, surface: "worker"}})},
|
||||
{body: envelope("hux.projects.page.v1", [], {next_cursor: "bad/cursor"})},
|
||||
{body: envelope("hux.projects.page.v1", [], {total: -1})},
|
||||
{body: envelope("hux.projects.page.v1", Array(101).fill(project()))},
|
||||
{body: envelope("hux.projects.page.v1", [project({id: "bad"})])},
|
||||
{body: envelope("hux.projects.page.v1", [project(), project()])},
|
||||
];
|
||||
for (const response of failures) {
|
||||
const current = harness([response]);
|
||||
await assert.rejects(current.client.listProjects(), OrganizationContractError);
|
||||
}
|
||||
const http = harness([{ok: false, status: 503}]);
|
||||
await assert.rejects(http.client.listProjects(), /failed \(503\)/);
|
||||
const conflict = harness([{ok: false, status: 409}]);
|
||||
await assert.rejects(conflict.client.listProjects(), /changed/);
|
||||
const badCursor = harness([]);
|
||||
await assert.rejects(badCursor.client.listProjects("bad/cursor"), /cursor/);
|
||||
await assert.rejects(badCursor.client.listConversations("prj_other1234",
|
||||
new Set([PROJECT_ID])), /outside/);
|
||||
await assert.rejects(badCursor.client.searchConversations({query: "", limit: 1}, new Set()), /1 to 200/);
|
||||
await assert.rejects(badCursor.client.searchConversations({query: "x".repeat(201)}, new Set()), /1 to 200/);
|
||||
await assert.rejects(badCursor.client.searchConversations({query: "x", projectId: "prj_other1234"},
|
||||
new Set([PROJECT_ID])), /outside/);
|
||||
await assert.rejects(badCursor.client.searchConversations({query: "x", tags: ["Bad"]},
|
||||
new Set()), /tags/);
|
||||
});
|
||||
|
||||
test("conversation pages cannot cross their requested project", async () => {
|
||||
const ids = new Set([PROJECT_ID, "prj_other1234"]);
|
||||
const wrong = harness([{body: envelope("hux.conversations.page.v1",
|
||||
[conversation({project_id: "prj_other1234"})])}]);
|
||||
await assert.rejects(wrong.client.listConversations(PROJECT_ID, ids), /project boundary/);
|
||||
const invalid = harness([{body: envelope("hux.conversations.search.v1", [conversation({id: "bad"})])}]);
|
||||
await assert.rejects(invalid.client.searchConversations({query: "x"}, ids), /Invalid conversation/);
|
||||
const oversized = harness([{body: envelope("hux.conversations.search.v1", Array(101).fill(conversation()))}]);
|
||||
await assert.rejects(oversized.client.searchConversations({query: "x"}, ids), /exceeds/);
|
||||
const duplicate = harness([{body: envelope("hux.conversations.search.v1", [conversation(), conversation()])}]);
|
||||
await assert.rejects(duplicate.client.searchConversations({query: "x"}, ids), /Invalid conversation/);
|
||||
});
|
||||
|
||||
test("project mutations send only scoped fields and verify stable identity", async () => {
|
||||
const before = normalizeProject(project(), IDENTITY.userRef);
|
||||
const changed = project({name: "New name", tags: ["new"], pinned: false,
|
||||
updated_at: "2026-08-24T10:00:00Z"});
|
||||
const {client, calls} = harness([{body: mutation("hux.project.response.v1", changed)}]);
|
||||
const after = await client.updateProject(before, {name: " New name ", tags: ["new"], pinned: false,
|
||||
expectedUpdatedAt: before.updatedAt});
|
||||
assert.equal(after.name, "New name");
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.deepEqual(body, {expected_updated_at: before.updatedAt, name: "New name", tags: ["new"], pinned: false});
|
||||
assert.equal("tenant_ref" in body, false);
|
||||
assert.equal(calls[0].init.method, "PATCH");
|
||||
for (const [patch, message] of [
|
||||
[{expectedUpdatedAt: "bad"}, /outside/],
|
||||
[{expectedUpdatedAt: before.updatedAt, name: ""}, /invalid/],
|
||||
[{expectedUpdatedAt: before.updatedAt, tags: ["Bad"]}, /invalid/],
|
||||
]) {
|
||||
await assert.rejects(client.updateProject(before, patch), message);
|
||||
}
|
||||
await assert.rejects(client.updateProject({...before, owner: "usr_aaaaaaaaaaaaaaaa"},
|
||||
{expectedUpdatedAt: before.updatedAt}), /outside/);
|
||||
const noItem = harness([{body: mutation("hux.project.response.v1", changed, {item: undefined})}]);
|
||||
await assert.rejects(noItem.client.updateProject(before,
|
||||
{expectedUpdatedAt: before.updatedAt}), /no item/);
|
||||
const changedId = harness([{body: mutation("hux.project.response.v1", project({id: "prj_other1234"}))}]);
|
||||
await assert.rejects(changedId.client.updateProject(before,
|
||||
{expectedUpdatedAt: before.updatedAt}), /identity changed/);
|
||||
});
|
||||
|
||||
test("conversation mutation proves atomic move invariants", async () => {
|
||||
const ids = new Set([PROJECT_ID, "prj_other1234"]);
|
||||
const before = normalizeConversation(conversation(), IDENTITY.userRef, ids);
|
||||
const moved = conversation({project_id: "prj_other1234", title: "Renamed", tags: ["new"], pinned: true,
|
||||
updated_at: "2026-08-24T10:00:00Z"});
|
||||
const {client, calls} = harness([{body: mutation("hux.conversation.response.v1", moved)}]);
|
||||
const after = await client.updateConversation(before, {projectId: "prj_other1234", title: " Renamed ",
|
||||
tags: ["new"], pinned: true, expectedUpdatedAt: before.updatedAt}, ids);
|
||||
assert.equal(after.projectId, "prj_other1234");
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {expected_updated_at: before.updatedAt,
|
||||
title: "Renamed", project_id: "prj_other1234", tags: ["new"], pinned: true});
|
||||
for (const [candidate, error] of [
|
||||
[{...moved, id: "conv_other1234"}, /lineage/],
|
||||
[{...moved, branch: undefined}, /lineage/],
|
||||
[{...moved, artifact_ids: ["art_quote1234"]}, /lineage/],
|
||||
[{...moved, owner: "usr_aaaaaaaaaaaaaaaa"}, /lineage/],
|
||||
]) {
|
||||
const current = harness([{body: mutation("hux.conversation.response.v1", candidate)}]);
|
||||
await assert.rejects(current.client.updateConversation(before,
|
||||
{expectedUpdatedAt: before.updatedAt}, ids), error);
|
||||
}
|
||||
const invalid = harness([]);
|
||||
await assert.rejects(invalid.client.updateConversation({...before, owner: "usr_aaaaaaaaaaaaaaaa"},
|
||||
{expectedUpdatedAt: before.updatedAt}, ids), /outside/);
|
||||
await assert.rejects(invalid.client.updateConversation({...before, projectId: "prj_missing1234"},
|
||||
{expectedUpdatedAt: before.updatedAt}, ids), /outside/);
|
||||
await assert.rejects(invalid.client.updateConversation(before,
|
||||
{expectedUpdatedAt: "bad"}, ids), /outside/);
|
||||
await assert.rejects(invalid.client.updateConversation(before,
|
||||
{expectedUpdatedAt: before.updatedAt, projectId: "prj_missing1234"}, ids), /outside/);
|
||||
await assert.rejects(invalid.client.updateConversation(before,
|
||||
{expectedUpdatedAt: before.updatedAt, title: ""}, ids), /invalid/);
|
||||
await assert.rejects(invalid.client.updateConversation(before,
|
||||
{expectedUpdatedAt: before.updatedAt, tags: ["Bad"]}, ids), /invalid/);
|
||||
});
|
||||
99
testing/tests/test_hermes_hux_ui_organization.py
Normal file
99
testing/tests/test_hermes_hux_ui_organization.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""Executable and static quality gates for the isolated HUX-03 frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ORGANIZATION = ROOT / "dockerfiles" / "hermes-webui-hux" / "organization"
|
||||
|
||||
|
||||
def test_hux_organization_node_suite_and_line_branch_coverage():
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-branches=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/organization/client.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/organization/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/organization/security.ts",
|
||||
"testing/tests/test_hermes_hux_ui_organization.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_organization_component_is_flagged_accessible_and_scope_safe():
|
||||
component = (ORGANIZATION / "OrganizationWorkspace.tsx").read_text(encoding="utf-8")
|
||||
model = (ORGANIZATION / "model.ts").read_text(encoding="utf-8")
|
||||
client = (ORGANIZATION / "client.ts").read_text(encoding="utf-8")
|
||||
contracts = (ORGANIZATION / "contracts.ts").read_text(encoding="utf-8")
|
||||
styles = (ORGANIZATION / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled) return null" in component
|
||||
assert "projectsEnabled(props.flags)" in component
|
||||
assert "FOUNDATION_FLAG" in model and "PROJECTS_FLAG" in model
|
||||
assert 'role="search"' in component
|
||||
assert 'role="status"' in component and 'aria-live="polite"' in component
|
||||
assert 'aria-label="Project folders"' in component
|
||||
assert 'aria-label="Conversations"' in component
|
||||
assert 'aria-labelledby="hux-org-lineage-title"' in component
|
||||
assert 'aria-labelledby="hux-org-attachments-title"' in component
|
||||
assert "Edit tags" in component and 'role="alert"' in component
|
||||
assert "encodeURIComponent(conversation.id)" in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert all(token not in client for token in ("localStorage", "sessionStorage", "indexedDB", "document.cookie"))
|
||||
assert 'credentials: "same-origin"' in client and 'cache: "no-store"' in client
|
||||
assert "expected_updated_at" in client and "preservesConversation" in client
|
||||
assert "tenant_ref" in contracts and "MUST NOT be accepted" in contracts
|
||||
assert ".hux-organization" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_organization_sources_respect_size_and_scope_boundaries():
|
||||
sources = sorted(ORGANIZATION.glob("*"))
|
||||
assert {path.name for path in sources} == {
|
||||
"OrganizationWorkspace.tsx",
|
||||
"client.ts",
|
||||
"contracts.ts",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in sources:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
assert "dockerfiles/hermes-webui-hux/organization" not in (
|
||||
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_hux_organization_declares_exact_backend_contracts():
|
||||
contracts = (ORGANIZATION / "contracts.ts").read_text(encoding="utf-8")
|
||||
expected = (
|
||||
"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}",
|
||||
'api_version="hux.v1"',
|
||||
"next_cursor",
|
||||
"expected_updated_at",
|
||||
"branch",
|
||||
"artifact_ids",
|
||||
"409",
|
||||
)
|
||||
for value in expected:
|
||||
assert value in contracts
|
||||
312
testing/tests/test_hermes_hux_ui_privacy.mjs
Normal file
312
testing/tests/test_hermes_hux_ui_privacy.mjs
Normal file
@ -0,0 +1,312 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createPrivacyClient,
|
||||
PrivacyContractError,
|
||||
} from "../../dockerfiles/hermes-webui-hux/privacy/client.ts";
|
||||
import {
|
||||
FOUNDATION_FLAG,
|
||||
GENERIC_NOTICE,
|
||||
PRIVACY_FLAG,
|
||||
normalizeNotice,
|
||||
normalizeSnapshot,
|
||||
privacyControls,
|
||||
privacyEnabled,
|
||||
withoutNotice,
|
||||
} from "../../dockerfiles/hermes-webui-hux/privacy/model.ts";
|
||||
import {
|
||||
asRecord,
|
||||
exactKeys,
|
||||
isId,
|
||||
isSafeText,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
normalizeScope,
|
||||
privacyQuery,
|
||||
sameIdentity,
|
||||
} from "../../dockerfiles/hermes-webui-hux/privacy/security.ts";
|
||||
|
||||
const IDENTITY = Object.freeze({
|
||||
tenantRef: "tnt_0123456789abcdef",
|
||||
userRef: "usr_0123456789abcdef",
|
||||
surface: "chat",
|
||||
});
|
||||
const RAW_IDENTITY = Object.freeze({
|
||||
tenant_ref: IDENTITY.tenantRef,
|
||||
user_ref: IDENTITY.userRef,
|
||||
surface: IDENTITY.surface,
|
||||
});
|
||||
const SCOPE = Object.freeze({
|
||||
sessionId: "ses_alpha1234",
|
||||
conversationId: "conv_alpha1234",
|
||||
});
|
||||
const SHOWN_AT = "2026-08-24T10:00:00Z";
|
||||
|
||||
function notice(extra = {}) {
|
||||
return {
|
||||
schema: "hux.privacy_notice.v1",
|
||||
topic: "health",
|
||||
conversation_id: SCOPE.conversationId,
|
||||
text: "This looks like a health topic. It stays in this conversation.",
|
||||
controls: [
|
||||
"forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss",
|
||||
],
|
||||
shown_at: SHOWN_AT,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function retention(extra = {}) {
|
||||
return {
|
||||
scope: "conversation",
|
||||
memory_write: "ask",
|
||||
topic_context_decay_days: 30,
|
||||
audit_interval_days: 1,
|
||||
next_audit_at: "2026-08-25T03:00:00Z",
|
||||
cross_surface_sharing: "never",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(extra = {}) {
|
||||
return {
|
||||
schema: "hux.privacy_snapshot.v1",
|
||||
api_version: "hux.v1",
|
||||
identity: RAW_IDENTITY,
|
||||
session_id: SCOPE.sessionId,
|
||||
conversation_id: SCOPE.conversationId,
|
||||
revision: 1,
|
||||
no_store: false,
|
||||
memory_enabled: true,
|
||||
retention: retention(),
|
||||
notice: notice(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function noStore(extra = {}) {
|
||||
return snapshot({
|
||||
revision: 2,
|
||||
no_store: true,
|
||||
memory_enabled: false,
|
||||
retention: retention({scope: "session", memory_write: "deny",
|
||||
topic_context_decay_days: null}),
|
||||
notice: null,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function memoryOff(extra = {}) {
|
||||
return snapshot({
|
||||
revision: 2,
|
||||
memory_enabled: false,
|
||||
retention: retention({memory_write: "deny", topic_context_decay_days: null}),
|
||||
notice: null,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function harness(responses, flags = [FOUNDATION_FLAG, PRIVACY_FLAG]) {
|
||||
const calls = [];
|
||||
const enabled = new Set(flags);
|
||||
const foundation = {
|
||||
apiVersion: "hux.v1",
|
||||
identity: IDENTITY,
|
||||
enabled: (flag) => enabled.has(flag),
|
||||
endpoint: (path) => `/hux/v1${path}`,
|
||||
};
|
||||
const fetcher = async (url, init) => {
|
||||
calls.push({url, init});
|
||||
const response = responses.shift();
|
||||
if (!response) throw new Error("unexpected request");
|
||||
return {ok: response.ok ?? true, status: response.status ?? 200,
|
||||
json: async () => response.body};
|
||||
};
|
||||
return {calls, foundation,
|
||||
client: createPrivacyClient({client: foundation, fetcher})};
|
||||
}
|
||||
|
||||
test("HUX-10 is default-off and requires the foundation", () => {
|
||||
assert.equal(privacyEnabled(), false);
|
||||
assert.equal(privacyEnabled([]), false);
|
||||
assert.equal(privacyEnabled([FOUNDATION_FLAG]), false);
|
||||
assert.equal(privacyEnabled([PRIVACY_FLAG]), false);
|
||||
assert.equal(privacyEnabled([FOUNDATION_FLAG, PRIVACY_FLAG]), true);
|
||||
});
|
||||
|
||||
test("security primitives bind opaque scope and same-origin query", () => {
|
||||
assert.deepEqual(asRecord({a: 1}), {a: 1});
|
||||
assert.equal(asRecord(null), null);
|
||||
assert.equal(asRecord([]), null);
|
||||
assert.equal(exactKeys({a: 1, b: 2}, ["a"], ["b"]), true);
|
||||
assert.equal(exactKeys({a: 1, x: 2}, ["a"]), false);
|
||||
assert.equal(isId("conv_alpha1234", "conv"), true);
|
||||
assert.equal(isId("bad", "conv"), false);
|
||||
assert.equal(isId("ses_alpha1234", "conv"), false);
|
||||
assert.equal(isUtc("2026-08-24T10:00:00Z"), true);
|
||||
assert.equal(isUtc("2026-99-99T10:00:00Z"), false);
|
||||
assert.equal(isSafeText("safe", 1, 20), true);
|
||||
assert.equal(isSafeText("bad\u0000", 1, 20), false);
|
||||
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
|
||||
for (const raw of [null, [], {}, {...RAW_IDENTITY, extra: true},
|
||||
{...RAW_IDENTITY, tenant_ref: "bad"}, {...RAW_IDENTITY, user_ref: "bad"},
|
||||
{...RAW_IDENTITY, surface: "browser"}]) assert.equal(normalizeIdentity(raw), null);
|
||||
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
|
||||
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
|
||||
assert.deepEqual(normalizeScope(SCOPE), SCOPE);
|
||||
for (const raw of [null, {...SCOPE, extra: true}, {...SCOPE, sessionId: "bad"},
|
||||
{...SCOPE, conversationId: "bad"}]) assert.equal(normalizeScope(raw), null);
|
||||
assert.equal(privacyQuery(SCOPE),
|
||||
"/privacy/snapshot?session_id=ses_alpha1234&conversation_id=conv_alpha1234");
|
||||
assert.throws(() => privacyQuery({...SCOPE, sessionId: "bad"}), TypeError);
|
||||
});
|
||||
|
||||
test("notice validation drops category and source text from view state", () => {
|
||||
const item = normalizeNotice(notice(), SCOPE.conversationId, "ask", 30);
|
||||
assert.equal(item.text, GENERIC_NOTICE);
|
||||
assert.equal("topic" in item, false);
|
||||
assert.deepEqual(item.controls, notice().controls);
|
||||
const restricted = normalizeNotice(notice({topic: "credentials"}),
|
||||
SCOPE.conversationId, "deny", 1);
|
||||
assert.equal(restricted.text, GENERIC_NOTICE);
|
||||
const invalid = [null, {...notice(), extra: true}, {...notice(), schema: "old"},
|
||||
{...notice(), topic: "politics"}, {...notice(), conversation_id: "conv_other1234"},
|
||||
{...notice(), text: ""}, {...notice(), text: "bad\u0000"},
|
||||
{...notice(), shown_at: "today"}, {...notice(), controls: null},
|
||||
{...notice(), controls: []}, {...notice(), controls: [...notice().controls, "dismiss"]},
|
||||
{...notice(), controls: ["unknown"]}];
|
||||
invalid.forEach((raw) => assert.equal(
|
||||
normalizeNotice(raw, SCOPE.conversationId, "ask", 30), null));
|
||||
assert.equal(normalizeNotice(notice(), SCOPE.conversationId, "deny", 30), null);
|
||||
assert.equal(normalizeNotice(notice(), SCOPE.conversationId, "ask", 1), null);
|
||||
});
|
||||
|
||||
test("snapshot exposes generic retention while enforcing policy semantics", () => {
|
||||
const item = normalizeSnapshot(snapshot(), IDENTITY, SCOPE);
|
||||
assert.equal(item.notice.text, GENERIC_NOTICE);
|
||||
assert.equal(item.retention.topicContextDecayDays, 30);
|
||||
const privateItem = normalizeSnapshot(noStore(), IDENTITY, SCOPE);
|
||||
assert.equal(privateItem.noStore, true);
|
||||
assert.equal(privateItem.retention.scope, "session");
|
||||
assert.equal(normalizeSnapshot(memoryOff(), IDENTITY, SCOPE).memoryEnabled, false);
|
||||
const restricted = normalizeSnapshot(snapshot({memory_enabled: false,
|
||||
retention: retention({memory_write: "deny", topic_context_decay_days: 1}),
|
||||
notice: notice({topic: "credentials"})}), IDENTITY, SCOPE);
|
||||
assert.equal(restricted.notice.text, GENERIC_NOTICE);
|
||||
const changedTopic = normalizeSnapshot(snapshot({notice: null}), IDENTITY, SCOPE);
|
||||
assert.equal(changedTopic.notice, null);
|
||||
});
|
||||
|
||||
test("snapshot rejects malformed scope, retention, no-store and notice state", () => {
|
||||
const invalid = [null, {...snapshot(), extra: true}, {...snapshot(), schema: "old"},
|
||||
{...snapshot(), api_version: "hux.v0"},
|
||||
{...snapshot(), identity: {...RAW_IDENTITY, surface: "worker"}},
|
||||
{...snapshot(), session_id: "ses_other1234"},
|
||||
{...snapshot(), conversation_id: "conv_other1234"},
|
||||
{...snapshot(), revision: 0}, {...snapshot(), no_store: "no"},
|
||||
{...snapshot(), memory_enabled: "yes"}, {...snapshot(), retention: null},
|
||||
{...snapshot(), retention: {...retention(), extra: true}},
|
||||
{...snapshot(), retention: retention({scope: "forever"})},
|
||||
{...snapshot(), retention: retention({memory_write: "automatic"})},
|
||||
{...snapshot(), retention: retention({topic_context_decay_days: 0})},
|
||||
{...snapshot(), retention: retention({audit_interval_days: 31})},
|
||||
{...snapshot(), retention: retention({next_audit_at: "tomorrow"})},
|
||||
{...snapshot(), retention: retention({cross_surface_sharing: "everyone"})},
|
||||
{...snapshot(), no_store: true},
|
||||
{...noStore(), retention: retention({scope: "session", memory_write: "deny",
|
||||
topic_context_decay_days: 1})},
|
||||
{...noStore(), memory_enabled: true},
|
||||
{...snapshot(), memory_enabled: false},
|
||||
{...snapshot(), notice: notice({topic: "credentials"})},
|
||||
];
|
||||
invalid.forEach((raw) => assert.equal(normalizeSnapshot(raw, IDENTITY, SCOPE), null));
|
||||
});
|
||||
|
||||
test("view helpers suppress repeats and expose only effective controls", () => {
|
||||
const item = normalizeSnapshot(snapshot(), IDENTITY, SCOPE);
|
||||
assert.equal(withoutNotice(item).notice, null);
|
||||
const changed = withoutNotice({...item, notice: null});
|
||||
assert.equal(changed.notice, null);
|
||||
assert.deepEqual(privacyControls(item), [
|
||||
"enable_no_store", "disable_memory_here", "forget_this_conversation",
|
||||
"switch_to_private", "dismiss",
|
||||
]);
|
||||
assert.deepEqual(privacyControls(normalizeSnapshot(noStore(), IDENTITY, SCOPE)),
|
||||
["disable_no_store"]);
|
||||
});
|
||||
|
||||
test("client loads scoped no-cache state and does not reiterate notices", async () => {
|
||||
const current = harness([{body: snapshot()}, {body: snapshot()},
|
||||
{body: snapshot({revision: 2, notice: null})},
|
||||
{body: snapshot({revision: 3, notice: notice({topic: "finance",
|
||||
shown_at: "2026-08-24T11:00:00Z"})})}]);
|
||||
assert.equal(current.client.enabled(), true);
|
||||
const first = await current.client.load(SCOPE);
|
||||
assert.ok(first.notice);
|
||||
assert.equal((await current.client.load(SCOPE)).notice, null);
|
||||
assert.equal((await current.client.load(SCOPE)).notice, null);
|
||||
assert.ok((await current.client.load(SCOPE)).notice);
|
||||
assert.equal(current.calls[0].url,
|
||||
"/hux/v1/privacy/snapshot?session_id=ses_alpha1234&conversation_id=conv_alpha1234");
|
||||
assert.equal(current.calls[0].init.method, "GET");
|
||||
assert.equal(current.calls[0].init.cache, "no-store");
|
||||
assert.equal(current.calls[0].init.credentials, "same-origin");
|
||||
});
|
||||
|
||||
test("client applies each explicit control with optimistic scope binding", async () => {
|
||||
const cases = [
|
||||
["enable_no_store", noStore()],
|
||||
["switch_to_private", noStore()],
|
||||
["forget_this_conversation", noStore()],
|
||||
["disable_memory_here", memoryOff()],
|
||||
["dismiss", snapshot({revision: 2, notice: null})],
|
||||
];
|
||||
for (const [action, result] of cases) {
|
||||
const current = harness([{body: snapshot()}, {body: result}]);
|
||||
const before = await current.client.load(SCOPE);
|
||||
const after = await current.client.control(before, action);
|
||||
assert.equal(after.revision, 2);
|
||||
assert.equal(current.calls[1].url, "/hux/v1/privacy/controls");
|
||||
assert.deepEqual(JSON.parse(current.calls[1].init.body), {
|
||||
schema: "hux.privacy_control_request.v1", session_id: SCOPE.sessionId,
|
||||
conversation_id: SCOPE.conversationId, expected_revision: 1, action,
|
||||
});
|
||||
await assert.rejects(current.client.control(before, action), /not bound/);
|
||||
}
|
||||
const current = harness([{body: noStore({revision: 1})},
|
||||
{body: snapshot({revision: 2, notice: null})}]);
|
||||
const before = await current.client.load(SCOPE);
|
||||
assert.equal((await current.client.control(before, "disable_no_store")).noStore, false);
|
||||
});
|
||||
|
||||
test("client fails closed for disabled, invalid, stale and cross-scope data", async () => {
|
||||
const disabled = harness([], [FOUNDATION_FLAG]);
|
||||
assert.equal(disabled.client.enabled(), false);
|
||||
await assert.rejects(disabled.client.load(SCOPE), /not enabled/);
|
||||
disabled.foundation.apiVersion = "hux.v0";
|
||||
await assert.rejects(disabled.client.load(SCOPE), /not enabled/);
|
||||
const invalidScope = harness([]);
|
||||
await assert.rejects(invalidScope.client.load({...SCOPE, sessionId: "bad"}), /scope/);
|
||||
await assert.rejects(invalidScope.client.control({}, "enable_no_store"), /not bound/);
|
||||
for (const response of [{ok: false, status: 409}, {ok: false, status: 503}]) {
|
||||
const current = harness([response]);
|
||||
await assert.rejects(current.client.load(SCOPE), PrivacyContractError);
|
||||
}
|
||||
const malformed = harness([{body: {}}]);
|
||||
await assert.rejects(malformed.client.load(SCOPE), /crossed/);
|
||||
const wrongEffect = harness([{body: snapshot()}, {body: snapshot({revision: 2})}]);
|
||||
const item = await wrongEffect.client.load(SCOPE);
|
||||
await assert.rejects(wrongEffect.client.control(item, "enable_no_store"), /verified/);
|
||||
const stale = harness([{body: snapshot()}, {body: noStore({revision: 1})}]);
|
||||
await assert.rejects(stale.client.control(await stale.client.load(SCOPE),
|
||||
"enable_no_store"), /verified/);
|
||||
assert.throws(() => createPrivacyClient({client: null, fetcher: async () => ({})}), /requires/);
|
||||
assert.throws(() => createPrivacyClient({client: {...invalidScope.foundation,
|
||||
identity: {...IDENTITY, userRef: "bad"}}, fetcher: async () => ({})}), /identity/);
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = undefined;
|
||||
assert.throws(() => createPrivacyClient({client: invalidScope.foundation}), /requires/);
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
});
|
||||
119
testing/tests/test_hermes_hux_ui_privacy.py
Normal file
119
testing/tests/test_hermes_hux_ui_privacy.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""Executable and static quality gates for the isolated HUX-10 frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PRIVACY = ROOT / "dockerfiles" / "hermes-webui-hux" / "privacy"
|
||||
|
||||
|
||||
def test_hux_privacy_node_suite_and_per_source_coverage():
|
||||
"""Privacy adapters and transport remain above the repository gate."""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-branches=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/privacy/client.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/privacy/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/privacy/security.ts",
|
||||
"testing/tests/test_hermes_hux_ui_privacy.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
for row in ("client.ts", "model.ts", "security.ts"):
|
||||
report = next(line for line in result.stdout.splitlines() if row in line)
|
||||
percentages = [float(part) for part in report.split("|")[1:4]]
|
||||
assert min(percentages) >= 95.0, report
|
||||
|
||||
|
||||
def test_hux_privacy_matches_contract_and_stays_inert():
|
||||
"""HUX-10 uses shared topic/notice rules without live integration."""
|
||||
|
||||
schema = json.loads(
|
||||
(ROOT / "services/hermes/contracts/hux/privacy.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
model = (PRIVACY / "model.ts").read_text(encoding="utf-8")
|
||||
security = (PRIVACY / "security.ts").read_text(encoding="utf-8")
|
||||
for topic in schema["$defs"]["topic"]["enum"]:
|
||||
assert f"{topic}:" in model
|
||||
for control in schema["$defs"]["notice"]["properties"]["controls"][
|
||||
"items"
|
||||
]["enum"]:
|
||||
assert f'"{control}"' in model
|
||||
assert "hux.privacy_notice.v1" in model and "hux.v1" in model
|
||||
assert "conversationId" in security and "sessionId" in security
|
||||
assert "dockerfiles/hermes-webui-hux/privacy" not in (
|
||||
ROOT / "dockerfiles/Dockerfile.hermes-webui"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_hux_privacy_ui_is_accessible_minimized_and_explicit():
|
||||
"""The UI explains retention without repeating sensitive categories."""
|
||||
|
||||
component = (PRIVACY / "PrivacyCenter.tsx").read_text(encoding="utf-8")
|
||||
model = (PRIVACY / "model.ts").read_text(encoding="utf-8")
|
||||
client = (PRIVACY / "client.ts").read_text(encoding="utf-8")
|
||||
contracts = (PRIVACY / "contracts.ts").read_text(encoding="utf-8")
|
||||
styles = (PRIVACY / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled || !snapshot) return null" in component
|
||||
assert "What Hermes keeps" in component and "Effective retention" in component
|
||||
assert "No-store active" in component and "Use no-store here" in component
|
||||
assert "Sensitive-topic protection" in component
|
||||
assert "Previous sensitive details are not repeated" in component
|
||||
assert "Retention audit" in component and "auditIntervalDays" in component
|
||||
assert "Confirm forget" in component and "This cannot be undone" in component
|
||||
assert 'role="group"' in component and 'role="status"' in component
|
||||
assert 'aria-live="polite"' in component and 'role="alert"' in component
|
||||
assert "snapshot.notice.topic" not in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert "GENERIC_NOTICE" in model and "Never carry the category" in model
|
||||
assert "seenNotices" in client and "withoutNotice" in client
|
||||
assert all(
|
||||
token not in client
|
||||
for token in ("localStorage", "sessionStorage", "indexedDB", "document.cookie")
|
||||
)
|
||||
assert 'credentials: "same-origin"' in client and 'cache: "no-store"' in client
|
||||
assert "MUST omit inactive or previous topic" in contracts
|
||||
assert "Topic changes clear the prior notice" in contracts
|
||||
assert ".hux-privacy" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "focus-visible" in styles and "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_privacy_sources_are_bounded_and_endpoint_only():
|
||||
"""The slice defines API needs but changes no runtime or release wiring."""
|
||||
|
||||
sources = sorted(PRIVACY.glob("*"))
|
||||
assert {path.name for path in sources} == {
|
||||
"PrivacyCenter.tsx",
|
||||
"client.ts",
|
||||
"contracts.ts",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in sources:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) < 500
|
||||
contracts = (PRIVACY / "contracts.ts").read_text(encoding="utf-8")
|
||||
assert "GET /hux/v1/privacy/snapshot?session_id=&conversation_id=" in contracts
|
||||
assert "POST /hux/v1/privacy/controls" in contracts
|
||||
assert "expected_revision" in contracts and "A 409 rejects stale state" in contracts
|
||||
242
testing/tests/test_hermes_hux_ui_research.mjs
Normal file
242
testing/tests/test_hermes_hux_ui_research.mjs
Normal file
@ -0,0 +1,242 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {createResearchEndpointContract} from "../../dockerfiles/hermes-webui-hux/research/endpoints.ts";
|
||||
import {
|
||||
FOUNDATION_FLAG, FRIENDLY_MODES_FLAG, RESEARCH_FLAG, normalizeResearchPage, researchEnabled,
|
||||
} from "../../dockerfiles/hermes-webui-hux/research/model.ts";
|
||||
import {
|
||||
asRecord, boundedText, isHash, isId, isUtc, normalizeIdentity, normalizeScope,
|
||||
safeMessageId, safePathId, safeSourceUri, sameIdentity, sameScope,
|
||||
} from "../../dockerfiles/hermes-webui-hux/research/security.ts";
|
||||
|
||||
const IDENTITY = {tenantRef: "tnt_0123456789abcdef", userRef: "usr_0123456789abcdef", surface: "chat"};
|
||||
const RAW_IDENTITY = {tenant_ref: IDENTITY.tenantRef, user_ref: IDENTITY.userRef, surface: "chat"};
|
||||
const SCOPE = {projectId: "prj_project123", conversationId: "conv_converse1"};
|
||||
const RAW_SCOPE = {project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId};
|
||||
const HASH = `sha256:${"a".repeat(64)}`;
|
||||
const FLAGS = [FOUNDATION_FLAG, FRIENDLY_MODES_FLAG, RESEARCH_FLAG];
|
||||
|
||||
function source(extra = {}) {
|
||||
return {schema: "hux.source.v1", id: "src_primary12", kind: "web",
|
||||
uri: "https://example.com/report", title: "Primary report", publisher: "Example Institute",
|
||||
published_at: "2026-08-20T10:00:00Z", retrieved_at: "2026-08-24T10:00:00Z",
|
||||
classification: "primary", content_hash: HASH, provenance: {surface: "chat",
|
||||
actor: {type: "assistant", id: "hermes"}, recorded_at: "2026-08-24T10:00:01Z",
|
||||
conversation_id: SCOPE.conversationId, run_id: "run_research"}, ...extra};
|
||||
}
|
||||
|
||||
function passage(extra = {}) {
|
||||
return {schema: "hux.passage.v1", id: "psg_passage12", source_id: "src_primary12",
|
||||
text: "The exact supporting passage.\nSecond line.", hash: HASH,
|
||||
locator: {page: 2, selector: "#finding", line_start: 10, line_end: 12,
|
||||
char_start: 50, char_end: 91}, ...extra};
|
||||
}
|
||||
|
||||
function citation(extra = {}) {
|
||||
return {schema: "hux.citation.v1", id: "cit_citation1", message_id: "msg-44",
|
||||
claim: "The report supports this claim.", passage_ids: ["psg_passage12"],
|
||||
support: "supports", note: "Check the methodology.", ...extra};
|
||||
}
|
||||
|
||||
function notebook(extra = {}) {
|
||||
return {schema: "hux.research_notebook.v1", id: "nb_notebook12",
|
||||
conversation_id: SCOPE.conversationId, question: "What does the evidence show?", status: "open",
|
||||
source_ids: ["src_primary12"], passage_ids: ["psg_passage12"], citation_ids: ["cit_citation1"],
|
||||
assumptions: ["The publication date is accurate."], unresolved_questions: ["Was it peer reviewed?"],
|
||||
updated_at: "2026-08-24T10:00:02Z", ...extra};
|
||||
}
|
||||
|
||||
function page(extra = {}) {
|
||||
return {schema: "hux.research_page.v1", api_version: "hux.v1", identity: RAW_IDENTITY,
|
||||
binding: RAW_SCOPE, sources: [source()], passages: [passage()], citations: [citation()],
|
||||
notebooks: [notebook()], ...extra};
|
||||
}
|
||||
|
||||
test("research stays default-off until its declared capabilities are active", () => {
|
||||
assert.equal(researchEnabled(), false);
|
||||
assert.equal(researchEnabled([]), false);
|
||||
for (const flag of FLAGS) assert.equal(researchEnabled(FLAGS.filter((item) => item !== flag)), false);
|
||||
assert.equal(researchEnabled(FLAGS), true);
|
||||
});
|
||||
|
||||
test("security helpers enforce exact identity, scope, text, IDs, timestamps and hashes", () => {
|
||||
assert.deepEqual(asRecord({a: 1}), {a: 1});
|
||||
assert.equal(asRecord(null), null);
|
||||
assert.equal(asRecord([]), null);
|
||||
assert.equal(isId("src_primary12", "src"), true);
|
||||
assert.equal(isId("psg_passage12", "src"), false);
|
||||
assert.equal(isId(4), false);
|
||||
assert.equal(isUtc("2026-08-24T10:00:00.123Z"), true);
|
||||
assert.equal(isUtc("today"), false);
|
||||
assert.equal(isUtc(4), false);
|
||||
assert.equal(isHash(HASH), true);
|
||||
assert.equal(isHash(`sha256:${"z".repeat(64)}`), false);
|
||||
assert.equal(isHash(4), false);
|
||||
assert.equal(boundedText("hello", 1, 5), true);
|
||||
assert.equal(boundedText("", 1, 5), false);
|
||||
assert.equal(boundedText("bad\u0000", 1, 10), false);
|
||||
assert.equal(boundedText(4, 1, 5), false);
|
||||
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
|
||||
for (const bad of [null, [], {...RAW_IDENTITY, tenant_ref: "bad"},
|
||||
{...RAW_IDENTITY, user_ref: "bad"}, {...RAW_IDENTITY, surface: "browser"}]) {
|
||||
assert.equal(normalizeIdentity(bad), null);
|
||||
}
|
||||
assert.deepEqual(normalizeScope(RAW_SCOPE), SCOPE);
|
||||
assert.equal(normalizeScope(null), null);
|
||||
assert.equal(normalizeScope({...RAW_SCOPE, project_id: "bad"}), null);
|
||||
assert.equal(normalizeScope({...RAW_SCOPE, conversation_id: "bad"}), null);
|
||||
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
|
||||
assert.equal(sameIdentity(null, IDENTITY), false);
|
||||
assert.equal(sameIdentity({...IDENTITY, surface: "voice"}, IDENTITY), false);
|
||||
assert.equal(sameScope(SCOPE, SCOPE), true);
|
||||
assert.equal(sameScope(null, SCOPE), false);
|
||||
assert.equal(sameScope({...SCOPE, conversationId: "conv_other1234"}, SCOPE), false);
|
||||
});
|
||||
|
||||
test("source URLs allow HTTPS and HUX paths but reject active or ambiguous schemes", () => {
|
||||
assert.equal(safeSourceUri(undefined), null);
|
||||
assert.equal(safeSourceUri(null), null);
|
||||
assert.equal(safeSourceUri(""), null);
|
||||
assert.equal(safeSourceUri("https://example.com/a?q=1"), "https://example.com/a?q=1");
|
||||
assert.equal(safeSourceUri("/hux/v1/artifacts/art_safe1234"), "/hux/v1/artifacts/art_safe1234");
|
||||
for (const unsafe of ["http://example.com", "javascript:alert(1)", "data:text/html,x",
|
||||
"//example.com/x", "/hux/v1/../secret", "/hux/v1/x?q=1", "https://u:p@example.com", "not a url",
|
||||
`https://example.com/${"x".repeat(2000)}`, "https://example.com/\u0000"] ) {
|
||||
assert.equal(safeSourceUri(unsafe), null);
|
||||
}
|
||||
assert.equal(safeMessageId("msg-44"), "msg-44");
|
||||
assert.throws(() => safeMessageId("bad/path"), TypeError);
|
||||
assert.throws(() => safeMessageId(""), TypeError);
|
||||
assert.equal(safePathId("nb_notebook12", "nb"), "nb_notebook12");
|
||||
assert.throws(() => safePathId("bad", "nb"), TypeError);
|
||||
});
|
||||
|
||||
test("valid research keeps provenance, bounded passages, support, and notebook notes", () => {
|
||||
const result = normalizeResearchPage(page(), IDENTITY, SCOPE);
|
||||
assert.equal(result.rejected, 0);
|
||||
assert.equal(result.sources[0].classification, "primary");
|
||||
assert.equal(result.sources[0].provenance.actorType, "assistant");
|
||||
assert.equal(result.sources[0].provenance.runId, "run_research");
|
||||
assert.equal(result.passages[0].text, "The exact supporting passage.\nSecond line.");
|
||||
assert.equal(result.passages[0].locator, "page 2 · #finding · lines 10–12 · characters 50–91");
|
||||
assert.equal(result.citations[0].support, "supports");
|
||||
assert.deepEqual(result.notebooks[0].notes, ["Check the methodology."]);
|
||||
assert.deepEqual(result.notebooks[0].unresolvedQuestions, ["Was it peer reviewed?"]);
|
||||
});
|
||||
|
||||
test("envelope rejects cross-tenant, user, project and conversation data", () => {
|
||||
const bad = [null, {}, page({schema: "old"}), page({api_version: "hux.v0"}),
|
||||
page({identity: {...RAW_IDENTITY, tenant_ref: "tnt_ffffffffffffffff"}}),
|
||||
page({identity: {...RAW_IDENTITY, user_ref: "usr_ffffffffffffffff"}}),
|
||||
page({identity: {...RAW_IDENTITY, surface: "voice"}}),
|
||||
page({binding: {...RAW_SCOPE, project_id: "prj_other1234"}}),
|
||||
page({binding: {...RAW_SCOPE, conversation_id: "conv_other1234"}}),
|
||||
page({sources: null}), page({sources: Array(501).fill(source())}),
|
||||
page({passages: null}), page({passages: Array(501).fill(passage())}),
|
||||
page({citations: null}), page({citations: Array(501).fill(citation())}),
|
||||
page({notebooks: null}), page({notebooks: Array(101).fill(notebook())})];
|
||||
for (const raw of bad) assert.equal(normalizeResearchPage(raw, IDENTITY, SCOPE), null);
|
||||
});
|
||||
|
||||
test("invalid source metadata, provenance and URL are withheld with dependants", () => {
|
||||
const badSources = [null, source({schema: "old"}), source({id: "bad"}), source({kind: "video"}),
|
||||
source({title: ""}), source({publisher: "x".repeat(201)}), source({retrieved_at: "today"}),
|
||||
source({classification: "official"}), source({published_at: "today"}), source({content_hash: "bad"}),
|
||||
source({published_at: "2026-08-25T10:00:00Z"}),
|
||||
source({uri: "javascript:alert(1)"}), source({provenance: null}),
|
||||
source({provenance: {...source().provenance, surface: "browser"}}),
|
||||
source({provenance: {...source().provenance, actor: null}}),
|
||||
source({provenance: {...source().provenance, actor: {type: "intruder"}}}),
|
||||
source({provenance: {...source().provenance, actor: {type: "assistant", id: ""}}}),
|
||||
source({provenance: {...source().provenance, recorded_at: "today"}}),
|
||||
source({provenance: {...source().provenance, conversation_id: "conv_other1234"}}),
|
||||
source({provenance: {...source().provenance, run_id: ""}})];
|
||||
for (const raw of badSources) {
|
||||
const result = normalizeResearchPage(page({sources: [raw]}), IDENTITY, SCOPE);
|
||||
assert.equal(result.sources.length, 0);
|
||||
assert.equal(result.passages.length, 0);
|
||||
assert.equal(result.citations.length, 0);
|
||||
assert.equal(result.notebooks.length, 0);
|
||||
}
|
||||
const withoutOptional = normalizeResearchPage(page({sources: [source({uri: undefined,
|
||||
publisher: undefined, published_at: undefined, content_hash: undefined, provenance: undefined})]}), IDENTITY, SCOPE);
|
||||
assert.equal(withoutOptional.sources[0].uri, null);
|
||||
assert.equal(withoutOptional.sources[0].provenance, null);
|
||||
});
|
||||
|
||||
test("passage locators and citations are schema-bounded and referentially complete", () => {
|
||||
const invalidPassages = [null, passage({schema: "old"}), passage({id: "bad"}),
|
||||
passage({source_id: "src_missing12"}), passage({text: ""}), passage({text: "x".repeat(4001)}),
|
||||
passage({hash: "bad"}), passage({locator: null}), passage({locator: {unknown: 1}}),
|
||||
passage({locator: {page: 0}}), passage({locator: {selector: "x".repeat(501)}}),
|
||||
passage({locator: {line_start: 0}}), passage({locator: {line_end: 0}}),
|
||||
passage({locator: {char_start: -1}}), passage({locator: {char_end: -1}}),
|
||||
passage({locator: {line_start: 10, line_end: 9}}), passage({locator: {char_start: 10, char_end: 9}})];
|
||||
for (const raw of invalidPassages) {
|
||||
assert.equal(normalizeResearchPage(page({passages: [raw]}), IDENTITY, SCOPE).passages.length, 0);
|
||||
}
|
||||
assert.equal(normalizeResearchPage(page({passages: [passage({locator: undefined})]}), IDENTITY, SCOPE)
|
||||
.passages[0].locator, "");
|
||||
assert.equal(normalizeResearchPage(page({passages: [passage({locator: {line_start: 4}})]}), IDENTITY, SCOPE)
|
||||
.passages[0].locator, "lines 4");
|
||||
|
||||
const invalidCitations = [null, citation({schema: "old"}), citation({id: "bad"}),
|
||||
citation({message_id: ""}), citation({claim: ""}), citation({passage_ids: null}),
|
||||
citation({passage_ids: []}), citation({passage_ids: Array(33).fill("psg_passage12")}),
|
||||
citation({passage_ids: ["psg_passage12", "psg_passage12"]}),
|
||||
citation({passage_ids: ["psg_missing12"]}), citation({support: "maybe"}),
|
||||
citation({note: "x".repeat(501)})];
|
||||
for (const raw of invalidCitations) {
|
||||
assert.equal(normalizeResearchPage(page({citations: [raw]}), IDENTITY, SCOPE).citations.length, 0);
|
||||
}
|
||||
const allSupport = ["supports", "partially_supports", "contradicts", "unverified"];
|
||||
for (const support of allSupport) assert.equal(normalizeResearchPage(page({citations: [citation({support})]}),
|
||||
IDENTITY, SCOPE).citations[0].support, support);
|
||||
});
|
||||
|
||||
test("notebooks validate references, lists, status, conversation and duplicates", () => {
|
||||
const invalid = [null, notebook({schema: "old"}), notebook({id: "bad"}),
|
||||
notebook({conversation_id: "conv_other1234"}), notebook({question: ""}), notebook({status: "paused"}),
|
||||
notebook({updated_at: "today"}), notebook({source_ids: null}), notebook({source_ids: ["src_missing12"]}),
|
||||
notebook({source_ids: ["src_primary12", "src_primary12"]}), notebook({passage_ids: null}),
|
||||
notebook({passage_ids: ["psg_missing12"]}), notebook({citation_ids: null}),
|
||||
notebook({citation_ids: ["cit_missing12"]}), notebook({assumptions: null}),
|
||||
notebook({assumptions: Array(65).fill("x")}), notebook({assumptions: [""]}),
|
||||
notebook({unresolved_questions: null}), notebook({unresolved_questions: Array(65).fill("x")}),
|
||||
notebook({unresolved_questions: [""]})];
|
||||
for (const raw of invalid) {
|
||||
assert.equal(normalizeResearchPage(page({notebooks: [raw]}), IDENTITY, SCOPE).notebooks.length, 0);
|
||||
}
|
||||
for (const status of ["open", "answered", "abandoned"]) {
|
||||
assert.equal(normalizeResearchPage(page({notebooks: [notebook({status})]}), IDENTITY, SCOPE)
|
||||
.notebooks[0].status, status);
|
||||
}
|
||||
const duplicate = normalizeResearchPage(page({sources: [source(), source()],
|
||||
passages: [passage(), passage()], citations: [citation(), citation()], notebooks: [notebook(), notebook()]}),
|
||||
IDENTITY, SCOPE);
|
||||
assert.deepEqual([duplicate.sources.length, duplicate.passages.length, duplicate.citations.length,
|
||||
duplicate.notebooks.length], [1, 1, 1, 1]);
|
||||
assert.equal(duplicate.rejected, 4);
|
||||
});
|
||||
|
||||
test("endpoint contract is scoped, inert, optimistic, and same-origin delegated", () => {
|
||||
const client = {apiVersion: "hux.v1", identity: IDENTITY,
|
||||
endpoint(path) { assert.match(path, /^\/projects\/prj_[^/]+\/conversations\/conv_[^/]+\/research/);
|
||||
return `/hux/v1${path}`; }};
|
||||
assert.equal(createResearchEndpointContract(client, SCOPE), null);
|
||||
assert.equal(createResearchEndpointContract(client, SCOPE, FLAGS.slice(1)), null);
|
||||
const contract = createResearchEndpointContract(client, SCOPE, FLAGS);
|
||||
assert.equal(contract.page.path, "/hux/v1/projects/prj_project123/conversations/conv_converse1/research");
|
||||
assert.match(contract.citationsForMessage("msg-44").path, /messages\/msg-44\/citations$/);
|
||||
assert.match(contract.notebook("nb_notebook12").path, /notebooks\/nb_notebook12$/);
|
||||
assert.deepEqual(contract.updateNotebook("nb_notebook12").requiredBody,
|
||||
["expected_updated_at", "status", "assumptions", "unresolved_questions"]);
|
||||
assert.deepEqual(contract.attachCitation("msg-44").requiredBody,
|
||||
["claim", "passage_ids", "support", "note"]);
|
||||
for (const call of [() => contract.citationsForMessage("bad/path"), () => contract.notebook("bad"),
|
||||
() => contract.updateNotebook("bad")]) assert.throws(call, TypeError);
|
||||
assert.throws(() => createResearchEndpointContract({...client, apiVersion: "hux.v2"}, SCOPE, FLAGS), TypeError);
|
||||
assert.throws(() => createResearchEndpointContract({...client, identity: {...IDENTITY, tenantRef: "bad"}}, SCOPE, FLAGS), TypeError);
|
||||
assert.throws(() => createResearchEndpointContract(client, {...SCOPE, projectId: "bad"}, FLAGS), TypeError);
|
||||
});
|
||||
103
testing/tests/test_hermes_hux_ui_research.py
Normal file
103
testing/tests/test_hermes_hux_ui_research.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Executable and static quality gates for isolated HUX-08 research UX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RESEARCH = ROOT / "dockerfiles" / "hermes-webui-hux" / "research"
|
||||
|
||||
|
||||
def test_hux_research_node_suite_and_coverage():
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--test",
|
||||
"--experimental-strip-types",
|
||||
"--experimental-test-coverage",
|
||||
"--test-coverage-lines=95",
|
||||
"--test-coverage-functions=95",
|
||||
"--test-coverage-branches=95",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/research/model.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/research/security.ts",
|
||||
"--test-coverage-include=dockerfiles/hermes-webui-hux/research/endpoints.ts",
|
||||
"testing/tests/test_hermes_hux_ui_research.mjs",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_hux_research_matches_shared_contract_and_stays_default_off():
|
||||
schema = json.loads(
|
||||
(ROOT / "services/hermes/contracts/hux/citation.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
model = (RESEARCH / "model.ts").read_text(encoding="utf-8")
|
||||
for definition in ("source", "passage", "citation", "notebook"):
|
||||
assert schema["$defs"][definition]["properties"]["schema"]["const"] in model
|
||||
for value in schema["$defs"]["source"]["properties"]["kind"]["enum"]:
|
||||
assert f'"{value}"' in model
|
||||
for value in schema["$defs"]["source"]["properties"]["classification"][
|
||||
"enum"
|
||||
]:
|
||||
assert f'"{value}"' in model
|
||||
for value in schema["$defs"]["citation"]["properties"]["support"]["enum"]:
|
||||
assert f'"{value}"' in model
|
||||
assert "researchEnabled(props.flags)" in (
|
||||
RESEARCH / "ResearchWorkspace.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "dockerfiles/hermes-webui-hux/research" not in (
|
||||
ROOT / "dockerfiles/Dockerfile.hermes-webui"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_hux_research_ui_is_accessible_and_evidence_is_not_executable():
|
||||
component = (RESEARCH / "ResearchWorkspace.tsx").read_text(encoding="utf-8")
|
||||
endpoints = (RESEARCH / "endpoints.ts").read_text(encoding="utf-8")
|
||||
security = (RESEARCH / "security.ts").read_text(encoding="utf-8")
|
||||
styles = (RESEARCH / "styles.css").read_text(encoding="utf-8")
|
||||
assert "if (!enabled || !page) return null" in component
|
||||
assert 'aria-label="Research and citations"' in component
|
||||
assert 'aria-expanded={expanded}' in component
|
||||
assert 'aria-controls={panelId}' in component
|
||||
assert 'aria-live="polite"' in component
|
||||
assert "primary source" not in component # classification is data-driven
|
||||
assert "Research notes" in component
|
||||
assert "Assumptions" in component
|
||||
assert "Unresolved questions" in component
|
||||
assert 'rel="noopener noreferrer"' in component
|
||||
assert "dangerouslySetInnerHTML" not in component
|
||||
assert "<iframe" not in component and "<object" not in component
|
||||
assert "javascript:" not in component and "data:" not in component
|
||||
assert 'parsed.protocol === "https:"' in security
|
||||
assert "fetch(" not in endpoints and "axios" not in endpoints
|
||||
assert ".hux-research" in styles
|
||||
assert "body" not in styles and ":root" not in styles
|
||||
assert "prefers-reduced-motion" in styles
|
||||
|
||||
|
||||
def test_hux_research_sources_are_bounded_and_endpoint_only():
|
||||
sources = sorted(RESEARCH.glob("*"))
|
||||
assert {path.name for path in sources} == {
|
||||
"ResearchWorkspace.tsx",
|
||||
"endpoints.ts",
|
||||
"index.ts",
|
||||
"model.ts",
|
||||
"security.ts",
|
||||
"styles.css",
|
||||
"types.ts",
|
||||
}
|
||||
for path in sources:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
endpoints = (RESEARCH / "endpoints.ts").read_text(encoding="utf-8")
|
||||
assert "expected_updated_at" in endpoints
|
||||
assert '"PATCH"' not in endpoints and '"DELETE"' not in endpoints
|
||||
Loading…
x
Reference in New Issue
Block a user