mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
618fba92bf
* feat(audit): add opt-in execution identity inspection * fix(audit): gate recovery identity retention * fix(audit): keep recovery identity type private * test(audit): type internal recovery fixture * test(audit): split recovery identity coverage * docs(audit): define operator read trust boundary * test(qa): register identity scenario child * fix(audit): enforce shared identity retention bounds * fix(audit): seal public ingress identity boundary * fix(audit): keep ingress guard lint-clean * fix(gateway): preserve advertised method order * chore(protocol): sync advertised method order * fix(protocol): encode audit selector invariants * test(audit): prove exact execution guard * fix(audit): keep identity storage lazy
448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
// Tests for gateway runtime subscription wiring.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
configureExecutionIdentityAdmissionSink,
|
|
enqueueExecutionIdentityContextAtAdmission,
|
|
hasExecutionIdentityAdmissionSink,
|
|
} from "../audit/execution-identity-admission.js";
|
|
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 {
|
|
emitSessionTranscriptUpdate,
|
|
type InternalSessionTranscriptUpdate,
|
|
} from "../sessions/transcript-events.js";
|
|
import { createTaskRecord } from "../tasks/task-registry.js";
|
|
import { getTaskRegistryObservers } from "../tasks/task-registry.store.js";
|
|
import { resetTaskRegistryForTests } from "../tasks/task-runtime.test-helpers.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";
|
|
|
|
function waitForFast<T>(
|
|
callback: () => T | Promise<T>,
|
|
options: { timeout?: number; interval?: number } = {},
|
|
) {
|
|
return vi.waitFor(callback, { interval: 1, ...options });
|
|
}
|
|
|
|
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,
|
|
identityRecorded: 0,
|
|
stopped: 0,
|
|
}));
|
|
const agentEventHandlerMocks = vi.hoisted(() => ({
|
|
create: vi.fn(),
|
|
}));
|
|
const transcriptBroadcastMocks = vi.hoisted(() => ({
|
|
useActualHandler: false,
|
|
readMessageCount: vi.fn(),
|
|
}));
|
|
|
|
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(),
|
|
recordExecutionIdentity: vi.fn(() => {
|
|
auditTestState.identityRecorded += 1;
|
|
return true;
|
|
}),
|
|
stop: vi.fn(async () => {
|
|
auditTestState.stopped += 1;
|
|
}),
|
|
};
|
|
},
|
|
}));
|
|
|
|
vi.mock("./server-chat.js", () => ({
|
|
createAgentEventHandler: (...args: unknown[]) => agentEventHandlerMocks.create(...args),
|
|
}));
|
|
|
|
vi.mock("./server-session-key.js", () => ({
|
|
resolveSessionKeyForRun: () => "agent:main:main",
|
|
}));
|
|
|
|
vi.mock("./session-transcript-readers.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./session-transcript-readers.js")>();
|
|
return {
|
|
...actual,
|
|
readSessionMessageCountAsync: transcriptBroadcastMocks.readMessageCount,
|
|
};
|
|
});
|
|
|
|
vi.mock("./server-session-events.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./server-session-events.js")>();
|
|
return {
|
|
...actual,
|
|
createTranscriptUpdateBroadcastHandler: (
|
|
...args: Parameters<typeof actual.createTranscriptUpdateBroadcastHandler>
|
|
) => {
|
|
if (transcriptBroadcastMocks.useActualHandler) {
|
|
return actual.createTranscriptUpdateBroadcastHandler(...args);
|
|
}
|
|
return () => {
|
|
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.identityRecorded = 0;
|
|
auditTestState.stopped = 0;
|
|
transcriptBroadcastMocks.useActualHandler = false;
|
|
transcriptBroadcastMocks.readMessageCount.mockReset();
|
|
agentEventHandlerMocks.create.mockReset().mockImplementation(() => {
|
|
throw new Error("server-chat lazy load failure");
|
|
});
|
|
installInMemoryTaskRegistryRuntime();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await unsubs?.agentUnsub();
|
|
unsubs?.heartbeatUnsub();
|
|
unsubs?.transcriptUnsub();
|
|
unsubs?.lifecycleUnsub();
|
|
void unsubs?.taskUnsub();
|
|
resetAgentEventsForTest();
|
|
resetTaskRegistryForTests({ persist: false });
|
|
configureExecutionIdentityAdmissionSink(() => 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", startedAt: 1_000 },
|
|
});
|
|
expect(auditTestState.recorded).toBe(1);
|
|
expect(hasExecutionIdentityAdmissionSink()).toBe(true);
|
|
expect(
|
|
enqueueExecutionIdentityContextAtAdmission(
|
|
{
|
|
runId: "gateway-admission",
|
|
agentId: "main",
|
|
ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
|
|
runtime: { kind: "embedded" },
|
|
},
|
|
{ enabled: true, runtimeInstanceId: "runtime-1" },
|
|
)?.accepted,
|
|
).toBe(true);
|
|
expect(auditTestState.identityRecorded).toBe(1);
|
|
await unsubs.agentUnsub();
|
|
expect(auditTestState.stopped).toBe(1);
|
|
expect(hasExecutionIdentityAdmissionSink()).toBe(false);
|
|
});
|
|
|
|
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", startedAt: 1_000 },
|
|
});
|
|
emitAgentEvent({
|
|
runId: "disabled-public",
|
|
stream: "lifecycle",
|
|
data: { phase: "start", startedAt: 1_000 },
|
|
});
|
|
expect(auditTestState.recorded).toBe(0);
|
|
await waitForFast(() => expect(warn).toHaveBeenCalledOnce());
|
|
warn.mockClear();
|
|
// Disabled wiring must still unsubscribe cleanly.
|
|
await unsubs.agentUnsub();
|
|
expect(auditTestState.stopped).toBe(1);
|
|
});
|
|
|
|
it("logs lazy agent event handler failures", async () => {
|
|
unsubs = startGatewayEventSubscriptions(createParams());
|
|
|
|
emitAgentEvent({
|
|
runId: "run-1",
|
|
stream: "lifecycle",
|
|
data: { phase: "start", startedAt: 1_000 },
|
|
});
|
|
|
|
await waitForFast(() => expect(warn).toHaveBeenCalledTimes(1));
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"Agent event dispatch failed",
|
|
expect.objectContaining({ runId: "run-1", stream: "lifecycle" }),
|
|
);
|
|
});
|
|
|
|
it("disposes a loaded agent event handler on unsubscribe", async () => {
|
|
const dispose = vi.fn();
|
|
const handler = Object.assign(vi.fn(), { dispose });
|
|
agentEventHandlerMocks.create.mockReturnValue(handler);
|
|
unsubs = startGatewayEventSubscriptions(createParams());
|
|
|
|
emitAgentEvent({ runId: "run-dispose", stream: "lifecycle", data: { phase: "error" } });
|
|
await waitForFast(() => expect(handler).toHaveBeenCalledOnce());
|
|
|
|
await unsubs.agentUnsub();
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("logs transcript handler failures", async () => {
|
|
unsubs = startGatewayEventSubscriptions(createParams());
|
|
|
|
emitSessionTranscriptUpdate({
|
|
sessionFile: "/tmp/sess.jsonl",
|
|
sessionKey: "agent:main:main",
|
|
} as InternalSessionTranscriptUpdate);
|
|
|
|
await waitForFast(() => expect(warn).toHaveBeenCalledTimes(1));
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"Transcript update dispatch failed",
|
|
expect.objectContaining({ sessionKey: "agent:main:main" }),
|
|
);
|
|
});
|
|
|
|
it("logs real asynchronous transcript failures and recovers the broadcast queue", async () => {
|
|
transcriptBroadcastMocks.useActualHandler = true;
|
|
const persistenceFailure = new Error("session transcript read failed");
|
|
transcriptBroadcastMocks.readMessageCount
|
|
.mockRejectedValueOnce(persistenceFailure)
|
|
.mockResolvedValueOnce(2);
|
|
|
|
const params = createParams();
|
|
params.sessionEventSubscribers.subscribe("conn-transcript");
|
|
unsubs = startGatewayEventSubscriptions(params);
|
|
|
|
const emitMessage = (messageId: string) =>
|
|
emitSessionTranscriptUpdate({
|
|
sessionFile: "/tmp/openclaw-transcript-dispatch.sqlite",
|
|
sessionKey: "agent:main:main",
|
|
message: { role: "assistant", content: [{ type: "text", text: "visible answer" }] },
|
|
messageId,
|
|
target: {
|
|
agentId: "main",
|
|
sessionId: "sess-transcript",
|
|
sessionKey: "agent:main:main",
|
|
storePath: "/tmp/openclaw-transcript-dispatch-sessions.json",
|
|
},
|
|
});
|
|
|
|
emitMessage("failed-message");
|
|
await waitForFast(() =>
|
|
expect(transcriptBroadcastMocks.readMessageCount).toHaveBeenCalledOnce(),
|
|
);
|
|
await waitForFast(() =>
|
|
expect(warn).toHaveBeenCalledWith("Transcript update dispatch failed", {
|
|
sessionKey: "agent:main:main",
|
|
error: persistenceFailure,
|
|
}),
|
|
);
|
|
expect(params.broadcastToConnIds).not.toHaveBeenCalled();
|
|
|
|
emitMessage("recovered-message");
|
|
await waitForFast(() => expect(params.broadcastToConnIds).toHaveBeenCalledOnce());
|
|
expect(params.broadcastToConnIds).toHaveBeenCalledWith(
|
|
"session.message",
|
|
expect.objectContaining({
|
|
sessionKey: "agent:main:main",
|
|
messageId: "recovered-message",
|
|
messageSeq: 2,
|
|
}),
|
|
new Set(["conn-transcript"]),
|
|
);
|
|
expect(transcriptBroadcastMocks.readMessageCount).toHaveBeenCalledTimes(2);
|
|
expect(warn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("logs lifecycle handler failures", async () => {
|
|
unsubs = startGatewayEventSubscriptions(createParams());
|
|
|
|
emitSessionLifecycleEvent({ sessionKey: "agent:main:main", reason: "created" });
|
|
|
|
await waitForFast(() => 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 waitForFast(() => 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 waitForFast(() => 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 waitForFast(() => expect(getTaskRegistryObservers()).not.toBeNull());
|
|
const staleObservers = getTaskRegistryObservers();
|
|
|
|
const replacementBroadcast = vi.fn<SubscriptionParams["broadcast"]>();
|
|
unsubs = startGatewayEventSubscriptions({
|
|
...createParams(),
|
|
broadcast: replacementBroadcast,
|
|
});
|
|
await waitForFast(() => {
|
|
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,
|
|
});
|
|
});
|
|
});
|