Files
openclaw/src/gateway/server-runtime-subscriptions.test.ts
Peter Steinberger 7a466b33f3 feat: add metadata-only message audit events (#103903)
* feat: add metadata-only message audit events

* chore(protocol): restore generated swift models and config baseline after rebase

* fix(state): gate agent-db ownership check before schema version

* fix(cli): sync audit command description into the root-help catalog

* fix(audit): require destination proof before classifying outbound messages as direct

* refactor(audit): drop dead migration guard and add contract comments

* docs(gateway): add dedicated audit history page and cross-links

* test(e2e): enable direct-mode message audit in the telegram proof SUT config

* test(channels): expect declared conversationKind in durable delivery session context

* fix(audit): record the routing channel id for inbound rows from plugin channels

* fix(audit): match channel-prefixed delivery targets in the destination route gate

* fix(audit): validate explicit target kind against the destination route kind

* fix(audit): use the canonical target-prefix grammar in the destination route gate and fail closed on foreign migration tables

* fix(audit): normalize nested provider and kind target prefixes in the destination gate

* fix(audit): strip registered provider aliases and the direct kind prefix in the destination gate

* chore(docs): refresh config baseline after rebase
2026-07-11 13:14:08 -07:00

306 lines
10 KiB
TypeScript

// Tests for gateway runtime subscription wiring.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
emitAgentAuditEvent,
emitAgentEvent,
resetAgentEventsForTest,
} from "../infra/agent-events.js";
import type { SubsystemLogger } from "../logging/subsystem.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import {
emitInternalSessionTranscriptUpdate,
type InternalSessionTranscriptUpdate,
} from "../sessions/transcript-events.js";
import { createTaskRecord, resetTaskRegistryForTests } from "../tasks/task-registry.js";
import { getTaskRegistryObservers } from "../tasks/task-registry.store.js";
import { installInMemoryTaskRegistryRuntime } from "../test-utils/task-registry-runtime.js";
import {
createChatRunState,
createSessionEventSubscriberRegistry,
createSessionMessageSubscriberRegistry,
createToolEventRecipientRegistry,
} from "./server-chat-state.js";
import type { TaskEventPayload } from "./server-methods/task-summary.js";
const warn = vi.fn();
const mockLog: SubsystemLogger = {
subsystem: "gateway-test",
isEnabled: () => true,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn,
error: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child: () => mockLog,
};
const auditTestState = vi.hoisted(() => ({
enabled: true,
messageMode: "off" as "off" | "direct" | "all",
created: 0,
recorded: 0,
stopped: 0,
}));
vi.mock("../audit/audit-config.js", () => ({
isAuditLedgerEnabled: () => auditTestState.enabled,
resolveAuditMessageMode: () => auditTestState.messageMode,
}));
vi.mock("../audit/audit-recorder.js", () => ({
createAuditEventRecorder: () => {
auditTestState.created += 1;
return {
record: vi.fn(() => {
auditTestState.recorded += 1;
}),
recordTool: vi.fn(),
recordMessage: vi.fn(),
stop: vi.fn(async () => {
auditTestState.stopped += 1;
}),
};
},
}));
vi.mock("./server-chat.js", () => {
throw new Error("server-chat lazy load failure");
});
vi.mock("./server-session-key.js", () => ({
resolveSessionKeyForRun: () => "agent:main:main",
}));
vi.mock("./server-session-events.js", () => ({
createTranscriptUpdateBroadcastHandler: () => () => {
throw new Error("transcript handler failure");
},
createLifecycleEventBroadcastHandler: () => () => {
throw new Error("lifecycle handler failure");
},
}));
const { startGatewayEventSubscriptions } = await import("./server-runtime-subscriptions.js");
type SubscriptionParams = Parameters<typeof startGatewayEventSubscriptions>[0];
function createParams(): SubscriptionParams {
return {
log: mockLog,
broadcast: vi.fn(),
broadcastToConnIds: vi.fn(),
nodeSendToSession: vi.fn(),
agentRunSeq: new Map(),
chatRunState: createChatRunState(),
toolEventRecipients: createToolEventRecipientRegistry(),
sessionEventSubscribers: createSessionEventSubscriberRegistry(),
sessionMessageSubscribers: createSessionMessageSubscriberRegistry(),
chatAbortControllers: new Map(),
restartRecoveryCandidates: new Map(),
};
}
describe("startGatewayEventSubscriptions", () => {
let unsubs: ReturnType<typeof startGatewayEventSubscriptions> | undefined;
beforeEach(() => {
vi.clearAllMocks();
auditTestState.enabled = true;
auditTestState.messageMode = "off";
auditTestState.created = 0;
auditTestState.recorded = 0;
auditTestState.stopped = 0;
installInMemoryTaskRegistryRuntime();
});
afterEach(async () => {
await unsubs?.agentUnsub();
unsubs?.heartbeatUnsub();
unsubs?.transcriptUnsub();
unsubs?.lifecycleUnsub();
void unsubs?.taskUnsub();
resetAgentEventsForTest();
resetTaskRegistryForTests({ persist: false });
});
it("records audit events by default and stops the recorder on unsubscribe", async () => {
unsubs = startGatewayEventSubscriptions(createParams());
expect(auditTestState.created).toBe(1);
emitAgentAuditEvent({ runId: "enabled-audit", stream: "lifecycle", data: { phase: "start" } });
expect(auditTestState.recorded).toBe(1);
await unsubs.agentUnsub();
expect(auditTestState.stopped).toBe(1);
});
it("keeps retention maintenance but creates no producers when audit.enabled is false", async () => {
auditTestState.enabled = false;
unsubs = startGatewayEventSubscriptions(createParams());
expect(auditTestState.created).toBe(1);
emitAgentAuditEvent({
runId: "disabled-private",
stream: "lifecycle",
data: { phase: "start" },
});
emitAgentEvent({ runId: "disabled-public", stream: "lifecycle", data: { phase: "start" } });
expect(auditTestState.recorded).toBe(0);
await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce());
warn.mockClear();
// Disabled wiring must still unsubscribe cleanly.
await unsubs.agentUnsub();
expect(auditTestState.stopped).toBe(1);
});
it("logs lazy agent event module failures", async () => {
unsubs = startGatewayEventSubscriptions(createParams());
emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: { phase: "start" } });
await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(1));
expect(warn).toHaveBeenCalledWith(
"Agent event dispatch failed",
expect.objectContaining({ runId: "run-1", stream: "lifecycle" }),
);
});
it("logs transcript handler failures", async () => {
unsubs = startGatewayEventSubscriptions(createParams());
emitInternalSessionTranscriptUpdate({
sessionFile: "/tmp/sess.jsonl",
sessionKey: "agent:main:main",
} as InternalSessionTranscriptUpdate);
await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(1));
expect(warn).toHaveBeenCalledWith(
"Transcript update dispatch failed",
expect.objectContaining({ sessionKey: "agent:main:main" }),
);
});
it("logs lifecycle handler failures", async () => {
unsubs = startGatewayEventSubscriptions(createParams());
emitSessionLifecycleEvent({ sessionKey: "agent:main:main", reason: "created" });
await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(1));
expect(warn).toHaveBeenCalledWith(
"Lifecycle event dispatch failed",
expect.objectContaining({ sessionKey: "agent:main:main" }),
);
});
it("broadcasts bounded public task summaries with ledger statuses", async () => {
const broadcast = vi.fn<SubscriptionParams["broadcast"]>();
unsubs = startGatewayEventSubscriptions({ ...createParams(), broadcast });
await vi.waitFor(() => expect(getTaskRegistryObservers()).not.toBeNull());
const completed = createTaskRecord({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
task: "Completed task",
status: "succeeded",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
terminalSummary: "x".repeat(10_000),
});
const lost = createTaskRecord({
runtime: "cli",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
task: "Lost task",
status: "lost",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
});
if (!completed || !lost) {
throw new Error("expected task records to be created");
}
const taskUpsertsById = new Map(
broadcast.mock.calls
.filter(([event]) => event === "task")
.map(([, payload]) => payload as TaskEventPayload)
.filter(
(payload): payload is Extract<TaskEventPayload, { action: "upserted" }> =>
payload.action === "upserted",
)
.map((payload) => [payload.task.id, payload.task]),
);
expect(broadcast).toHaveBeenCalledWith("task", expect.anything(), { dropIfSlow: true });
// Runtime registry statuses translate to the public ledger vocabulary.
expect(taskUpsertsById.get(completed.taskId)?.status).toBe("completed");
expect(taskUpsertsById.get(lost.taskId)?.status).toBe("failed");
// Unbounded status text from providers/shells must be truncated on the wire.
const wireTerminalSummary = taskUpsertsById.get(completed.taskId)?.terminalSummary;
expect(wireTerminalSummary).toBeTruthy();
expect(wireTerminalSummary?.length ?? 0).toBeLessThan(10_000);
void unsubs?.taskUnsub();
await vi.waitFor(() => expect(getTaskRegistryObservers()).toBeNull());
broadcast.mockClear();
createTaskRecord({
runtime: "cli",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
task: "After dispose",
status: "queued",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
});
expect(broadcast).not.toHaveBeenCalled();
});
it("keeps a replacement gateway's task observer when a stale unsub runs late", async () => {
const staleBroadcast = vi.fn<SubscriptionParams["broadcast"]>();
const staleSubs = startGatewayEventSubscriptions({
...createParams(),
broadcast: staleBroadcast,
});
await vi.waitFor(() => expect(getTaskRegistryObservers()).not.toBeNull());
const staleObservers = getTaskRegistryObservers();
const replacementBroadcast = vi.fn<SubscriptionParams["broadcast"]>();
unsubs = startGatewayEventSubscriptions({
...createParams(),
broadcast: replacementBroadcast,
});
await vi.waitFor(() => {
const current = getTaskRegistryObservers();
expect(current).not.toBeNull();
expect(current).not.toBe(staleObservers);
});
// The stale dispose must not clear the replacement's observer slot.
await staleSubs.taskUnsub();
await staleSubs.agentUnsub();
staleSubs.heartbeatUnsub();
staleSubs.transcriptUnsub();
staleSubs.lifecycleUnsub();
expect(getTaskRegistryObservers()).not.toBeNull();
createTaskRecord({
runtime: "cli",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
task: "After stale dispose",
status: "queued",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
});
expect(replacementBroadcast).toHaveBeenCalledWith("task", expect.anything(), {
dropIfSlow: true,
});
expect(staleBroadcast).not.toHaveBeenCalledWith("task", expect.anything(), {
dropIfSlow: true,
});
});
});