test: dedupe gateway network and transcript suites

This commit is contained in:
Peter Steinberger
2026-03-28 00:26:34 +00:00
parent fef688fb7a
commit 87792c9050
2 changed files with 268 additions and 308 deletions

View File

@@ -14,16 +14,13 @@ import {
} from "./net.js";
describe("resolveHostName", () => {
it("normalizes IPv4/hostname and IPv6 host forms", () => {
const cases = [
{ input: "localhost:18789", expected: "localhost" },
{ input: "127.0.0.1:18789", expected: "127.0.0.1" },
{ input: "[::1]:18789", expected: "::1" },
{ input: "::1", expected: "::1" },
] as const;
for (const testCase of cases) {
expect(resolveHostName(testCase.input), testCase.input).toBe(testCase.expected);
}
it.each([
{ input: "localhost:18789", expected: "localhost" },
{ input: "127.0.0.1:18789", expected: "127.0.0.1" },
{ input: "[::1]:18789", expected: "::1" },
{ input: "::1", expected: "::1" },
] as const)("normalizes host form for $input", ({ input, expected }) => {
expect(resolveHostName(input), input).toBe(expected);
});
});
@@ -280,36 +277,32 @@ describe("resolveClientIp", () => {
});
describe("resolveGatewayListenHosts", () => {
it("resolves listen hosts for non-loopback and loopback variants", async () => {
const cases = [
{
name: "non-loopback host passthrough",
host: "0.0.0.0",
canBindToHost: async () => {
throw new Error("should not be called");
},
expected: ["0.0.0.0"],
it.each([
{
name: "non-loopback host passthrough",
host: "0.0.0.0",
canBindToHost: async () => {
throw new Error("should not be called");
},
{
name: "loopback with IPv6 available",
host: "127.0.0.1",
canBindToHost: async () => true,
expected: ["127.0.0.1", "::1"],
},
{
name: "loopback with IPv6 unavailable",
host: "127.0.0.1",
canBindToHost: async () => false,
expected: ["127.0.0.1"],
},
] as const;
for (const testCase of cases) {
const hosts = await resolveGatewayListenHosts(testCase.host, {
canBindToHost: testCase.canBindToHost,
});
expect(hosts, testCase.name).toEqual(testCase.expected);
}
expected: ["0.0.0.0"],
},
{
name: "loopback with IPv6 available",
host: "127.0.0.1",
canBindToHost: async () => true,
expected: ["127.0.0.1", "::1"],
},
{
name: "loopback with IPv6 unavailable",
host: "127.0.0.1",
canBindToHost: async () => false,
expected: ["127.0.0.1"],
},
] as const)("resolves listen hosts: $name", async ({ host, canBindToHost, expected }) => {
const hosts = await resolveGatewayListenHosts(host, {
canBindToHost,
});
expect(hosts).toEqual(expected);
});
});
@@ -318,47 +311,45 @@ describe("pickPrimaryLanIPv4", () => {
vi.restoreAllMocks();
});
it("prefers en0, then eth0, then any non-internal IPv4, otherwise undefined", () => {
const cases = [
{
name: "prefers en0",
interfaces: makeNetworkInterfacesSnapshot({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
en0: [{ address: "192.168.1.42", family: "IPv4" }],
}),
expected: "192.168.1.42",
},
{
name: "falls back to eth0",
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
eth0: [{ address: "10.0.0.5", family: "IPv4" }],
}),
expected: "10.0.0.5",
},
{
name: "falls back to any non-internal interface",
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
wlan0: [{ address: "172.16.0.99", family: "IPv4" }],
}),
expected: "172.16.0.99",
},
{
name: "no non-internal interface",
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
}),
expected: undefined,
},
] as const;
for (const testCase of cases) {
vi.spyOn(os, "networkInterfaces").mockReturnValue(testCase.interfaces);
expect(pickPrimaryLanIPv4(), testCase.name).toBe(testCase.expected);
vi.restoreAllMocks();
}
});
it.each([
{
name: "prefers en0",
interfaces: makeNetworkInterfacesSnapshot({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
en0: [{ address: "192.168.1.42", family: "IPv4" }],
}),
expected: "192.168.1.42",
},
{
name: "falls back to eth0",
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
eth0: [{ address: "10.0.0.5", family: "IPv4" }],
}),
expected: "10.0.0.5",
},
{
name: "falls back to any non-internal interface",
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
wlan0: [{ address: "172.16.0.99", family: "IPv4" }],
}),
expected: "172.16.0.99",
},
{
name: "no non-internal interface",
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
}),
expected: undefined,
},
] as const)(
"prefers en0, then eth0, then any non-internal IPv4: $name",
({ interfaces, expected }) => {
vi.spyOn(os, "networkInterfaces").mockReturnValue(interfaces);
expect(pickPrimaryLanIPv4()).toBe(expected);
},
);
it("throws when interface discovery throws", () => {
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
@@ -456,48 +447,44 @@ describe("isPrivateOrLoopbackHost", () => {
});
describe("isSecureWebSocketUrl", () => {
it("defaults to loopback-only ws:// and rejects private/public remote ws://", () => {
const cases = [
// wss:// always accepted
{ input: "wss://127.0.0.1:18789", expected: true },
{ input: "wss://localhost:18789", expected: true },
{ input: "wss://remote.example.com:18789", expected: true },
{ input: "wss://192.168.1.100:18789", expected: true },
// ws:// loopback accepted
{ input: "ws://127.0.0.1:18789", expected: true },
{ input: "ws://localhost:18789", expected: true },
{ input: "ws://[::1]:18789", expected: true },
{ input: "ws://127.0.0.42:18789", expected: true },
// ws:// private/public remote addresses rejected by default
{ input: "ws://10.0.0.5:18789", expected: false },
{ input: "ws://10.42.1.100:18789", expected: false },
{ input: "ws://172.16.0.1:18789", expected: false },
{ input: "ws://172.31.255.254:18789", expected: false },
{ input: "ws://192.168.1.100:18789", expected: false },
{ input: "ws://169.254.10.20:18789", expected: false },
{ input: "ws://100.64.0.1:18789", expected: false },
{ input: "ws://[fc00::1]:18789", expected: false },
{ input: "ws://[fd12:3456:789a::1]:18789", expected: false },
{ input: "ws://[fe80::1]:18789", expected: false },
{ input: "ws://[::]:18789", expected: false },
{ input: "ws://[ff02::1]:18789", expected: false },
// ws:// public addresses rejected
{ input: "ws://remote.example.com:18789", expected: false },
{ input: "ws://1.1.1.1:18789", expected: false },
{ input: "ws://8.8.8.8:18789", expected: false },
{ input: "ws://203.0.113.10:18789", expected: false },
// invalid URLs
{ input: "not-a-url", expected: false },
{ input: "", expected: false },
{ input: "http://127.0.0.1:18789", expected: true },
{ input: "https://127.0.0.1:18789", expected: true },
{ input: "https://remote.example.com:18789", expected: true },
{ input: "http://remote.example.com:18789", expected: false },
] as const;
for (const testCase of cases) {
expect(isSecureWebSocketUrl(testCase.input), testCase.input).toBe(testCase.expected);
}
it.each([
// wss:// always accepted
{ input: "wss://127.0.0.1:18789", expected: true },
{ input: "wss://localhost:18789", expected: true },
{ input: "wss://remote.example.com:18789", expected: true },
{ input: "wss://192.168.1.100:18789", expected: true },
// ws:// loopback accepted
{ input: "ws://127.0.0.1:18789", expected: true },
{ input: "ws://localhost:18789", expected: true },
{ input: "ws://[::1]:18789", expected: true },
{ input: "ws://127.0.0.42:18789", expected: true },
// ws:// private/public remote addresses rejected by default
{ input: "ws://10.0.0.5:18789", expected: false },
{ input: "ws://10.42.1.100:18789", expected: false },
{ input: "ws://172.16.0.1:18789", expected: false },
{ input: "ws://172.31.255.254:18789", expected: false },
{ input: "ws://192.168.1.100:18789", expected: false },
{ input: "ws://169.254.10.20:18789", expected: false },
{ input: "ws://100.64.0.1:18789", expected: false },
{ input: "ws://[fc00::1]:18789", expected: false },
{ input: "ws://[fd12:3456:789a::1]:18789", expected: false },
{ input: "ws://[fe80::1]:18789", expected: false },
{ input: "ws://[::]:18789", expected: false },
{ input: "ws://[ff02::1]:18789", expected: false },
// ws:// public addresses rejected
{ input: "ws://remote.example.com:18789", expected: false },
{ input: "ws://1.1.1.1:18789", expected: false },
{ input: "ws://8.8.8.8:18789", expected: false },
{ input: "ws://203.0.113.10:18789", expected: false },
// invalid URLs
{ input: "not-a-url", expected: false },
{ input: "", expected: false },
{ input: "http://127.0.0.1:18789", expected: true },
{ input: "https://127.0.0.1:18789", expected: true },
{ input: "https://remote.example.com:18789", expected: true },
{ input: "http://remote.example.com:18789", expected: false },
] as const)("defaults secure websocket behavior for $input", ({ input, expected }) => {
expect(isSecureWebSocketUrl(input), input).toBe(expected);
});
it("allows private ws:// only when opt-in is enabled", () => {

View File

@@ -57,51 +57,47 @@ describe("readFirstUserMessageFromTranscript", () => {
storePath = nextStorePath;
});
test("extracts first user text across supported content formats", () => {
const cases = [
{
sessionId: "test-session-1",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-1" }),
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
],
expected: "Hello world",
},
{
sessionId: "test-session-2",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-2" }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "Array message content" }],
},
}),
],
expected: "Array message content",
},
{
sessionId: "test-session-2b",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-2b" }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "input_text", text: "Input text content" }],
},
}),
],
expected: "Input text content",
},
] as const;
for (const testCase of cases) {
const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
fs.writeFileSync(transcriptPath, testCase.lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(testCase.sessionId, storePath);
expect(result, testCase.sessionId).toBe(testCase.expected);
}
test.each([
{
sessionId: "test-session-1",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-1" }),
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
],
expected: "Hello world",
},
{
sessionId: "test-session-2",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-2" }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "Array message content" }],
},
}),
],
expected: "Array message content",
},
{
sessionId: "test-session-2b",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-2b" }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "input_text", text: "Input text content" }],
},
}),
],
expected: "Input text content",
},
] as const)("extracts first user text for $sessionId", ({ sessionId, lines, expected }) => {
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result, sessionId).toBe(expected);
});
test("skips non-user messages to find first user message", () => {
const sessionId = "test-session-3";
@@ -198,34 +194,33 @@ describe("readLastMessagePreviewFromTranscript", () => {
expect(result).toBeNull();
});
test("returns the last user or assistant message from transcript", () => {
const cases = [
{
sessionId: "test-last-user",
lines: [
JSON.stringify({ message: { role: "user", content: "First user" } }),
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
JSON.stringify({ message: { role: "user", content: "Last user message" } }),
],
expected: "Last user message",
},
{
sessionId: "test-last-assistant",
lines: [
JSON.stringify({ message: { role: "user", content: "User question" } }),
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
],
expected: "Final assistant reply",
},
] as const;
for (const testCase of cases) {
const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
fs.writeFileSync(transcriptPath, testCase.lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(testCase.sessionId, storePath);
expect(result).toBe(testCase.expected);
}
});
test.each([
{
sessionId: "test-last-user",
lines: [
JSON.stringify({ message: { role: "user", content: "First user" } }),
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
JSON.stringify({ message: { role: "user", content: "Last user message" } }),
],
expected: "Last user message",
},
{
sessionId: "test-last-assistant",
lines: [
JSON.stringify({ message: { role: "user", content: "User question" } }),
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
],
expected: "Final assistant reply",
},
] as const)(
"returns the last user or assistant message from transcript for $sessionId",
({ sessionId, lines, expected }) => {
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe(expected);
},
);
test("skips system messages to find last user/assistant", () => {
const sessionId = "test-last-skip-system";
@@ -266,32 +261,32 @@ describe("readLastMessagePreviewFromTranscript", () => {
expect(result).toBe("Valid first");
});
test("handles array/output_text content formats", () => {
const cases = [
{
sessionId: "test-last-array",
message: {
role: "assistant",
content: [{ type: "text", text: "Array content response" }],
},
expected: "Array content response",
test.each([
{
sessionId: "test-last-array",
message: {
role: "assistant",
content: [{ type: "text", text: "Array content response" }],
},
{
sessionId: "test-last-output-text",
message: {
role: "assistant",
content: [{ type: "output_text", text: "Output text response" }],
},
expected: "Output text response",
expected: "Array content response",
},
{
sessionId: "test-last-output-text",
message: {
role: "assistant",
content: [{ type: "output_text", text: "Output text response" }],
},
] as const;
for (const testCase of cases) {
const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
fs.writeFileSync(transcriptPath, JSON.stringify({ message: testCase.message }), "utf-8");
const result = readLastMessagePreviewFromTranscript(testCase.sessionId, storePath);
expect(result, testCase.sessionId).toBe(testCase.expected);
}
});
expected: "Output text response",
},
] as const)(
"handles array/output_text content format for $sessionId",
({ sessionId, message, expected }) => {
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
fs.writeFileSync(transcriptPath, JSON.stringify({ message }), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result, sessionId).toBe(expected);
},
);
test("skips empty content to find previous message", () => {
const sessionId = "test-last-skip-empty";
@@ -506,56 +501,40 @@ describe("readSessionMessages", () => {
expect(typeof marker.timestamp).toBe("number");
});
test("reads cross-agent absolute sessionFile across store-root layouts", () => {
const cases = [
{
sessionId: "cross-agent-default-root",
sessionFile: path.join(
tmpDir,
"agents",
"ops",
"sessions",
"cross-agent-default-root.jsonl",
),
wrongStorePath: path.join(tmpDir, "agents", "main", "sessions", "sessions.json"),
message: { role: "user", content: "from-ops" },
},
{
sessionId: "cross-agent-custom-root",
sessionFile: path.join(
tmpDir,
"custom",
"agents",
"ops",
"sessions",
"cross-agent-custom-root.jsonl",
),
wrongStorePath: path.join(tmpDir, "custom", "agents", "main", "sessions", "sessions.json"),
message: { role: "assistant", content: "from-custom-ops" },
},
] as const;
for (const testCase of cases) {
fs.mkdirSync(path.dirname(testCase.sessionFile), { recursive: true });
test.each([
{
sessionId: "cross-agent-default-root",
sessionFileParts: ["agents", "ops", "sessions", "cross-agent-default-root.jsonl"],
wrongStorePathParts: ["agents", "main", "sessions", "sessions.json"],
message: { role: "user", content: "from-ops" },
},
{
sessionId: "cross-agent-custom-root",
sessionFileParts: ["custom", "agents", "ops", "sessions", "cross-agent-custom-root.jsonl"],
wrongStorePathParts: ["custom", "agents", "main", "sessions", "sessions.json"],
message: { role: "assistant", content: "from-custom-ops" },
},
] as const)(
"reads cross-agent absolute sessionFile across store-root layouts for $sessionId",
({ sessionId, sessionFileParts, wrongStorePathParts, message }) => {
const sessionFile = path.join(tmpDir, ...sessionFileParts);
const wrongStorePath = path.join(tmpDir, ...wrongStorePathParts);
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
fs.writeFileSync(
testCase.sessionFile,
sessionFile,
[
JSON.stringify({ type: "session", version: 1, id: testCase.sessionId }),
JSON.stringify({ message: testCase.message }),
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message }),
].join("\n"),
"utf-8",
);
const out = readSessionMessages(
testCase.sessionId,
testCase.wrongStorePath,
testCase.sessionFile,
);
const out = readSessionMessages(sessionId, wrongStorePath, sessionFile);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject(testCase.message);
expect(out[0]).toMatchObject(message);
expect((out[0] as { __openclaw?: { seq?: number } }).__openclaw?.seq).toBe(1);
}
});
},
);
});
describe("readSessionPreviewItemsFromTranscript", () => {
@@ -819,29 +798,22 @@ describe("resolveSessionTranscriptCandidates", () => {
});
describe("resolveSessionTranscriptCandidates safety", () => {
test("keeps cross-agent absolute sessionFile for standard and custom store roots", () => {
const cases = [
{
storePath: "/tmp/openclaw/agents/main/sessions/sessions.json",
sessionFile: "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl",
},
{
storePath: "/srv/custom/agents/main/sessions/sessions.json",
sessionFile: "/srv/custom/agents/ops/sessions/sess-safe.jsonl",
},
] as const;
for (const testCase of cases) {
const candidates = resolveSessionTranscriptCandidates(
"sess-safe",
testCase.storePath,
testCase.sessionFile,
);
expect(candidates.map((value) => path.resolve(value))).toContain(
path.resolve(testCase.sessionFile),
);
}
});
test.each([
{
storePath: "/tmp/openclaw/agents/main/sessions/sessions.json",
sessionFile: "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl",
},
{
storePath: "/srv/custom/agents/main/sessions/sessions.json",
sessionFile: "/srv/custom/agents/ops/sessions/sess-safe.jsonl",
},
] as const)(
"keeps cross-agent absolute sessionFile candidate for $storePath",
({ storePath, sessionFile }) => {
const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile);
expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile));
},
);
test("drops unsafe session IDs instead of producing traversal paths", () => {
const candidates = resolveSessionTranscriptCandidates(
@@ -956,34 +928,35 @@ describe("archiveSessionTranscripts", () => {
vi.unstubAllEnvs();
});
test("archives transcript from default and explicit sessionFile paths", () => {
const cases = [
{
sessionId: "sess-archive-1",
transcriptPath: path.join(tmpDir, "sess-archive-1.jsonl"),
args: { sessionId: "sess-archive-1", storePath, reason: "reset" as const },
},
{
test.each([
{
sessionId: "sess-archive-1",
transcriptFileName: "sess-archive-1.jsonl",
buildArgs: () => ({ sessionId: "sess-archive-1", storePath, reason: "reset" as const }),
},
{
sessionId: "sess-archive-2",
transcriptFileName: "custom-transcript.jsonl",
buildArgs: () => ({
sessionId: "sess-archive-2",
transcriptPath: path.join(tmpDir, "custom-transcript.jsonl"),
args: {
sessionId: "sess-archive-2",
storePath: undefined,
sessionFile: path.join(tmpDir, "custom-transcript.jsonl"),
reason: "reset" as const,
},
},
] as const;
for (const testCase of cases) {
fs.writeFileSync(testCase.transcriptPath, '{"type":"session"}\n', "utf-8");
const archived = archiveSessionTranscripts(testCase.args);
storePath: undefined,
sessionFile: path.join(tmpDir, "custom-transcript.jsonl"),
reason: "reset" as const,
}),
},
] as const)(
"archives transcript from default and explicit sessionFile path for $sessionId",
({ transcriptFileName, buildArgs }) => {
const transcriptPath = path.join(tmpDir, transcriptFileName);
const args = buildArgs();
fs.writeFileSync(transcriptPath, '{"type":"session"}\n', "utf-8");
const archived = archiveSessionTranscripts(args);
expect(archived).toHaveLength(1);
expect(archived[0]).toContain(".reset.");
expect(fs.existsSync(testCase.transcriptPath)).toBe(false);
expect(fs.existsSync(transcriptPath)).toBe(false);
expect(fs.existsSync(archived[0])).toBe(true);
}
});
},
);
test("returns empty array when no transcript files exist", () => {
const archived = archiveSessionTranscripts({