'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(); });