Re-enforcement prerequisites, code-side complete: the autonomy runtime now docks pending-approval cards above the composer (newest first, cap three, aria-live, allow-once / always / deny wired to the existing decide route with idempotency; polling gated to active turns and fail-tolerant), so parked tool calls are never silent. The default capability matrix no longer denies by default: network and web_search ask below autonomous (visible prompt) and nothing resolves to deny except explicit grants or private mode; SO-39 stays intact - deploy and external side effects always ask and external never auto-allows. rules.py at 100% line+branch; 744 hux-lane tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
591 lines
37 KiB
JavaScript
591 lines
37 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const vm = require('node:vm');
|
|
|
|
const api = require('../../dockerfiles/hermes-webui-hux/runtime/autonomy-privacy.js');
|
|
|
|
const IDENTITY = Object.freeze({
|
|
tenantSlot: 'slot-3',
|
|
userRef: 'usr_0123456789abcdef',
|
|
surface: 'chat',
|
|
trust: 'relay',
|
|
});
|
|
const CONVERSATION = 'conv_alpha1234';
|
|
const RUN_ID = 'run_alpha1234';
|
|
const STAMP = '2026-08-24T10:00:00Z';
|
|
|
|
function policy(extra = {}) {
|
|
return {
|
|
schema: 'hux.policy.v1', id: 'pol_conversation.alpha', owner: IDENTITY.userRef,
|
|
scope: {level: 'conversation', scope_id: CONVERSATION}, autonomy: 'safe',
|
|
grants: [{capability: 'network', decision: 'ask', expires_at: '2026-08-25T10:00:00Z',
|
|
granted_by: {type: 'user', id: IDENTITY.userRef}}],
|
|
budgets: {tokens_per_run: 1000, tool_calls_per_run: 10, wall_clock_seconds: 60,
|
|
delegations_per_run: 2, spend_units: 5, subagents_per_run: 1,
|
|
scope: {conversations: [CONVERSATION], paths: ['/workspace']}},
|
|
provenance: {surface: 'chat'}, updated_at: STAMP, revision: 1, ...extra,
|
|
};
|
|
}
|
|
|
|
function approval(extra = {}) {
|
|
return {
|
|
schema: 'hux.approval.v1', id: 'apr_alpha1234', run_id: RUN_ID,
|
|
conversation_id: CONVERSATION, capability: 'send_message',
|
|
request: {summary: 'Send the finished report', detail: 'private details', risk: 'high',
|
|
external: true, evidence: []}, status: 'pending', requested_at: STAMP,
|
|
expires_at: '2026-08-25T10:00:00Z', idempotency_key: 'approval:key', ...extra,
|
|
};
|
|
}
|
|
|
|
function approvalPage(items = [approval()], extra = {}) {
|
|
return {items, next: null, ...extra};
|
|
}
|
|
|
|
const TOPICS = ['health', 'finance', 'legal', 'relationships', 'credentials', 'minors', 'location', 'biometric'];
|
|
function privacyPolicy(extra = {}) {
|
|
return {
|
|
schema: 'hux.privacy_policy.v1', version: 1,
|
|
topics: TOPICS.map((topic, index) => ({topic,
|
|
sensitivity: index < 4 ? 'sensitive' : 'restricted',
|
|
memory_write: index < 4 ? 'ask' : 'deny', decay_days: index + 1,
|
|
notice: `Generic ${topic} notice`})),
|
|
topic_scoping: {scope_to_conversation: true, cross_surface_sharing: 'never'},
|
|
retention_audit: {interval_days: 1,
|
|
actions: ['expire_memory', 'decay_topic_context', 'purge_forgotten_content', 'report']},
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function audit(extra = {}) {
|
|
return {schema: 'hux.retention_audit.v1', id: 'aud_alpha1234', ran_at: STAMP,
|
|
policy_version: 1, results: [
|
|
{action: 'expire_memory', count: 2},
|
|
{action: 'decay_topic_context', count: 1},
|
|
{action: 'purge_forgotten_content', count: 0},
|
|
{action: 'report', count: 1},
|
|
], ...extra};
|
|
}
|
|
|
|
function notice(extra = {}) {
|
|
return {schema: 'hux.privacy_notice.v1', topic: 'health',
|
|
conversation_id: CONVERSATION, text: 'Private health category text',
|
|
controls: ['forget_this_conversation', 'switch_to_private', 'disable_memory_here', 'dismiss'],
|
|
shown_at: STAMP, ...extra};
|
|
}
|
|
|
|
function receipt(extra = {}) {
|
|
return {schema: 'hux.cancel_receipt.v1', id: 'rcpt_alpha1234', run_id: RUN_ID,
|
|
requested_by: {type: 'user', id: IDENTITY.userRef}, requested_at: STAMP,
|
|
acknowledged_at: STAMP, completed_at: STAMP, outcome: 'cancelled',
|
|
side_effects: [{description: 'Draft file removed', reverted: true,
|
|
evidence: {kind: 'receipt', id: 'receipt-1'}},
|
|
{description: 'Message was already delivered', reverted: false}],
|
|
conversation_id: CONVERSATION,
|
|
...extra};
|
|
}
|
|
|
|
function response(status, body, headers = {}) {
|
|
const lower = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
return {status, ok: status >= 200 && status < 300,
|
|
headers: {get: (name) => lower[name.toLowerCase()] || null}, json: async () => body};
|
|
}
|
|
|
|
class FakeNode {
|
|
constructor(tag) { this.tagName = tag.toUpperCase(); this.attributes = {}; this.children = [];
|
|
this.listeners = {}; this.textContent = ''; this.parentNode = null; this.selected = false; this.value = '';
|
|
this.dataset = {}; this.hidden = false; }
|
|
setAttribute(name, value) { this.attributes[name] = String(value); if (name === 'value') this.value = String(value);
|
|
if (name.startsWith('data-')) this.dataset[name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = 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 = []; 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; }
|
|
}
|
|
|
|
const document = {createElement: (tag) => new FakeNode(tag)};
|
|
function descendants(node) { return node.children.flatMap((child) => [child, ...descendants(child)]); }
|
|
function findText(node, value) { return descendants(node).find((item) => item.textContent === value); }
|
|
function allText(node) { return [node, ...descendants(node)].map((item) => item.textContent).join(' '); }
|
|
function ticks() { return new Promise((resolve) => setImmediate(() => setImmediate(resolve))); }
|
|
|
|
function harness(initial = [], flags = ['hux.foundation', 'hux.autonomy', 'hux.privacy']) {
|
|
const responses = initial.slice(); const calls = []; const enabled = new Set(flags);
|
|
const client = {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 result = responses.shift(); if (!result) throw new Error('unexpected request'); return result; };
|
|
return {calls, client, responses, fetcher};
|
|
}
|
|
|
|
test('canonical records normalize to bounded display-only autonomy views', () => {
|
|
const item = api.normalizePolicy(policy(), IDENTITY, CONVERSATION, '"1"');
|
|
assert.equal(item.autonomy, 'safe');
|
|
assert.equal(item.ifMatch, 1);
|
|
assert.equal(item.grants[0].capability, 'network');
|
|
assert.equal('provenance' in item, false);
|
|
assert.deepEqual(item.budgets.scope, {conversations: [CONVERSATION], paths: ['/workspace']});
|
|
const inherited = api.normalizePolicy(policy({id: 'pol_global', scope: {level: 'global'}}),
|
|
IDENTITY, CONVERSATION, '1');
|
|
assert.equal(inherited.ifMatch, 0);
|
|
assert.deepEqual(inherited.scope, {level: 'conversation', scope_id: CONVERSATION});
|
|
assert.equal(api.normalizePolicy(policy({id: 'pol_project.alpha',
|
|
scope: {level: 'project', scope_id: 'prj_alpha1234'}}), IDENTITY, CONVERSATION, '1').ifMatch, 0);
|
|
assert.equal(api.normalizePolicy(policy({grants: [{capability: 'shell', decision: 'deny'}]}),
|
|
IDENTITY, CONVERSATION, '1').grants[0].expires_at, undefined);
|
|
assert.equal(api.normalizeApproval(approval(), CONVERSATION, new Set(['pending'])).summary,
|
|
'Send the finished report');
|
|
assert.deepEqual(api.normalizeApprovalPage(approvalPage(), CONVERSATION).map((row) => row.id),
|
|
['apr_alpha1234']);
|
|
const stopped = api.normalizeReceipt(receipt(), RUN_ID, CONVERSATION, IDENTITY);
|
|
assert.deepEqual(stopped.sideEffects, [{description: 'Draft file removed', reverted: true},
|
|
{description: 'Message was already delivered', reverted: false}]);
|
|
});
|
|
|
|
test('policy and approval validators reject identity, scope, schema and unsafe payloads', () => {
|
|
const invalidPolicies = [null, {...policy(), extra: true}, {...policy(), schema: 'old'},
|
|
{...policy(), id: 'bad'}, {...policy(), owner: 'usr_aaaaaaaaaaaaaaaa'},
|
|
{...policy(), autonomy: 'reckless'}, {...policy(), updated_at: 'today'},
|
|
{...policy(), revision: 0}, {...policy(), scope: null},
|
|
{...policy(), scope: {level: 'global'}}, {...policy(), provenance: null},
|
|
{...policy(), id: 'pol_global', scope: {level: 'global', scope_id: CONVERSATION}},
|
|
{...policy(), id: 'pol_project.alpha', scope: {level: 'project', scope_id: 'bad'}},
|
|
{...policy(), grants: null}, {...policy(), grants: Array(65).fill(policy().grants[0])},
|
|
{...policy(), grants: [null]}, {...policy(), grants: [{capability: 'unknown', decision: 'ask'}]},
|
|
{...policy(), grants: [{capability: 'network', decision: 'maybe'}]},
|
|
{...policy(), grants: [{capability: 'network', decision: 'ask', expires_at: 'later'}]},
|
|
{...policy(), budgets: null}, {...policy(), budgets: {tokens_per_run: -1}},
|
|
{...policy(), budgets: {surprise: 1}},
|
|
{...policy(), budgets: {scope: null}},
|
|
{...policy(), budgets: {scope: {conversations: ['bad']}}},
|
|
{...policy(), budgets: {scope: {paths: ['']}}}];
|
|
invalidPolicies.forEach((raw) => assert.equal(api.normalizePolicy(raw, IDENTITY, CONVERSATION, '1'), null));
|
|
assert.equal(api.normalizePolicy(policy(), IDENTITY, CONVERSATION, '2'), null);
|
|
|
|
const invalidApprovals = [null, {...approval(), extra: true}, {...approval(), schema: 'old'},
|
|
{...approval(), id: 'bad'}, {...approval(), run_id: 'bad/run'},
|
|
{...approval(), conversation_id: 'conv_other1234'}, {...approval(), capability: 'unknown'},
|
|
{...approval(), status: 'approved'}, {...approval(), requested_at: 'today'},
|
|
{...approval(), expires_at: 'today'}, {...approval(), request: null},
|
|
{...approval(), request: {...approval().request, summary: ''}},
|
|
{...approval(), request: {...approval().request, risk: 'extreme'}},
|
|
{...approval(), request: {...approval().request, external: 'yes'}}];
|
|
invalidApprovals.forEach((raw) => assert.equal(
|
|
api.normalizeApproval(raw, CONVERSATION, new Set(['pending'])), null));
|
|
assert.equal(api.normalizeApprovalPage(null, CONVERSATION), null);
|
|
assert.equal(api.normalizeApprovalPage({items: Array(201).fill(approval()), next: null}, CONVERSATION), null);
|
|
assert.equal(api.normalizeApprovalPage({items: [approval(), approval()], next: null}, CONVERSATION), null);
|
|
assert.equal(api.normalizeApprovalPage({items: [], next: 'cursor'}, CONVERSATION), null);
|
|
const resolved = approval({status: 'approved', decision: {choice: 'once',
|
|
by: {type: 'user', id: IDENTITY.userRef}, at: STAMP}});
|
|
assert.equal(api.normalizeApproval(resolved, CONVERSATION, new Set(['approved']), 'once', IDENTITY).status,
|
|
'approved');
|
|
assert.equal(api.normalizeApproval({...resolved, decision: {...resolved.decision, choice: 'always'}},
|
|
CONVERSATION, new Set(['approved']), 'once', IDENTITY), null);
|
|
assert.equal(api.normalizeApproval({...resolved, decision: {...resolved.decision,
|
|
by: {type: 'user', id: 'usr_aaaaaaaaaaaaaaaa'}}}, CONVERSATION,
|
|
new Set(['approved']), 'once', IDENTITY), null);
|
|
assert.equal(api.normalizeApproval({...approval(), decision: resolved.decision},
|
|
CONVERSATION, new Set(['pending'])), null);
|
|
});
|
|
|
|
test('receipt validation proves run and side-effect outcome', () => {
|
|
const invalid = [null, {...receipt(), extra: true}, {...receipt(), schema: 'old'},
|
|
{...receipt(), id: 'bad'}, {...receipt(), run_id: 'other'}, {...receipt(), requested_by: null},
|
|
{...receipt(), requested_at: 'today'}, {...receipt(), outcome: 'requested'},
|
|
{...receipt(), conversation_id: 'conv_other1234'}, {...receipt(), side_effects: null},
|
|
{...receipt(), side_effects: Array(65).fill(receipt().side_effects[0])},
|
|
{...receipt(), side_effects: [null]},
|
|
{...receipt(), side_effects: [{description: '', reverted: true}]},
|
|
{...receipt(), side_effects: [{description: 'x', reverted: 'yes'}]}];
|
|
invalid.forEach((raw) => assert.equal(api.normalizeReceipt(raw, RUN_ID, CONVERSATION, IDENTITY), null));
|
|
assert.equal(api.normalizeReceipt({...receipt(), conversation_id: undefined}, RUN_ID, CONVERSATION, IDENTITY).outcome,
|
|
'cancelled');
|
|
assert.equal(api.normalizeReceipt(receipt(), RUN_ID, CONVERSATION, null), null);
|
|
assert.equal(api.normalizeReceipt({...receipt(), requested_by: {type: 'system', id: 'worker'}},
|
|
RUN_ID, CONVERSATION, {...IDENTITY, surface: 'worker'}).outcome, 'cancelled');
|
|
});
|
|
|
|
test('privacy and audit adapters omit topic details and reject malformed policy', () => {
|
|
const item = api.normalizePrivacyPolicy(privacyPolicy(), 'false');
|
|
assert.deepEqual(item, {version: 1, protectedCount: 8, shortestDecay: 1,
|
|
longestDecay: 8, crossSurfaceSharing: 'never', auditIntervalDays: 1, auditStale: false});
|
|
assert.equal(api.normalizePrivacyPolicy(privacyPolicy(), 'true').auditStale, true);
|
|
const invalid = [null, {...privacyPolicy(), extra: true}, {...privacyPolicy(), schema: 'old'},
|
|
{...privacyPolicy(), version: 0}, {...privacyPolicy(), topics: []},
|
|
{...privacyPolicy(), topics: [...privacyPolicy().topics.slice(0, 7), privacyPolicy().topics[0]]},
|
|
{...privacyPolicy(), topics: privacyPolicy().topics.map((row, i) => i ? row : {...row, sensitivity: 'personal'})},
|
|
{...privacyPolicy(), topics: privacyPolicy().topics.map((row, i) => i ? row : {...row, memory_write: 'always'})},
|
|
{...privacyPolicy(), topics: privacyPolicy().topics.map((row, i) => i ? row : {...row, decay_days: 0})},
|
|
{...privacyPolicy(), topics: privacyPolicy().topics.map((row, i) => i ? row : {...row, notice: ''})},
|
|
{...privacyPolicy(), topic_scoping: null},
|
|
{...privacyPolicy(), topic_scoping: {scope_to_conversation: false, cross_surface_sharing: 'never'}},
|
|
{...privacyPolicy(), retention_audit: null},
|
|
{...privacyPolicy(), retention_audit: {interval_days: 31, actions: ['report']}},
|
|
{...privacyPolicy(), retention_audit: {interval_days: 1, actions: []}}];
|
|
invalid.forEach((raw) => assert.equal(api.normalizePrivacyPolicy(raw, 'false'), null));
|
|
const view = api.normalizeNotice(notice(), CONVERSATION);
|
|
assert.equal(view.text, api.GENERIC_NOTICE);
|
|
assert.equal(JSON.stringify(view).includes('health'), false);
|
|
assert.equal(api.normalizeNotice({...notice(), topic: 'unknown'}, CONVERSATION), null);
|
|
assert.equal(api.normalizeNotice({...notice(), conversation_id: 'conv_other1234'}, CONVERSATION), null);
|
|
assert.equal(api.normalizeNotice({...notice(), controls: ['unknown']}, CONVERSATION), null);
|
|
});
|
|
|
|
test('audit and forget views retain only operational counts', () => {
|
|
assert.deepEqual(api.normalizeAuditPage({items: [audit()], next: null}), [{id: 'aud_alpha1234',
|
|
ranAt: STAMP, policyVersion: 1, actions: 4, affected: 3}]);
|
|
const badAudits = [null, {items: Array(201).fill(audit()), next: null},
|
|
{items: [null], next: null}, {items: [{...audit(), schema: 'old'}], next: null},
|
|
{items: [{...audit(), id: 'bad'}], next: null},
|
|
{items: [{...audit(), ran_at: 'today'}], next: null},
|
|
{items: [{...audit(), policy_version: 0}], next: null},
|
|
{items: [{...audit(), results: [null]}], next: null},
|
|
{items: [{...audit(), results: [{action: 'delete', count: 1}]}], next: null},
|
|
{items: [{...audit(), results: [{action: 'report', count: -1}]}], next: null}];
|
|
badAudits.forEach((raw) => assert.equal(api.normalizeAuditPage(raw), null));
|
|
assert.deepEqual(api.normalizeForget({conversation_id: CONVERSATION, forgotten: true,
|
|
memory_forgotten: 2, events_redacted: 4}, CONVERSATION),
|
|
{forgotten: true, memoryForgotten: 2, eventsRedacted: 4});
|
|
assert.equal(api.normalizeForget({conversation_id: CONVERSATION, forgotten: false,
|
|
memory_forgotten: 0, events_redacted: 0}, CONVERSATION), null);
|
|
});
|
|
|
|
test('extensions register with the HUX shell and validate runtime scope', () => {
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION,
|
|
fetcher: async () => response(500, {})});
|
|
assert.deepEqual(runtime.extensions.map((item) => [item.id, item.flag]), [
|
|
['autonomy-controls', 'hux.autonomy'], ['privacy-controls', 'hux.privacy']]);
|
|
const registered = []; const removed = [];
|
|
const remove = runtime.register({register(extension) { registered.push(extension.id);
|
|
return () => removed.push(extension.id); }});
|
|
assert.deepEqual(registered, ['autonomy-controls', 'privacy-controls']);
|
|
remove(); assert.deepEqual(removed, ['privacy-controls', 'autonomy-controls']);
|
|
assert.throws(() => runtime.register(null), /shell/);
|
|
assert.throws(() => api.createRuntime({document, conversationId: 'bad', fetcher() {}}), /conversation/);
|
|
assert.throws(() => api.createRuntime({document, conversationId: CONVERSATION, runId: 'bad/run', fetcher() {}}), /run id/);
|
|
assert.throws(() => api.createRuntime({document: null, conversationId: CONVERSATION, fetcher() {}}), /document/);
|
|
const originalFetch = globalThis.fetch;
|
|
try { globalThis.fetch = undefined;
|
|
assert.throws(() => api.createRuntime({document, conversationId: CONVERSATION}), /fetch/);
|
|
} finally { globalThis.fetch = originalFetch; }
|
|
assert.throws(() => api.createRuntime(), /document/);
|
|
});
|
|
|
|
test('canonical autonomy/privacy runtime negotiates tenantSlot identity and mounts both cards', async () => {
|
|
const rawIdentity = {tenant_slot: IDENTITY.tenantSlot, subject: IDENTITY.userRef,
|
|
surface: IDENTITY.surface, trust: IDENTITY.trust};
|
|
const cards = [
|
|
['HUX-11', 'hux.foundation', ['/hux/v1/capabilities']],
|
|
['HUX-01', 'hux.activity_timeline', ['/hux/v1/conversations/{id}/events']],
|
|
['HUX-05', 'hux.autonomy', ['/hux/v1/policy']],
|
|
['HUX-10', 'hux.privacy', ['/hux/v1/privacy/policy']],
|
|
].map(([card, flag, routes]) => ({card, flag, enabled: true, routes}));
|
|
const calls = [];
|
|
const fetcher = async (url, init) => {
|
|
calls.push({url, init});
|
|
if (url === '/hux/v1/capabilities') return response(200, {schema: 'hux.capabilities.v1',
|
|
contract_version: '1.1.0', identity: rawIdentity, cards});
|
|
if (url.startsWith('/hux/v1/policy?')) return response(200, policy(), {ETag: '1'});
|
|
if (url === '/hux/v1/approvals?status=pending') return response(200, approvalPage([]));
|
|
if (url === '/hux/v1/privacy/policy') return response(200, privacyPolicy());
|
|
if (url === '/hux/v1/privacy/audit') return response(200, {items: [], next: null});
|
|
throw new Error(`unexpected ${url}`);
|
|
};
|
|
const runtime = api.createAutonomyPrivacyRuntime({document, conversationId: CONVERSATION,
|
|
expectedIdentity: IDENTITY, fetcher});
|
|
const root = new FakeNode('section');
|
|
await runtime.mount(root); await ticks();
|
|
assert.equal(runtime.client.identity.tenantSlot, 'slot-3');
|
|
assert.equal(runtime.client.identity.trust, 'relay');
|
|
assert.match(allText(root), /Autonomy and approvals/);
|
|
assert.match(allText(root), /Privacy and retention/);
|
|
assert.equal(calls.filter((call) => call.url === '/hux/v1/capabilities').length, 1);
|
|
runtime.destroy(); runtime.destroy(); assert.equal(root.children.length, 0);
|
|
await assert.rejects(() => runtime.mount(root), /destroyed/);
|
|
assert.throws(() => api.createAutonomyPrivacyRuntime({document, conversationId: CONVERSATION,
|
|
expectedIdentity: {...IDENTITY, tenantSlot: 'tenant'}, fetcher}), /identity/);
|
|
});
|
|
|
|
test('browser script exports the vanilla runtime namespace', () => {
|
|
const context = {globalThis: {HermesHuxWaveAContract: {}, HermesHuxShell: {}}};
|
|
const source = fs.readFileSync(path.join(__dirname,
|
|
'../../dockerfiles/hermes-webui-hux/runtime/autonomy-privacy.js'), 'utf8');
|
|
vm.runInNewContext(source, context);
|
|
assert.equal(typeof context.globalThis.HermesHuxAutonomyPrivacy.createRuntime, 'function');
|
|
assert.equal(typeof context.globalThis.HermesHuxAutonomyPrivacy.createAutonomyPrivacyRuntime, 'function');
|
|
});
|
|
|
|
test('autonomy renderer reads only and never approves before a click', async () => {
|
|
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage())]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, runId: RUN_ID, fetcher: h.fetcher,
|
|
canStopModelResponse: () => true, stopModelResponse: async () => ({accepted: true})});
|
|
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root});
|
|
await ticks();
|
|
assert.equal(h.calls.length, 2); assert(h.calls.every((call) => call.init.method === 'GET'));
|
|
assert.match(allText(root), /Pending approvals \(1\)/);
|
|
assert.equal(allText(root).includes('private details'), false);
|
|
assert(findText(root, 'Allow once')); assert(findText(root, 'Stop model response'));
|
|
h.calls.forEach((call) => { assert.equal(call.init.credentials, 'same-origin');
|
|
assert.equal(call.init.cache, 'no-store'); assert.equal(call.init.headers.Accept.includes('version=1'), true); });
|
|
});
|
|
|
|
test('policy update uses If-Match and approval choice uses idempotency', async () => {
|
|
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage()),
|
|
response(200, policy({autonomy: 'autonomous', revision: 2}), {ETag: '2'}),
|
|
response(200, approval({status: 'approved', decision: {choice: 'once', by: {type: 'user', id: IDENTITY.userRef}, at: STAMP}}))]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher});
|
|
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
|
const select = descendants(root).find((node) => node.tagName === 'SELECT'); select.value = 'autonomous';
|
|
findText(root, 'Save autonomy').click(); await ticks();
|
|
assert.equal(h.calls[2].init.headers['If-Match'], '1');
|
|
assert.equal(JSON.parse(h.calls[2].init.body).autonomy, 'autonomous');
|
|
findText(root, 'Allow once').click(); await ticks();
|
|
assert.match(h.calls[3].init.headers['Idempotency-Key'], /^hux:approval-once:/);
|
|
assert.deepEqual(JSON.parse(h.calls[3].init.body), {choice: 'once'});
|
|
assert.match(allText(root), /Pending approvals \(0\)/);
|
|
await assert.rejects(runtime.decideApproval('apr_alpha1234', 'once'), /presented/);
|
|
await assert.rejects(runtime.savePolicy('reckless'), /invalid/);
|
|
});
|
|
|
|
test('an inherited global policy creates a conversation override with If-Match zero', async () => {
|
|
const inherited = policy({id: 'pol_global', scope: {level: 'global'}});
|
|
const created = policy({autonomy: 'ask_first', revision: 1});
|
|
const h = harness([response(200, inherited, {ETag: '1'}), response(200, approvalPage([])),
|
|
response(200, created, {ETag: '1'})]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher});
|
|
runtime.extensions[0].render({client: h.client, container: new FakeNode('section')}); await ticks();
|
|
await runtime.savePolicy('ask_first');
|
|
assert.equal(h.calls[2].init.headers['If-Match'], '0');
|
|
assert.deepEqual(JSON.parse(h.calls[2].init.body).scope,
|
|
{level: 'conversation', scope_id: CONVERSATION});
|
|
});
|
|
|
|
test('stop is not complete until a validated receipt is rendered', async () => {
|
|
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage([])),
|
|
response(201, receipt({side_effects: [{description: 'Draft file removed', reverted: true}]}))]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, runId: RUN_ID, fetcher: h.fetcher,
|
|
canStopModelResponse: () => true, stopModelResponse: async () => ({accepted: true})});
|
|
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
|
findText(root, 'Stop model response').click(); await ticks();
|
|
assert.match(h.calls[2].init.headers['Idempotency-Key'], /^hux:stop:/);
|
|
assert.deepEqual(JSON.parse(h.calls[2].init.body), {conversation_id: CONVERSATION, process_registry_empty: false});
|
|
assert.match(allText(root), /Stop receipt/); assert.match(allText(root), /Outcome: cancelled/);
|
|
assert.match(allText(root), /Reverted: Draft file removed/);
|
|
assert.match(allText(root), /model response and recorded side effects stopped/i);
|
|
});
|
|
|
|
test('privacy renderer is generic and suppresses topic replay', async () => {
|
|
const h = harness([response(200, privacyPolicy(), {'HUX-Audit-Stale': 'false'}),
|
|
response(200, {items: [audit()], next: null}), response(201, notice())]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher});
|
|
const root = new FakeNode('section'); runtime.extensions[1].render({client: h.client, container: root}); await ticks();
|
|
assert.match(allText(root), /Privacy and retention/); assert.match(allText(root), /prior sensitive details are not repeated/);
|
|
await runtime.showPrivacyNotice('health');
|
|
assert.match(allText(root), /Sensitive-topic protection/); assert.equal(allText(root).includes('health'), false);
|
|
const before = h.calls.length; assert.equal(await runtime.showPrivacyNotice('finance'), null);
|
|
assert.equal(h.calls.length, before); runtime.clearPrivacyNotice();
|
|
assert.equal(allText(root).includes('Sensitive-topic protection'), false);
|
|
assert.equal(await runtime.showPrivacyNotice('unknown'), null);
|
|
});
|
|
|
|
test('privacy controls are explicit, idempotent and minimize results', async () => {
|
|
let privateCalls = 0;
|
|
const h = harness([response(200, privacyPolicy(), {'HUX-Audit-Stale': 'true'}),
|
|
response(200, {items: [], next: null}), response(201, notice()),
|
|
response(201, notice())]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher,
|
|
onPrivateMode: async () => { privateCalls += 1; }});
|
|
const root = new FakeNode('section'); runtime.extensions[1].render({client: h.client, container: root}); await ticks();
|
|
await runtime.showPrivacyNotice('health'); await runtime.privacyChoice('switch_to_private');
|
|
assert.equal(privateCalls, 1); assert.match(h.calls[3].init.headers['Idempotency-Key'], /privacy-switch_to_private/);
|
|
assert.equal(allText(root).includes('health'), false); assert.match(allText(root), /Private mode was requested/);
|
|
|
|
const forgetHarness = harness([response(200, privacyPolicy()), response(200, {items: [], next: null}),
|
|
response(201, notice()), response(200, {conversation_id: CONVERSATION, forgotten: true,
|
|
memory_forgotten: 3, events_redacted: 7})]);
|
|
const forgetting = api.createRuntime({document, conversationId: CONVERSATION, fetcher: forgetHarness.fetcher});
|
|
const forgetRoot = new FakeNode('section'); forgetting.extensions[1].render({client: forgetHarness.client, container: forgetRoot}); await ticks();
|
|
await forgetting.showPrivacyNotice('health'); await forgetting.privacyChoice('forget_this_conversation');
|
|
assert.match(forgetHarness.calls[3].init.headers['Idempotency-Key'], /^hux:forget:/);
|
|
assert.match(allText(forgetRoot), /3 memory entries removed/);
|
|
|
|
const memoryHarness = harness([response(200, privacyPolicy()), response(200, {items: [], next: null}),
|
|
response(201, notice()), response(201, notice())]);
|
|
const memory = api.createRuntime({document, conversationId: CONVERSATION, fetcher: memoryHarness.fetcher});
|
|
const memoryRoot = new FakeNode('section'); memory.extensions[1].render({client: memoryHarness.client, container: memoryRoot}); await ticks();
|
|
await memory.showPrivacyNotice('health'); await memory.privacyChoice('disable_memory_here');
|
|
assert.match(allText(memoryRoot), /Memory is disabled/);
|
|
|
|
const dismissHarness = harness([response(200, privacyPolicy()), response(200, {items: [], next: null}), response(201, notice())]);
|
|
const dismissing = api.createRuntime({document, conversationId: CONVERSATION, fetcher: dismissHarness.fetcher});
|
|
const dismissRoot = new FakeNode('section'); dismissing.extensions[1].render({client: dismissHarness.client, container: dismissRoot}); await ticks();
|
|
await dismissing.showPrivacyNotice('health'); assert.equal(await dismissing.privacyChoice('dismiss'), null);
|
|
assert.match(allText(dismissRoot), /notice dismissed/);
|
|
|
|
const localHarness = harness([response(200, privacyPolicy()), response(200, {items: [], next: null}),
|
|
response(201, notice()), response(201, notice())]);
|
|
const local = api.createRuntime({document, conversationId: CONVERSATION, fetcher: localHarness.fetcher});
|
|
const localRoot = new FakeNode('section'); local.extensions[1].render({client: localHarness.client,
|
|
container: localRoot}); await ticks();
|
|
await local.showPrivacyNotice('health'); await local.privacyChoice('switch_to_private');
|
|
assert.match(allText(localRoot), /Private mode was requested/);
|
|
});
|
|
|
|
test('renderers fail closed with safe errors and reject unpresented actions', async () => {
|
|
const badClient = {...harness([]).client, apiVersion: 'hux.v0'};
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION,
|
|
fetcher: async () => response(500, {secret: 'must not render'})});
|
|
assert.throws(() => runtime.extensions[0].render({client: badClient, container: new FakeNode('div')}), /capability/);
|
|
const root = new FakeNode('section'); runtime.extensions[0].render({client: harness([]).client, container: root}); await ticks();
|
|
assert.match(allText(root), /temporarily unavailable/); assert.equal(allText(root).includes('secret'), false);
|
|
await assert.rejects(runtime.decideApproval('apr_fake1234', 'once'), /presented/);
|
|
await assert.rejects(runtime.privacyChoice('dismiss'), /not presented/);
|
|
|
|
const privacyRoot = new FakeNode('section'); runtime.extensions[1].render({client: harness([]).client,
|
|
container: privacyRoot}); await ticks();
|
|
assert.match(allText(privacyRoot), /temporarily unavailable/);
|
|
});
|
|
|
|
test('event handlers contain failed mutations without claiming success', async () => {
|
|
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage()),
|
|
response(200, policy({revision: 1}), {ETag: '1'}), response(200, approval()),
|
|
response(200, {...receipt(), run_id: 'other'})]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, runId: RUN_ID, fetcher: h.fetcher,
|
|
canStopModelResponse: () => true, stopModelResponse: async () => ({accepted: true})});
|
|
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
|
findText(root, 'Save autonomy').click(); await ticks();
|
|
findText(root, 'Allow once').click(); await ticks();
|
|
findText(root, 'Stop model response').click(); await ticks();
|
|
assert.match(allText(root), /tool\/side-effect cancellation unverified/);
|
|
|
|
const ph = harness([response(200, privacyPolicy({topic_scoping: {
|
|
scope_to_conversation: true, cross_surface_sharing: 'same_owner_only'}})),
|
|
response(200, {items: [], next: null}), response(201, notice()), response(200, {})]);
|
|
const pr = api.createRuntime({document, conversationId: CONVERSATION, fetcher: ph.fetcher});
|
|
const privacyRoot = new FakeNode('section'); pr.extensions[1].render({client: ph.client, container: privacyRoot}); await ticks();
|
|
await pr.showPrivacyNotice('health'); findText(privacyRoot, 'disable memory here').click(); await ticks();
|
|
assert.match(allText(privacyRoot), /temporarily unavailable/);
|
|
|
|
const badNotice = harness([response(200, privacyPolicy()), response(200, {items: [], next: null}), response(201, {})]);
|
|
const nr = api.createRuntime({document, conversationId: CONVERSATION, fetcher: badNotice.fetcher});
|
|
nr.extensions[1].render({client: badNotice.client, container: new FakeNode('section')}); await ticks();
|
|
await assert.rejects(nr.showPrivacyNotice('health'), /Invalid privacy notice/);
|
|
badNotice.responses.push(response(201, notice()));
|
|
assert.ok(await nr.showPrivacyNotice('health'));
|
|
|
|
const noHeaders = harness([]);
|
|
noHeaders.responses.push({status: 200, ok: true, json: async () => policy()});
|
|
noHeaders.responses.push(response(200, approvalPage([])));
|
|
const noHeaderRuntime = api.createRuntime({document, conversationId: CONVERSATION,
|
|
fetcher: noHeaders.fetcher});
|
|
const noHeaderRoot = new FakeNode('section'); noHeaderRuntime.extensions[0].render({client: noHeaders.client,
|
|
container: noHeaderRoot}); await ticks();
|
|
assert.match(allText(noHeaderRoot), /temporarily unavailable/);
|
|
|
|
const plainApproval = harness([response(200, policy(), {ETag: '1'}),
|
|
response(200, approvalPage([approval({request: {...approval().request, external: false}})]))]);
|
|
const plainRuntime = api.createRuntime({document, conversationId: CONVERSATION,
|
|
fetcher: plainApproval.fetcher});
|
|
const plainRoot = new FakeNode('section'); plainRuntime.extensions[0].render({client: plainApproval.client,
|
|
container: plainRoot}); await ticks();
|
|
assert.equal(allText(plainRoot).includes('external'), false);
|
|
});
|
|
|
|
function inlineItems(count) {
|
|
return Array.from({length: count}, (_, index) => approval({id: `apr_inline000${index + 1}`,
|
|
requested_at: `2026-08-24T10:0${index + 1}:00Z`,
|
|
request: {...approval().request, summary: `req ${index + 1}`}}));
|
|
}
|
|
|
|
test('inline approval prompts stack newest first near the chat flow and decide in place', async () => {
|
|
const inlineHost = new FakeNode('main');
|
|
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage(inlineItems(4))),
|
|
response(200, approval({id: 'apr_inline0004', requested_at: '2026-08-24T10:04:00Z',
|
|
request: {...approval().request, summary: 'req 4'}, status: 'approved',
|
|
decision: {choice: 'always', by: {type: 'user', id: IDENTITY.userRef}, at: STAMP}})),
|
|
response(200, policy(), {ETag: '1'}), response(200, approvalPage([]))]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher, inlineHost});
|
|
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
|
const dock = inlineHost.children[0];
|
|
assert.equal(dock.attributes.role, 'region');
|
|
assert.equal(dock.attributes['aria-live'], 'polite');
|
|
assert.equal(dock.attributes['aria-label'], 'Approvals needed');
|
|
assert.equal(dock.hidden, false);
|
|
const cards = dock.children.filter((node) => node.tagName === 'ARTICLE');
|
|
assert.equal(cards.length, 3, 'at most three prompts are visible');
|
|
assert.deepEqual(cards.map((card) => card.children[0].textContent), ['req 4', 'req 3', 'req 2']);
|
|
assert.match(allText(dock), /1 more waiting in the workspace drawer/);
|
|
assert(findText(cards[0], 'Allow once') && findText(cards[0], 'Always allow') && findText(cards[0], 'Deny'));
|
|
assert.equal(findText(cards[0], 'Allow this session'), undefined, 'inline offers exactly three choices');
|
|
findText(cards[0], 'Always allow').click(); await ticks();
|
|
assert.match(h.calls[2].init.headers['Idempotency-Key'], /^hux:approval-always:/);
|
|
assert.deepEqual(JSON.parse(h.calls[2].init.body), {choice: 'always'});
|
|
const after = inlineHost.children[0].children.filter((node) => node.tagName === 'ARTICLE');
|
|
assert.deepEqual(after.map((card) => card.children[0].textContent), ['req 3', 'req 2', 'req 1']);
|
|
assert.equal(allText(inlineHost).includes('more waiting'), false);
|
|
assert.match(allText(root), /Pending approvals \(3\)/, 'drawer stays in sync');
|
|
runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
|
assert.equal(inlineHost.children.length, 1, 'a re-render never mounts a second dock');
|
|
runtime.destroy(); runtime.destroy();
|
|
assert.equal(inlineHost.children.length, 0, 'destroy removes the inline dock');
|
|
});
|
|
|
|
test('parked approvals poll only while a turn is active and stop with the runtime', async () => {
|
|
let active = true; let tick = null; let cleared = 0;
|
|
const timersApi = {setInterval(callback, ms) { tick = callback; assert.equal(ms, 4000); return 0; },
|
|
clearInterval(handle) { assert.equal(handle, 0); cleared += 1; }};
|
|
const inlineHost = new FakeNode('main');
|
|
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage([])),
|
|
response(200, approvalPage([approval()])), response(500, {}),
|
|
response(200, {items: 'nope', next: null})]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher,
|
|
inlineHost, timers: timersApi, canStopModelResponse: () => active});
|
|
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
|
assert.equal(inlineHost.children[0].hidden, true, 'no pending approvals keeps the dock hidden');
|
|
active = false; tick(); await ticks();
|
|
assert.equal(h.calls.length, 2, 'no polling without an active turn');
|
|
active = true; tick(); await ticks();
|
|
assert.equal(h.calls.length, 3);
|
|
assert.equal(inlineHost.children[0].hidden, false, 'a parked approval surfaces within one poll');
|
|
assert.match(allText(inlineHost), /Send the finished report/);
|
|
tick(); await ticks();
|
|
tick(); await ticks();
|
|
assert.match(allText(inlineHost), /Send the finished report/, 'poll failures keep the last prompt');
|
|
runtime.destroy(); runtime.destroy();
|
|
assert.equal(cleared, 1);
|
|
assert.equal(await runtime.refreshApprovals(), null, 'a destroyed runtime never fetches');
|
|
});
|
|
|
|
test('inline dock defaults to the document element root and tolerates a missing approvals page', async () => {
|
|
const bodyDoc = {createElement: (tag) => new FakeNode(tag), body: new FakeNode('div')};
|
|
const fallback = api.createRuntime({document: bodyDoc, conversationId: CONVERSATION,
|
|
approvalPollMs: 3600000, fetcher: async () => response(500, {})});
|
|
fallback.extensions[0].render({client: harness([]).client, container: new FakeNode('section')}); await ticks();
|
|
assert.equal(bodyDoc.body.children[0].attributes.class, 'hux-runtime-inline-dock');
|
|
assert.equal(bodyDoc.body.children[0].hidden, true);
|
|
fallback.destroy();
|
|
assert.equal(bodyDoc.body.children.length, 0);
|
|
|
|
let tick = null;
|
|
const timersApi = {setInterval(callback) { tick = callback; return 9; }, clearInterval() {}};
|
|
const inlineHost = new FakeNode('main');
|
|
const bad = harness([response(200, policy(), {ETag: '1'}), response(200, {items: 'nope', next: null}),
|
|
response(200, policy({autonomy: 'ask_first', revision: 2}), {ETag: '2'})]);
|
|
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: bad.fetcher,
|
|
inlineHost, timers: timersApi});
|
|
const root = new FakeNode('section'); runtime.extensions[0].render({client: bad.client, container: root}); await ticks();
|
|
assert.match(allText(root), /temporarily unavailable/);
|
|
await runtime.savePolicy('ask_first');
|
|
assert.equal(inlineHost.children[0].hidden, true, 'no approvals page renders no inline prompt');
|
|
tick(); await ticks();
|
|
assert.equal(bad.responses.length, 0);
|
|
runtime.destroy();
|
|
});
|