335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
|
|
import assert from "node:assert/strict";
|
||
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import test from "node:test";
|
||
|
|
|
||
|
|
import { createMemoryEndpointContract } from "../../dockerfiles/hermes-webui-hux/memory/endpoints.ts";
|
||
|
|
import {
|
||
|
|
FOUNDATION_FLAG,
|
||
|
|
MEMORY_CONTROL_FLAG,
|
||
|
|
MEMORY_TOPICS,
|
||
|
|
PRIVACY_FLAG,
|
||
|
|
memoryControlEnabled,
|
||
|
|
normalizeMemoryPage,
|
||
|
|
} from "../../dockerfiles/hermes-webui-hux/memory/model.ts";
|
||
|
|
import {
|
||
|
|
MemoryContractError,
|
||
|
|
isControlReference,
|
||
|
|
isOpaqueMemoryId,
|
||
|
|
isUtcTimestamp,
|
||
|
|
normalizeIdentity,
|
||
|
|
safeMemoryText,
|
||
|
|
sameIdentity,
|
||
|
|
} from "../../dockerfiles/hermes-webui-hux/memory/security.ts";
|
||
|
|
|
||
|
|
const IDENTITY = {
|
||
|
|
tenantRef: "tnt_0123456789abcdef",
|
||
|
|
userRef: "usr_0123456789abcdef",
|
||
|
|
surface: "chat",
|
||
|
|
};
|
||
|
|
const FLAGS = [FOUNDATION_FLAG, PRIVACY_FLAG, MEMORY_CONTROL_FLAG];
|
||
|
|
|
||
|
|
function entry(index = 1, extra = {}) {
|
||
|
|
return {
|
||
|
|
schema: "hux.memory.v1",
|
||
|
|
id: `mem_test${String(index).padStart(4, "0")}`,
|
||
|
|
owner: IDENTITY.userRef,
|
||
|
|
scope: { level: "conversation", scope_id: "conv_test1234" },
|
||
|
|
kind: "preference",
|
||
|
|
content: `Concise answer preference ${index}`,
|
||
|
|
status: "active",
|
||
|
|
approval_mode: "ask",
|
||
|
|
sensitivity: "personal",
|
||
|
|
topic: "general",
|
||
|
|
ttl: { policy: "decay", decay_days: 180 },
|
||
|
|
source: { kind: "message", id: `msg-${index}`, uri: "https://secret" },
|
||
|
|
provenance: {
|
||
|
|
surface: "chat",
|
||
|
|
actor: { type: "assistant", id: "hermes", display: "do not copy" },
|
||
|
|
recorded_at: "2026-08-24T10:00:00Z",
|
||
|
|
route: { requested: "thoughtful", resolved_target: "secret-model" },
|
||
|
|
},
|
||
|
|
created_at: "2026-08-24T10:00:00Z",
|
||
|
|
updated_at: `2026-08-24T10:00:${String(index % 60).padStart(2, "0")}Z`,
|
||
|
|
audit: [
|
||
|
|
{
|
||
|
|
at: "2026-08-24T10:00:00Z",
|
||
|
|
action: "approved",
|
||
|
|
actor: { type: "user", id: "private-subject" },
|
||
|
|
note: "Useful response preference",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
...extra,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function page(entries, extra = {}) {
|
||
|
|
return {
|
||
|
|
schema: "hux.memory_page.v1",
|
||
|
|
identity: {
|
||
|
|
tenant_ref: IDENTITY.tenantRef,
|
||
|
|
user_ref: IDENTITY.userRef,
|
||
|
|
surface: IDENTITY.surface,
|
||
|
|
},
|
||
|
|
entries,
|
||
|
|
...extra,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test("memory control requires foundation, privacy, and memory flags", () => {
|
||
|
|
assert.equal(memoryControlEnabled(), false);
|
||
|
|
assert.equal(memoryControlEnabled([]), false);
|
||
|
|
assert.equal(memoryControlEnabled([MEMORY_CONTROL_FLAG]), false);
|
||
|
|
assert.equal(memoryControlEnabled([FOUNDATION_FLAG, MEMORY_CONTROL_FLAG]), false);
|
||
|
|
assert.equal(memoryControlEnabled([FOUNDATION_FLAG, PRIVACY_FLAG]), false);
|
||
|
|
assert.equal(memoryControlEnabled(FLAGS), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("memory topics stay aligned with the existing memory and privacy schemas", () => {
|
||
|
|
const memory = JSON.parse(
|
||
|
|
readFileSync("services/hermes/contracts/hux/memory.schema.json"),
|
||
|
|
);
|
||
|
|
const privacy = JSON.parse(
|
||
|
|
readFileSync("services/hermes/contracts/hux/privacy.schema.json"),
|
||
|
|
);
|
||
|
|
assert.deepEqual(MEMORY_TOPICS, memory.properties.topic.enum);
|
||
|
|
assert.deepEqual(
|
||
|
|
MEMORY_TOPICS.filter((topic) => topic !== "general"),
|
||
|
|
privacy.$defs.topic.enum,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("display text scrubs credentials, controls, and object payloads", () => {
|
||
|
|
assert.equal(safeMemoryText({}, "fallback"), "fallback");
|
||
|
|
assert.equal(safeMemoryText(" \n ", "fallback"), "fallback");
|
||
|
|
assert.equal(safeMemoryText("abc\u0000def", "fallback"), "abc def");
|
||
|
|
assert.equal(safeMemoryText("abcdef", "fallback", 4), "abc…");
|
||
|
|
assert.equal(safeMemoryText("abcdef", "fallback", 0), "…");
|
||
|
|
const scrubbed = safeMemoryText(
|
||
|
|
[
|
||
|
|
"password=hunter2",
|
||
|
|
"Bearer abc.DEF_123",
|
||
|
|
"eyJabcdefgh.abcdefghijkl.abcdefghijkl",
|
||
|
|
"https://user:pass@example.test",
|
||
|
|
"Abcdefghijklmnopqrstuvwxyz1234567890ABCD",
|
||
|
|
"-----BEGIN PRIVATE KEY----- nope -----END PRIVATE KEY-----",
|
||
|
|
].join(" "),
|
||
|
|
"fallback",
|
||
|
|
1000,
|
||
|
|
);
|
||
|
|
assert.doesNotMatch(scrubbed, /hunter2|abc\.DEF|user:pass|1234567890ABCD|nope/);
|
||
|
|
assert.match(scrubbed, /redacted/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("scope primitives reject malformed identities, ids, and timestamps", () => {
|
||
|
|
assert.equal(isOpaqueMemoryId("mem_abcd"), true);
|
||
|
|
assert.equal(isOpaqueMemoryId("evt_abcd"), false);
|
||
|
|
assert.equal(isOpaqueMemoryId(4), false);
|
||
|
|
assert.equal(isControlReference("msg-42"), true);
|
||
|
|
assert.equal(isControlReference("conv_test1234", "conv"), true);
|
||
|
|
assert.equal(isControlReference("msg-42", "conv"), false);
|
||
|
|
assert.equal(isControlReference("../message"), false);
|
||
|
|
assert.equal(isControlReference(4), false);
|
||
|
|
assert.equal(isUtcTimestamp("2026-08-24T10:00:00.123Z"), true);
|
||
|
|
assert.equal(isUtcTimestamp("2026-90-24T10:00:00Z"), false);
|
||
|
|
assert.equal(isUtcTimestamp(4), false);
|
||
|
|
assert.deepEqual(normalizeIdentity(IDENTITY), IDENTITY);
|
||
|
|
assert.equal(sameIdentity(IDENTITY, { ...IDENTITY }), true);
|
||
|
|
assert.equal(sameIdentity(IDENTITY, { ...IDENTITY, surface: "worker" }), false);
|
||
|
|
for (const bad of [null, [], {}, { ...IDENTITY, tenantRef: "tenant" }, { ...IDENTITY, userRef: "user" }, { ...IDENTITY, surface: "web" }]) {
|
||
|
|
assert.throws(() => normalizeIdentity(bad), MemoryContractError);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a valid ledger exposes useful labels but no raw provenance or source ids", () => {
|
||
|
|
const result = normalizeMemoryPage(
|
||
|
|
page([
|
||
|
|
entry(1),
|
||
|
|
entry(2, {
|
||
|
|
scope: { level: "global" },
|
||
|
|
ttl: { policy: "never" },
|
||
|
|
kind: "fact",
|
||
|
|
topic: "finance",
|
||
|
|
source: { kind: "file", id: "/private/path" },
|
||
|
|
provenance: {
|
||
|
|
surface: "chat",
|
||
|
|
actor: { type: "user", id: "private-user" },
|
||
|
|
recorded_at: "2026-08-24T10:00:00Z",
|
||
|
|
},
|
||
|
|
audit: [{ action: "approved", actor: { type: "user", id: "user" } }],
|
||
|
|
}),
|
||
|
|
entry(3, {
|
||
|
|
scope: { level: "project", scope_id: "prj_test1234" },
|
||
|
|
ttl: { policy: "expires_at", expires_at: "2026-09-24T10:00:00Z" },
|
||
|
|
kind: "instruction",
|
||
|
|
status: "proposed",
|
||
|
|
source: { kind: "approval", id: "apr-secret" },
|
||
|
|
audit: [{ note: "token=do-not-display" }],
|
||
|
|
}),
|
||
|
|
], { next_cursor: "cursor.2" }),
|
||
|
|
IDENTITY,
|
||
|
|
);
|
||
|
|
assert.equal(result.invalid, 0);
|
||
|
|
assert.equal(result.nextCursor, "cursor.2");
|
||
|
|
assert.deepEqual(result.entries.map((item) => item.id), ["mem_test0003", "mem_test0002", "mem_test0001"]);
|
||
|
|
assert.equal(result.entries[0].scopeLabel, "This project");
|
||
|
|
assert.equal(result.entries[0].ttlLabel, "Expires 2026-09-24T10:00:00Z");
|
||
|
|
assert.equal(result.entries[0].reason, "token=[redacted]");
|
||
|
|
assert.equal(result.entries[1].scopeLabel, "All conversations");
|
||
|
|
assert.equal(result.entries[1].ttlLabel, "Kept until you forget it");
|
||
|
|
assert.equal(result.entries[1].reason, "Saved for future help");
|
||
|
|
assert.equal(result.entries[1].sourceLabel, "Workspace file");
|
||
|
|
assert.equal(result.entries[1].sourceIsMessage, false);
|
||
|
|
assert.equal(result.entries[2].sourceIsMessage, true);
|
||
|
|
assert.equal(result.entries[1].provenanceLabel, "user via chat");
|
||
|
|
const serialized = JSON.stringify(result.entries);
|
||
|
|
assert.doesNotMatch(serialized, /private\/path|private-user|private-subject|secret-model|apr-secret|do-not-display/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("forgotten and sensitive values fail closed without leaking reasons", () => {
|
||
|
|
const forbidden = "kidney stone and account secret";
|
||
|
|
const result = normalizeMemoryPage(
|
||
|
|
page([
|
||
|
|
entry(1, { status: "forgotten", content: forbidden, audit: [{ note: forbidden }] }),
|
||
|
|
entry(2, { sensitivity: "sensitive", topic: "health", content: forbidden, audit: [{ note: forbidden }] }),
|
||
|
|
entry(3, { sensitivity: "restricted", topic: "credentials", content: forbidden }),
|
||
|
|
entry(4, { sensitivity: "public", content: "visible" }),
|
||
|
|
]),
|
||
|
|
IDENTITY,
|
||
|
|
);
|
||
|
|
const [publicItem, restricted, sensitive, forgotten] = result.entries;
|
||
|
|
assert.equal(publicItem.content, "visible");
|
||
|
|
assert.equal(restricted.content, null);
|
||
|
|
assert.equal(sensitive.content, null);
|
||
|
|
assert.equal(forgotten.content, null);
|
||
|
|
assert.equal(forgotten.contentNotice, "Content permanently removed");
|
||
|
|
assert.match(sensitive.contentNotice, /privacy controls/);
|
||
|
|
assert.doesNotMatch(JSON.stringify(result), new RegExp(forbidden));
|
||
|
|
assert.equal(sensitive.reason, "Reason hidden by privacy controls");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("an omitted optional topic defaults to general without changing approval", () => {
|
||
|
|
const item = normalizeMemoryPage(
|
||
|
|
page([
|
||
|
|
entry(8, {
|
||
|
|
topic: undefined,
|
||
|
|
status: "proposed",
|
||
|
|
audit: [{ action: "proposed", actor: { type: "assistant", id: "hermes" } }],
|
||
|
|
}),
|
||
|
|
]),
|
||
|
|
IDENTITY,
|
||
|
|
).entries[0];
|
||
|
|
assert.equal(item.topic, "general");
|
||
|
|
assert.equal(item.approvalMode, "ask");
|
||
|
|
assert.equal(item.reason, "Hermes suggested this for your approval");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("malformed or cross-owner entries are withheld independently", () => {
|
||
|
|
const invalid = [
|
||
|
|
entry(1, { schema: "hux.memory.v0" }),
|
||
|
|
entry(2, { id: "evt_bad" }),
|
||
|
|
entry(3, { owner: "usr_ffffffffffffffff" }),
|
||
|
|
entry(4, { kind: "password" }),
|
||
|
|
entry(5, { status: "deleted" }),
|
||
|
|
entry(6, { sensitivity: "secret" }),
|
||
|
|
entry(7, { topic: "politics" }),
|
||
|
|
entry(8, { approval_mode: "yes" }),
|
||
|
|
entry(9, { created_at: "today" }),
|
||
|
|
entry(10, { updated_at: "today" }),
|
||
|
|
entry(11, { provenance: null }),
|
||
|
|
entry(12, { provenance: { surface: "worker", actor: { type: "assistant" }, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||
|
|
entry(13, { provenance: { surface: "chat", actor: { type: "assistant" }, recorded_at: "today" } }),
|
||
|
|
entry(14, { provenance: { surface: "chat", actor: null, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||
|
|
entry(15, { provenance: { surface: "chat", actor: { type: "intruder" }, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||
|
|
entry(16, { source: null }),
|
||
|
|
entry(17, { source: { kind: "raw_tool_log", id: "secret" } }),
|
||
|
|
entry(18, { source: { kind: "message", id: "" } }),
|
||
|
|
entry(19, { source: { kind: "message", id: "x".repeat(201) } }),
|
||
|
|
entry(20, { ttl: { policy: "expires_at", expires_at: "today" } }),
|
||
|
|
entry(21, { ttl: { policy: "decay", decay_days: 0 } }),
|
||
|
|
entry(22, { ttl: { policy: "decay", decay_days: 3651 } }),
|
||
|
|
entry(23, { ttl: {} }),
|
||
|
|
entry(24, { scope: null }),
|
||
|
|
entry(25, { scope: { level: "global", scope_id: "should-not-exist" } }),
|
||
|
|
entry(26, { scope: { level: "project" } }),
|
||
|
|
entry(27, { scope: { level: "conversation" } }),
|
||
|
|
entry(28, { content: {} }),
|
||
|
|
entry(29, { content: "x".repeat(2001) }),
|
||
|
|
entry(30, { audit: [] }),
|
||
|
|
entry(31, { audit: null }),
|
||
|
|
entry(32, { provenance: { surface: "chat", actor: { type: "assistant", id: "" }, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||
|
|
entry(33, { provenance: { surface: "chat", actor: { type: "assistant", id: "x".repeat(121) }, recorded_at: "2026-08-24T10:00:00Z" } }),
|
||
|
|
];
|
||
|
|
const result = normalizeMemoryPage(page([entry(99), ...invalid]), IDENTITY);
|
||
|
|
assert.equal(result.entries.length, 1);
|
||
|
|
assert.equal(result.invalid, invalid.length);
|
||
|
|
assert.equal(result.nextCursor, null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("response identity mismatches fail the whole page closed", () => {
|
||
|
|
assert.throws(() => normalizeMemoryPage(null, IDENTITY), MemoryContractError);
|
||
|
|
assert.throws(() => normalizeMemoryPage({ schema: "old" }, IDENTITY), MemoryContractError);
|
||
|
|
assert.throws(
|
||
|
|
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, tenant_ref: "tnt_ffffffffffffffff" } }), IDENTITY),
|
||
|
|
/scope boundary/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, user_ref: "usr_ffffffffffffffff" } }), IDENTITY),
|
||
|
|
/scope boundary/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, surface: "worker" } }), IDENTITY),
|
||
|
|
/scope boundary/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => normalizeMemoryPage(page(null), IDENTITY),
|
||
|
|
/entries are missing/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("page bounds retain newest valid ledger entries", () => {
|
||
|
|
const many = Array.from({ length: 205 }, (_, index) => entry(index + 1));
|
||
|
|
const result = normalizeMemoryPage(page(many, { next_cursor: 7 }), IDENTITY);
|
||
|
|
assert.equal(result.entries.length, 200);
|
||
|
|
assert.equal(result.truncated, 5);
|
||
|
|
assert.equal(result.invalid, 0);
|
||
|
|
assert.equal(result.nextCursor, null);
|
||
|
|
assert.ok(result.entries[0].updatedAt >= result.entries.at(-1).updatedAt);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("endpoint contract is inert by default and describes same-origin calls only", () => {
|
||
|
|
const client = {
|
||
|
|
apiVersion: "hux.v1",
|
||
|
|
identity: IDENTITY,
|
||
|
|
endpoint(path) {
|
||
|
|
assert.match(path, /^\/[a-z0-9/_-]+$/);
|
||
|
|
return `/hux/v1${path}`;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
assert.equal(createMemoryEndpointContract(client), null);
|
||
|
|
assert.equal(createMemoryEndpointContract(client, [MEMORY_CONTROL_FLAG]), null);
|
||
|
|
const contract = createMemoryEndpointContract(client, FLAGS);
|
||
|
|
assert.deepEqual(contract.list, {
|
||
|
|
method: "GET",
|
||
|
|
path: "/hux/v1/memories",
|
||
|
|
responseSchema: "hux.memory_page.v1",
|
||
|
|
});
|
||
|
|
assert.deepEqual(contract.identity, IDENTITY);
|
||
|
|
assert.throws(() => { contract.identity.surface = "worker"; }, TypeError);
|
||
|
|
assert.equal(contract.suggestOnly.method, "PUT");
|
||
|
|
assert.equal(contract.doNotRemember.path, "/hux/v1/memory-blocks");
|
||
|
|
assert.equal(contract.action("mem_abcd", "approve").path, "/hux/v1/memories/mem_abcd/approve");
|
||
|
|
assert.equal(contract.action("mem_abcd", "reject").method, "POST");
|
||
|
|
assert.equal(contract.action("mem_abcd", "forget").responseSchema, "hux.memory.v1");
|
||
|
|
assert.equal(contract.revise("mem_abcd").path, "/hux/v1/memories/mem_abcd/revisions");
|
||
|
|
assert.throws(() => contract.action("bad", "approve"), /Opaque/);
|
||
|
|
assert.throws(() => contract.action("mem_abcd", "delete"), /Unsupported/);
|
||
|
|
assert.throws(
|
||
|
|
() => createMemoryEndpointContract({ ...client, apiVersion: "hux.v2" }, FLAGS),
|
||
|
|
/HUX v1/,
|
||
|
|
);
|
||
|
|
assert.throws(
|
||
|
|
() => createMemoryEndpointContract({ ...client, identity: { ...IDENTITY, userRef: "bad" } }, FLAGS),
|
||
|
|
MemoryContractError,
|
||
|
|
);
|
||
|
|
});
|