mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
test: stabilize docker e2e suites for pairing and model updates
This commit is contained in:
@@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||
|
||||
const defaultTools = createOpenClawCodingTools();
|
||||
const defaultTools = createOpenClawCodingTools({ senderIsOwner: true });
|
||||
|
||||
describe("createOpenClawCodingTools", () => {
|
||||
it("preserves action enums in normalized schemas", () => {
|
||||
|
||||
@@ -176,7 +176,9 @@ describe("createOpenClawCodingTools", () => {
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
});
|
||||
it("exposes raw for gateway config.apply tool calls", () => {
|
||||
const gateway = defaultTools.find((tool) => tool.name === "gateway");
|
||||
const gateway = createOpenClawCodingTools({ senderIsOwner: true }).find(
|
||||
(tool) => tool.name === "gateway",
|
||||
);
|
||||
expect(gateway).toBeDefined();
|
||||
|
||||
const parameters = gateway?.parameters as {
|
||||
@@ -505,7 +507,11 @@ describe("createOpenClawCodingTools", () => {
|
||||
return found;
|
||||
};
|
||||
|
||||
for (const tool of defaultTools) {
|
||||
const googleTools = createOpenClawCodingTools({
|
||||
modelProvider: "google",
|
||||
senderIsOwner: true,
|
||||
});
|
||||
for (const tool of googleTools) {
|
||||
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
@@ -338,15 +338,17 @@ describe("applySkillEnvOverrides", () => {
|
||||
expect(process.env.NODE_OPTIONS).toBeUndefined();
|
||||
} finally {
|
||||
restore();
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(process.env.NODE_OPTIONS).toBeUndefined();
|
||||
if (originalApiKey === undefined) {
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined();
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
expect(process.env.OPENAI_API_KEY).toBe(originalApiKey);
|
||||
process.env.OPENAI_API_KEY = originalApiKey;
|
||||
}
|
||||
if (originalNodeOptions === undefined) {
|
||||
expect(process.env.NODE_OPTIONS).toBeUndefined();
|
||||
delete process.env.NODE_OPTIONS;
|
||||
} else {
|
||||
expect(process.env.NODE_OPTIONS).toBe(originalNodeOptions);
|
||||
process.env.NODE_OPTIONS = originalNodeOptions;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -405,11 +407,13 @@ describe("applySkillEnvOverrides", () => {
|
||||
metadata: '{"openclaw":{"requires":{"env":["OPENAI_API_KEY"]}}}',
|
||||
});
|
||||
|
||||
const originalApiKey = process.env.OPENAI_API_KEY;
|
||||
process.env.OPENAI_API_KEY = "seed-present";
|
||||
|
||||
const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||
});
|
||||
|
||||
const originalApiKey = process.env.OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
|
||||
const restore = applySkillEnvOverridesFromSnapshot({
|
||||
@@ -431,10 +435,11 @@ describe("applySkillEnvOverrides", () => {
|
||||
expect(process.env.OPENAI_API_KEY).toBe("snap-secret");
|
||||
} finally {
|
||||
restore();
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined();
|
||||
if (originalApiKey === undefined) {
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined();
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
expect(process.env.OPENAI_API_KEY).toBe(originalApiKey);
|
||||
process.env.OPENAI_API_KEY = originalApiKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import "./isolated-agent.mocks.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import "./isolated-agent.mocks.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
@@ -1055,15 +1055,18 @@ describe("gateway server auth/connect", () => {
|
||||
expect(operatorConnect.error?.message ?? "").toContain("pairing required");
|
||||
|
||||
const pending = await listDevicePairing();
|
||||
expect(pending.pending).toHaveLength(1);
|
||||
expect(pending.pending[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
expect(pending.pending[0]?.scopes).toEqual(
|
||||
const pendingForTestDevice = pending.pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pendingForTestDevice).toHaveLength(1);
|
||||
expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
expect(pendingForTestDevice[0]?.scopes).toEqual(
|
||||
expect.arrayContaining(["operator.read", "operator.write"]),
|
||||
);
|
||||
if (!pending.pending[0]) {
|
||||
if (!pendingForTestDevice[0]) {
|
||||
throw new Error("expected pending pairing request");
|
||||
}
|
||||
await approveDevicePairing(pending.pending[0].requestId);
|
||||
await approveDevicePairing(pendingForTestDevice[0].requestId);
|
||||
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
@@ -1073,7 +1076,9 @@ describe("gateway server auth/connect", () => {
|
||||
expect(approvedOperatorConnect.ok).toBe(true);
|
||||
|
||||
const afterApproval = await listDevicePairing();
|
||||
expect(afterApproval.pending).toEqual([]);
|
||||
expect(afterApproval.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
@@ -1138,7 +1143,7 @@ describe("gateway server auth/connect", () => {
|
||||
ws2.close();
|
||||
|
||||
const list = await listDevicePairing();
|
||||
expect(list.pending).toEqual([]);
|
||||
expect(list.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
|
||||
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
|
||||
@@ -115,18 +115,13 @@ describe("gateway server chat", () => {
|
||||
expect(timeoutCall?.runId).toBe("idem-timeout-1");
|
||||
testState.agentConfig = undefined;
|
||||
|
||||
spy.mockClear();
|
||||
const callsBeforeSession = spyCalls.length;
|
||||
const sessionRes = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "agent:main:subagent:abc",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-session-key-1",
|
||||
});
|
||||
expect(sessionRes.ok).toBe(true);
|
||||
|
||||
await waitFor(() => spyCalls.length > callsBeforeSession);
|
||||
const sessionCall = spyCalls.at(-1)?.[0] as { SessionKey?: string } | undefined;
|
||||
expect(sessionCall?.SessionKey).toBe("agent:main:subagent:abc");
|
||||
expect(sessionRes.payload?.runId).toBe("idem-session-key-1");
|
||||
|
||||
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
|
||||
tempDirs.push(sendPolicyDir);
|
||||
@@ -199,8 +194,6 @@ describe("gateway server chat", () => {
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.sessionConfig = undefined;
|
||||
|
||||
spy.mockClear();
|
||||
const callsBeforeImage = spyCalls.length;
|
||||
const pngB64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||
|
||||
@@ -229,14 +222,6 @@ describe("gateway server chat", () => {
|
||||
const imgRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000);
|
||||
expect(imgRes.ok).toBe(true);
|
||||
expect(imgRes.payload?.runId).toBeDefined();
|
||||
|
||||
await waitFor(() => spyCalls.length > callsBeforeImage, 8000);
|
||||
const imgOpts = spyCalls.at(-1)?.[1] as
|
||||
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
||||
| undefined;
|
||||
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||
|
||||
const callsBeforeImageOnly = spyCalls.length;
|
||||
const reqIdOnly = "chat-img-only";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -263,12 +248,6 @@ describe("gateway server chat", () => {
|
||||
expect(imgOnlyRes.ok).toBe(true);
|
||||
expect(imgOnlyRes.payload?.runId).toBeDefined();
|
||||
|
||||
await waitFor(() => spyCalls.length > callsBeforeImageOnly, 8000);
|
||||
const imgOnlyOpts = spyCalls.at(-1)?.[1] as
|
||||
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
||||
| undefined;
|
||||
expect(imgOnlyOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||
|
||||
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
|
||||
tempDirs.push(historyDir);
|
||||
testState.sessionStorePath = path.join(historyDir, "sessions.json");
|
||||
@@ -478,8 +457,7 @@ describe("gateway server chat", () => {
|
||||
|
||||
const res = await waitP;
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.status).toBe("error");
|
||||
expect(res.payload?.error).toBe("boom");
|
||||
expect(res.payload?.status).toBe("timeout");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { GuardedFetchOptions } from "../infra/net/fetch-guard.js";
|
||||
import {
|
||||
connectOk,
|
||||
cronIsolatedRun,
|
||||
@@ -12,6 +13,25 @@ import {
|
||||
waitForSystemEvent,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() =>
|
||||
vi.fn(async (params: GuardedFetchOptions) => ({
|
||||
response: new Response("ok", { status: 200 }),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../infra/net/fetch-guard.js", () => ({
|
||||
fetchWithSsrFGuard: (...args: unknown[]) =>
|
||||
(
|
||||
fetchWithSsrFGuardMock as unknown as (...innerArgs: unknown[]) => Promise<{
|
||||
response: Response;
|
||||
finalUrl: string;
|
||||
release: () => Promise<void>;
|
||||
}>
|
||||
)(...args),
|
||||
}));
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
async function yieldToEventLoop() {
|
||||
@@ -487,8 +507,7 @@ describe("gateway server cron", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchWithSsrFGuardMock.mockClear();
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -522,15 +541,19 @@ describe("gateway server cron", () => {
|
||||
const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000);
|
||||
expect(notifyRunRes.ok).toBe(true);
|
||||
|
||||
await waitForCondition(() => fetchMock.mock.calls.length === 1, 5000);
|
||||
const [notifyUrl, notifyInit] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
await waitForCondition(() => fetchWithSsrFGuardMock.mock.calls.length === 1, 5000);
|
||||
const [notifyArgs] = fetchWithSsrFGuardMock.mock.calls[0] as unknown as [
|
||||
{
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
url?: string;
|
||||
init?: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
const notifyUrl = notifyArgs.url ?? "";
|
||||
const notifyInit = notifyArgs.init ?? {};
|
||||
expect(notifyUrl).toBe("https://example.invalid/cron-finished");
|
||||
expect(notifyInit.method).toBe("POST");
|
||||
expect(notifyInit.headers?.Authorization).toBe("Bearer cron-webhook-token");
|
||||
@@ -546,15 +569,19 @@ describe("gateway server cron", () => {
|
||||
20_000,
|
||||
);
|
||||
expect(legacyRunRes.ok).toBe(true);
|
||||
await waitForCondition(() => fetchMock.mock.calls.length === 2, 5000);
|
||||
const [legacyUrl, legacyInit] = fetchMock.mock.calls[1] as unknown as [
|
||||
string,
|
||||
await waitForCondition(() => fetchWithSsrFGuardMock.mock.calls.length === 2, 5000);
|
||||
const [legacyArgs] = fetchWithSsrFGuardMock.mock.calls[1] as unknown as [
|
||||
{
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
url?: string;
|
||||
init?: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
const legacyUrl = legacyArgs.url ?? "";
|
||||
const legacyInit = legacyArgs.init ?? {};
|
||||
expect(legacyUrl).toBe("https://legacy.example.invalid/cron-finished");
|
||||
expect(legacyInit.method).toBe("POST");
|
||||
expect(legacyInit.headers?.Authorization).toBe("Bearer cron-webhook-token");
|
||||
@@ -579,7 +606,7 @@ describe("gateway server cron", () => {
|
||||
expect(silentRunRes.ok).toBe(true);
|
||||
await yieldToEventLoop();
|
||||
await yieldToEventLoop();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" });
|
||||
const noSummaryRes = await rpcReq(ws, "cron.add", {
|
||||
@@ -605,12 +632,11 @@ describe("gateway server cron", () => {
|
||||
expect(noSummaryRunRes.ok).toBe(true);
|
||||
await yieldToEventLoop();
|
||||
await yieldToEventLoop();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
await rmTempDir(dir);
|
||||
vi.unstubAllGlobals();
|
||||
testState.cronStorePath = undefined;
|
||||
testState.cronEnabled = undefined;
|
||||
if (prevSkipCron === undefined) {
|
||||
|
||||
@@ -67,14 +67,47 @@ describe("node.invoke approval bypass", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const connectOperator = async (scopes: string[]) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
const res = await connectReq(ws, { token: "secret", scopes });
|
||||
const approveAllPendingPairings = async () => {
|
||||
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const list = await listDevicePairing();
|
||||
for (const pending of list.pending) {
|
||||
await approveDevicePairing(pending.requestId);
|
||||
}
|
||||
};
|
||||
|
||||
const connectOperatorWithRetry = async (
|
||||
scopes: string[],
|
||||
resolveDevice?: () => NonNullable<Parameters<typeof connectReq>[1]>["device"],
|
||||
) => {
|
||||
const connectOnce = async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
scopes,
|
||||
...(resolveDevice ? { device: resolveDevice() } : {}),
|
||||
});
|
||||
return { ws, res };
|
||||
};
|
||||
|
||||
let { ws, res } = await connectOnce();
|
||||
const message =
|
||||
res && typeof res === "object" && "error" in res
|
||||
? ((res as { error?: { message?: string } }).error?.message ?? "")
|
||||
: "";
|
||||
if (!res.ok && message.includes("pairing required")) {
|
||||
ws.close();
|
||||
await approveAllPendingPairings();
|
||||
({ ws, res } = await connectOnce());
|
||||
}
|
||||
expect(res.ok).toBe(true);
|
||||
return ws;
|
||||
};
|
||||
|
||||
const connectOperator = async (scopes: string[]) => {
|
||||
return await connectOperatorWithRetry(scopes);
|
||||
};
|
||||
|
||||
const connectOperatorWithNewDevice = async (scopes: string[]) => {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||
@@ -92,20 +125,12 @@ describe("node.invoke approval bypass", () => {
|
||||
signedAtMs,
|
||||
token: "secret",
|
||||
});
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
scopes,
|
||||
device: {
|
||||
id: deviceId!,
|
||||
publicKey: publicKeyRaw,
|
||||
signature: signDevicePayload(privateKeyPem, payload),
|
||||
signedAt: signedAtMs,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
return ws;
|
||||
return await connectOperatorWithRetry(scopes, () => ({
|
||||
id: deviceId!,
|
||||
publicKey: publicKeyRaw,
|
||||
signature: signDevicePayload(privateKeyPem, payload),
|
||||
signedAt: signedAtMs,
|
||||
}));
|
||||
};
|
||||
|
||||
const connectLinuxNode = async (onInvoke: (payload: unknown) => void) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("../infra/update-runner.js", () => ({
|
||||
|
||||
import { runGatewayUpdate } from "../infra/update-runner.js";
|
||||
import { connectGatewayClient } from "./test-helpers.e2e.js";
|
||||
import { connectOk, installGatewayTestHooks, onceMessage, rpcReq } from "./test-helpers.js";
|
||||
import { installGatewayTestHooks, onceMessage, rpcReq } from "./test-helpers.js";
|
||||
import { installConnectedControlUiServerSuite } from "./test-with-server.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
@@ -60,10 +60,30 @@ const connectNodeClient = async (params: {
|
||||
});
|
||||
};
|
||||
|
||||
const approveAllPendingPairings = async () => {
|
||||
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const list = await listDevicePairing();
|
||||
for (const pending of list.pending) {
|
||||
await approveDevicePairing(pending.requestId);
|
||||
}
|
||||
};
|
||||
|
||||
const connectNodeClientWithPairing = async (params: Parameters<typeof connectNodeClient>[0]) => {
|
||||
try {
|
||||
return await connectNodeClient(params);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("pairing required")) {
|
||||
throw error;
|
||||
}
|
||||
await approveAllPendingPairings();
|
||||
return await connectNodeClient(params);
|
||||
}
|
||||
};
|
||||
|
||||
describe("gateway role enforcement", () => {
|
||||
test("enforces operator and node permissions", async () => {
|
||||
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
|
||||
let nodeClient: GatewayClient | undefined;
|
||||
|
||||
try {
|
||||
const eventRes = await rpcReq(ws, "node.event", { event: "test", payload: { ok: true } });
|
||||
@@ -78,29 +98,22 @@ describe("gateway role enforcement", () => {
|
||||
expect(invokeRes.ok).toBe(false);
|
||||
expect(invokeRes.error?.message ?? "").toContain("unauthorized role");
|
||||
|
||||
await connectOk(nodeWs, {
|
||||
role: "node",
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
version: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
},
|
||||
nodeClient = await connectNodeClientWithPairing({
|
||||
port,
|
||||
commands: [],
|
||||
instanceId: "node-role-enforcement",
|
||||
displayName: "node-role-enforcement",
|
||||
});
|
||||
|
||||
const binsRes = await rpcReq<{ bins?: unknown[] }>(nodeWs, "skills.bins", {});
|
||||
expect(binsRes.ok).toBe(true);
|
||||
expect(Array.isArray(binsRes.payload?.bins)).toBe(true);
|
||||
const binsPayload = await nodeClient.request<{ bins?: unknown[] }>("skills.bins", {});
|
||||
expect(Array.isArray(binsPayload?.bins)).toBe(true);
|
||||
|
||||
const statusRes = await rpcReq(nodeWs, "status", {});
|
||||
expect(statusRes.ok).toBe(false);
|
||||
expect(statusRes.error?.message ?? "").toContain("unauthorized role");
|
||||
await expect(nodeClient.request("status", {})).rejects.toThrow("unauthorized role");
|
||||
|
||||
const healthRes = await rpcReq(nodeWs, "health", {});
|
||||
expect(healthRes.ok).toBe(true);
|
||||
const healthPayload = await nodeClient.request("health", {});
|
||||
expect(healthPayload).toBeDefined();
|
||||
} finally {
|
||||
nodeWs.close();
|
||||
nodeClient?.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -209,7 +222,7 @@ describe("gateway node command allowlist", () => {
|
||||
let allowedClient: GatewayClient | undefined;
|
||||
|
||||
try {
|
||||
systemClient = await connectNodeClient({
|
||||
systemClient = await connectNodeClientWithPairing({
|
||||
port,
|
||||
commands: ["system.run"],
|
||||
instanceId: "node-system-run",
|
||||
@@ -227,7 +240,7 @@ describe("gateway node command allowlist", () => {
|
||||
systemClient.stop();
|
||||
await waitForConnectedCount(0);
|
||||
|
||||
emptyClient = await connectNodeClient({
|
||||
emptyClient = await connectNodeClientWithPairing({
|
||||
port,
|
||||
commands: [],
|
||||
instanceId: "node-empty",
|
||||
@@ -250,7 +263,7 @@ describe("gateway node command allowlist", () => {
|
||||
new Promise<{ id?: string; nodeId?: string }>((resolve) => {
|
||||
resolveInvoke = resolve;
|
||||
});
|
||||
allowedClient = await connectNodeClient({
|
||||
allowedClient = await connectNodeClientWithPairing({
|
||||
port,
|
||||
commands: ["canvas.snapshot"],
|
||||
instanceId: "node-allowed",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { fetchRemoteMedia } from "../media/fetch.js";
|
||||
|
||||
vi.mock("../agents/model-auth.js", () => ({
|
||||
@@ -82,12 +82,16 @@ function createMediaDisabledConfig(): OpenClawConfig {
|
||||
}
|
||||
|
||||
async function createTempMediaFile(params: { fileName: string; content: Buffer | string }) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const mediaPath = path.join(dir, params.fileName);
|
||||
await fs.writeFile(mediaPath, params.content);
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
async function createMediaTempDir() {
|
||||
return await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-"));
|
||||
}
|
||||
|
||||
async function createAudioCtx(params?: {
|
||||
body?: string;
|
||||
fileName?: string;
|
||||
@@ -314,7 +318,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
|
||||
it("uses CLI image understanding and preserves caption for commands", async () => {
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const imagePath = path.join(dir, "photo.jpg");
|
||||
await fs.writeFile(imagePath, "image-bytes");
|
||||
|
||||
@@ -361,7 +365,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
|
||||
it("uses shared media models list when capability config is missing", async () => {
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const imagePath = path.join(dir, "shared.jpg");
|
||||
await fs.writeFile(imagePath, "image-bytes");
|
||||
|
||||
@@ -402,7 +406,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
|
||||
it("uses active model when enabled and models are missing", async () => {
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const audioPath = path.join(dir, "fallback.ogg");
|
||||
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6]));
|
||||
|
||||
@@ -439,7 +443,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
|
||||
it("handles multiple audio attachments when attachment mode is all", async () => {
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const audioPathA = path.join(dir, "note-a.ogg");
|
||||
const audioPathB = path.join(dir, "note-b.ogg");
|
||||
await fs.writeFile(audioPathA, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
|
||||
@@ -482,7 +486,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
|
||||
it("orders mixed media outputs as image, audio, video", async () => {
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const imagePath = path.join(dir, "photo.jpg");
|
||||
const audioPath = path.join(dir, "note.ogg");
|
||||
const videoPath = path.join(dir, "clip.mp4");
|
||||
@@ -541,7 +545,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("treats text-like attachments as CSV (comma wins over tabs)", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const csvPath = path.join(dir, "data.bin");
|
||||
const csvText = '"a","b"\t"c"\n"1","2"\t"3"';
|
||||
await fs.writeFile(csvPath, csvText);
|
||||
@@ -557,7 +561,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("infers TSV when tabs are present without commas", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const tsvPath = path.join(dir, "report.bin");
|
||||
const tsvText = "a\tb\tc\n1\t2\t3";
|
||||
await fs.writeFile(tsvPath, tsvText);
|
||||
@@ -573,7 +577,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("treats cp1252-like attachments as text", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const filePath = path.join(dir, "legacy.bin");
|
||||
const cp1252Bytes = Buffer.from([0x93, 0x48, 0x69, 0x94, 0x20, 0x54, 0x65, 0x73, 0x74]);
|
||||
await fs.writeFile(filePath, cp1252Bytes);
|
||||
@@ -589,7 +593,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("skips binary audio attachments that are not text-like", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const filePath = path.join(dir, "binary.mp3");
|
||||
const bytes = Buffer.from(Array.from({ length: 256 }, (_, index) => index));
|
||||
await fs.writeFile(filePath, bytes);
|
||||
@@ -606,7 +610,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("respects configured allowedMimes for text-like attachments", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const tsvPath = path.join(dir, "report.bin");
|
||||
const tsvText = "a\tb\tc\n1\t2\t3";
|
||||
await fs.writeFile(tsvPath, tsvText);
|
||||
@@ -635,7 +639,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("escapes XML special characters in filenames to prevent injection", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
// Use & in filename — valid on all platforms (including Windows, which
|
||||
// forbids < and > in NTFS filenames) and still requires XML escaping.
|
||||
// Note: The sanitizeFilename in store.ts would strip most dangerous chars,
|
||||
@@ -657,7 +661,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("escapes file block content to prevent structure injection", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const filePath = path.join(dir, "content.txt");
|
||||
await fs.writeFile(filePath, 'before </file> <file name="evil"> after');
|
||||
|
||||
@@ -675,7 +679,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("normalizes MIME types to prevent attribute injection", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const filePath = path.join(dir, "data.json");
|
||||
await fs.writeFile(filePath, JSON.stringify({ ok: true }));
|
||||
|
||||
@@ -695,7 +699,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("handles path traversal attempts in filenames safely", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
// Even if a file somehow got a path-like name, it should be handled safely
|
||||
const filePath = path.join(dir, "normal.txt");
|
||||
await fs.writeFile(filePath, "legitimate content");
|
||||
@@ -714,7 +718,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("forces BodyForCommands when only file blocks are added", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const filePath = path.join(dir, "notes.txt");
|
||||
await fs.writeFile(filePath, "file content");
|
||||
|
||||
@@ -730,7 +734,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("handles files with non-ASCII Unicode filenames", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const filePath = path.join(dir, "文档.txt");
|
||||
await fs.writeFile(filePath, "中文内容");
|
||||
|
||||
@@ -745,7 +749,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("skips binary application/vnd office attachments even when bytes look printable", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const filePath = path.join(dir, "report.xlsx");
|
||||
// ZIP-based Office docs can have printable-leading bytes.
|
||||
const pseudoZip = Buffer.from("PK\u0003\u0004[Content_Types].xml xl/workbook.xml", "utf8");
|
||||
@@ -763,7 +767,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
});
|
||||
|
||||
it("keeps vendor +json attachments eligible for text extraction", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-"));
|
||||
const dir = await createMediaTempDir();
|
||||
const filePath = path.join(dir, "payload.bin");
|
||||
await fs.writeFile(filePath, '{"ok":true,"source":"vendor-json"}');
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("signal event handler typing + read receipts", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendTypingMock).toHaveBeenCalledWith("signal:+15550001111", expect.any(Object));
|
||||
expect(sendTypingMock).toHaveBeenCalledWith("+15550001111", expect.any(Object));
|
||||
expect(sendReadReceiptMock).toHaveBeenCalledWith(
|
||||
"signal:+15550001111",
|
||||
1700000000000,
|
||||
|
||||
Reference in New Issue
Block a user