mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-22 04:44:03 +00:00
260 lines
7.0 KiB
JavaScript
260 lines
7.0 KiB
JavaScript
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { WebSocket } from "ws";
|
|
import { PROTOCOL_VERSION } from "../../../../dist/gateway/protocol/index.js";
|
|
import { renderBitmapTextPngBase64 } from "../../../../test/helpers/live-image-probe.ts";
|
|
|
|
const port = process.env.PORT;
|
|
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
const appServerLog =
|
|
process.env.OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG ??
|
|
"/tmp/openclaw-codex-media-path-app-server.jsonl";
|
|
const timeoutSeconds = Number.parseInt(
|
|
process.env.OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS ?? "180",
|
|
10,
|
|
);
|
|
|
|
if (!port || !token) {
|
|
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function sha256Base64(data) {
|
|
return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex");
|
|
}
|
|
|
|
function readLoggedRequests() {
|
|
if (!fs.existsSync(appServerLog)) {
|
|
return [];
|
|
}
|
|
return fs
|
|
.readFileSync(appServerLog, "utf8")
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
async function waitFor(label, predicate, timeoutMs) {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
const value = await predicate();
|
|
if (value !== undefined) {
|
|
return value;
|
|
}
|
|
await delay(50);
|
|
}
|
|
throw new Error(`timeout waiting for ${label}`);
|
|
}
|
|
|
|
function wsDataToString(data) {
|
|
if (typeof data === "string") {
|
|
return data;
|
|
}
|
|
if (Buffer.isBuffer(data)) {
|
|
return data.toString("utf8");
|
|
}
|
|
if (Array.isArray(data)) {
|
|
return Buffer.concat(data).toString("utf8");
|
|
}
|
|
return Buffer.from(data).toString("utf8");
|
|
}
|
|
|
|
async function connectGateway() {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error("gateway ws open timeout")), 45_000);
|
|
timer.unref?.();
|
|
ws.once("open", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
ws.once("error", (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
});
|
|
|
|
const events = [];
|
|
const pending = new Map();
|
|
ws.on("message", (data) => {
|
|
let frame;
|
|
try {
|
|
frame = JSON.parse(wsDataToString(data));
|
|
} catch {
|
|
return;
|
|
}
|
|
if (frame?.type === "event" && typeof frame.event === "string") {
|
|
events.push({
|
|
event: frame.event,
|
|
payload: frame.payload && typeof frame.payload === "object" ? frame.payload : {},
|
|
});
|
|
return;
|
|
}
|
|
if (frame?.type !== "res" || typeof frame.id !== "string") {
|
|
return;
|
|
}
|
|
const match = pending.get(frame.id);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
pending.delete(frame.id);
|
|
if (frame.ok === true) {
|
|
match.resolve(frame.payload ?? frame.result);
|
|
return;
|
|
}
|
|
match.reject(new Error(frame.error?.message ?? "gateway request failed"));
|
|
});
|
|
ws.once("close", (code, reason) => {
|
|
const error = new Error(`gateway closed (${code}): ${wsDataToString(reason)}`);
|
|
for (const entry of pending.values()) {
|
|
entry.reject(error);
|
|
}
|
|
pending.clear();
|
|
});
|
|
|
|
function request(method, params, opts = {}) {
|
|
const id = randomUUID();
|
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete(id);
|
|
reject(new Error(`gateway request timeout: ${method}`));
|
|
}, timeoutMs);
|
|
timer.unref?.();
|
|
pending.set(id, {
|
|
resolve: (value) => {
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
},
|
|
});
|
|
ws.send(JSON.stringify({ type: "req", id, method, params: params ?? {} }));
|
|
});
|
|
}
|
|
|
|
await request(
|
|
"connect",
|
|
{
|
|
minProtocol: PROTOCOL_VERSION,
|
|
maxProtocol: PROTOCOL_VERSION,
|
|
client: {
|
|
id: "gateway-client",
|
|
displayName: "docker-codex-media-path",
|
|
version: "1.0.0",
|
|
platform: process.platform,
|
|
mode: "backend",
|
|
},
|
|
role: "operator",
|
|
scopes: ["operator.read", "operator.write", "operator.admin"],
|
|
caps: [],
|
|
auth: { token },
|
|
},
|
|
{ timeoutMs: 60_000 },
|
|
);
|
|
await request("sessions.subscribe", {}, { timeoutMs: 60_000 });
|
|
|
|
return {
|
|
events,
|
|
request,
|
|
async close() {
|
|
if (ws.readyState === WebSocket.CLOSED) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, 2_000);
|
|
timer.unref?.();
|
|
ws.once("close", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
ws.close();
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
const gateway = await connectGateway();
|
|
|
|
function randomBitmapTextToken(length = 6) {
|
|
const alphabet = "24567ACEF";
|
|
return [...randomBytes(length)].map((byte) => alphabet[byte % alphabet.length]).join("");
|
|
}
|
|
|
|
try {
|
|
const expectedToken = randomBitmapTextToken();
|
|
const imageBase64 = renderBitmapTextPngBase64(expectedToken);
|
|
const expectedHash = sha256Base64(imageBase64);
|
|
const runId = `codex-media-path-${randomUUID()}`;
|
|
const started = Date.now();
|
|
|
|
const response = await gateway.request(
|
|
"chat.send",
|
|
{
|
|
sessionKey: "agent:main:codex-media-path-e2e",
|
|
idempotencyKey: runId,
|
|
message: "Read the code printed in the attached image. Reply only the code.",
|
|
attachments: [
|
|
{
|
|
mimeType: "image/png",
|
|
fileName: "codex-media-path-probe.png",
|
|
content: imageBase64,
|
|
},
|
|
],
|
|
originatingChannel: "codex-media-path-e2e",
|
|
originatingTo: "codex-media-path-e2e",
|
|
originatingAccountId: "codex-media-path-e2e",
|
|
},
|
|
{ timeoutMs: timeoutSeconds * 1000 },
|
|
);
|
|
assert(response?.status === "started", `chat.send did not start: ${JSON.stringify(response)}`);
|
|
|
|
const turnRequest = await waitFor(
|
|
"Codex turn/start image input",
|
|
() =>
|
|
readLoggedRequests().find((request) => {
|
|
if (request.method !== "turn/start") {
|
|
return undefined;
|
|
}
|
|
const imageInput = request.params?.input?.find?.(
|
|
(entry) => entry?.type === "image" && typeof entry.url === "string",
|
|
);
|
|
return imageInput ? request : undefined;
|
|
}),
|
|
timeoutSeconds * 1000,
|
|
);
|
|
|
|
const imageInput = turnRequest.params.input.find((entry) => entry?.type === "image");
|
|
const imageUrl = imageInput.url;
|
|
assert(
|
|
imageUrl.startsWith("data:image/png;base64,"),
|
|
`turn/start image input is not an inline PNG: ${JSON.stringify(imageInput)}`,
|
|
);
|
|
const actualBase64 = imageUrl.slice("data:image/png;base64,".length);
|
|
const actualHash = sha256Base64(actualBase64);
|
|
assert(
|
|
actualHash === expectedHash,
|
|
`forwarded PNG hash mismatch: expected ${expectedHash}, got ${actualHash}`,
|
|
);
|
|
|
|
await delay(50);
|
|
console.log(
|
|
JSON.stringify({
|
|
ok: true,
|
|
elapsedMs: Date.now() - started,
|
|
expectedToken,
|
|
imageSha256: actualHash,
|
|
}),
|
|
);
|
|
} finally {
|
|
await gateway.close();
|
|
}
|