243 lines
14 KiB
JavaScript
243 lines
14 KiB
JavaScript
|
|
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);
|
|||
|
|
});
|