mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
243 lines
8.4 KiB
TypeScript
243 lines
8.4 KiB
TypeScript
#!/usr/bin/env node
|
|
// Trusted observer only. Candidate shares a network, never this filesystem.
|
|
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import {
|
|
OPENCLAW_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH,
|
|
OPENCLAW_CRABLINE_PROVIDER_READINESS_PATH,
|
|
} from "@openclaw/crabline";
|
|
import { z } from "zod";
|
|
import { createQaCrablineTransportAdapter } from "../../extensions/qa-lab/src/crabline-transport.ts";
|
|
import { startQaGatewayRpcClient } from "../../extensions/qa-lab/src/gateway-rpc-client.ts";
|
|
import { readQaScenarioById } from "../../extensions/qa-lab/src/scenario-catalog.ts";
|
|
import { runScenarioFlow } from "../../extensions/qa-lab/src/scenario-flow-runner.ts";
|
|
import { runQaSuiteScenarioSteps } from "../../extensions/qa-lab/src/suite-runtime-flow.ts";
|
|
import { prepareTelegramQaDevice, telegramQaObserverScopes } from "./telegram-qa-device.ts";
|
|
import {
|
|
isTelegramQaBotApiRequest,
|
|
telegramQaObservationsSchema,
|
|
telegramQaResultSchema,
|
|
telegramQaScenario,
|
|
} from "./telegram-qa-proof.ts";
|
|
|
|
const output = path.resolve(process.argv[2] ?? "/out");
|
|
const scenario = readQaScenarioById(telegramQaScenario);
|
|
if (scenario.execution.kind !== "flow" || !scenario.execution.flow) {
|
|
throw new Error("Trusted QA recipe is not a flow");
|
|
}
|
|
const transport = await createQaCrablineTransportAdapter({
|
|
outputDir: output,
|
|
selection: {
|
|
channel: "telegram",
|
|
channelDriver: "crabline",
|
|
capabilityMatrixPath: OPENCLAW_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH,
|
|
providerReadinessArtifactPath: OPENCLAW_CRABLINE_PROVIDER_READINESS_PATH,
|
|
},
|
|
});
|
|
const channelConfig = transport.createGatewayConfig({ baseUrl: "http://127.0.0.1" });
|
|
const channel = z
|
|
.object({ apiRoot: z.string().url(), botToken: z.string().min(1) })
|
|
.parse(channelConfig.channels?.telegram);
|
|
const upstream = new URL(channel.apiRoot);
|
|
if (upstream.hostname !== "127.0.0.1" || upstream.protocol !== "http:") {
|
|
throw new Error("Crabline must remain loopback-only");
|
|
}
|
|
const token = randomUUID();
|
|
const config = {
|
|
// Take only the channel config. The adapter's transport bootstrap metadata
|
|
// (such as baseUrl) is not part of the candidate Gateway configuration.
|
|
gateway: {
|
|
mode: "local",
|
|
bind: "lan",
|
|
port: 19879,
|
|
auth: { mode: "token", token },
|
|
controlUi: { enabled: false },
|
|
},
|
|
logging: { file: "/state/gateway.log" },
|
|
plugins: { enabled: true, allow: ["telegram"], entries: { telegram: { enabled: true } } },
|
|
channels: {
|
|
...channelConfig.channels,
|
|
telegram: { ...channelConfig.channels?.telegram, apiRoot: "http://proof-observer:18080" },
|
|
},
|
|
};
|
|
// Only Bot API methods cross the container boundary. The candidate cannot call
|
|
// Crabline's admin/input/reset endpoints or write its recorder/evidence files.
|
|
let invalid = false,
|
|
requests = 0;
|
|
const bridge = http.createServer((request, response) => {
|
|
let stage = "admission";
|
|
void (async () => {
|
|
const prefix = `/bot${channel.botToken}/`;
|
|
const requestUrl = request.url ?? "";
|
|
stage = invalid
|
|
? "already-invalid"
|
|
: ++requests > 1024
|
|
? "request-budget"
|
|
: !requestUrl.startsWith(prefix)
|
|
? "bot-capability"
|
|
: !isTelegramQaBotApiRequest(request.method, requestUrl.slice(prefix.length))
|
|
? "api-method"
|
|
: "admitted";
|
|
if (stage !== "admitted") {
|
|
throw new Error("Unsupported Bot API request");
|
|
}
|
|
stage = "request-body";
|
|
const chunks: Buffer[] = [];
|
|
let size = 0;
|
|
for await (const part of request) {
|
|
size += part.length;
|
|
if (size > 65536) {
|
|
throw new Error("Oversized Bot API payload");
|
|
}
|
|
chunks.push(Buffer.from(part));
|
|
}
|
|
if (request.method === "GET" && size !== 0) {
|
|
throw new Error("Diagnostic GET must not contain a body");
|
|
}
|
|
stage = "upstream-fetch";
|
|
const upstreamUrl = new URL(upstream);
|
|
upstreamUrl.pathname = requestUrl;
|
|
const result = await fetch(upstreamUrl, {
|
|
method: request.method,
|
|
headers: { "content-type": request.headers["content-type"] ?? "application/json" },
|
|
body: request.method === "POST" ? Buffer.concat(chunks) : undefined,
|
|
redirect: "error",
|
|
signal: AbortSignal.timeout(35_000),
|
|
});
|
|
stage = "response-body";
|
|
const bytes = Buffer.from(await result.arrayBuffer());
|
|
if (bytes.length > 1024 * 1024) {
|
|
throw new Error("Oversized Bot API response");
|
|
}
|
|
response.writeHead(result.status, { "content-type": "application/json" });
|
|
response.end(bytes);
|
|
})().catch(() => {
|
|
invalid = true;
|
|
// Fixed categories only: no URL, token, request body or candidate text.
|
|
console.error(`Trusted QA Bot API bridge failed at ${stage}`);
|
|
response.writeHead(403);
|
|
response.end();
|
|
});
|
|
});
|
|
let rpc: Awaited<ReturnType<typeof startQaGatewayRpcClient>> | undefined;
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
bridge.once("error", reject);
|
|
bridge.listen(18080, "0.0.0.0", resolve);
|
|
});
|
|
const deviceIdentity = await prepareTelegramQaDevice(output);
|
|
await fs.writeFile(path.join(output, "candidate-config.json"), JSON.stringify(config), {
|
|
flag: "wx",
|
|
});
|
|
const until = Date.now() + 90_000;
|
|
const waitForGatewayHealthy = async () => {
|
|
while (true) {
|
|
if (invalid) {
|
|
throw new Error("Candidate crossed the Bot API boundary");
|
|
}
|
|
try {
|
|
const response = await fetch("http://proof-candidate:19879/readyz", {
|
|
signal: AbortSignal.timeout(1000),
|
|
});
|
|
if (response.ok) {
|
|
await response.body?.cancel();
|
|
return;
|
|
}
|
|
await response.body?.cancel();
|
|
} catch {
|
|
/* The controller starts the candidate after receiving its config. */
|
|
}
|
|
if (Date.now() >= until) {
|
|
throw new Error("Candidate Gateway did not become ready");
|
|
}
|
|
await sleep(100);
|
|
}
|
|
};
|
|
await waitForGatewayHealthy();
|
|
rpc = await startQaGatewayRpcClient({
|
|
wsUrl: "ws://proof-candidate:19879",
|
|
token,
|
|
deviceIdentity,
|
|
scopes: [...telegramQaObserverScopes],
|
|
logs: () => "",
|
|
});
|
|
const client = rpc;
|
|
const gateway = { call: (...args: Parameters<typeof client.request>) => client.request(...args) };
|
|
const env = { gateway, cfg: config, providerMode: "mock-openai", outputDir: output };
|
|
const vars: Record<string, unknown> = {};
|
|
const result = await runScenarioFlow({
|
|
scenarioTitle: scenario.title,
|
|
flow: scenario.execution.flow,
|
|
vars,
|
|
api: {
|
|
scenario,
|
|
config: scenario.execution.config ?? {},
|
|
state: transport.state,
|
|
transport,
|
|
env,
|
|
fs,
|
|
path,
|
|
randomUUID,
|
|
sleep,
|
|
signal: AbortSignal.timeout(180_000),
|
|
waitForGatewayHealthy,
|
|
waitForTransportReady: () => transport.waitReady({ gateway, timeoutMs: 60_000 }),
|
|
runScenario: runQaSuiteScenarioSteps,
|
|
},
|
|
});
|
|
if (invalid) {
|
|
throw new Error("Candidate crossed the Bot API boundary");
|
|
}
|
|
// Copy only complete synthetic observations. Raw logs and absolute paths stay
|
|
// private; an interrupted flow cannot become conclusive failure evidence.
|
|
const rawCases = z
|
|
.array(
|
|
z
|
|
.object({
|
|
acceptedPayloads: z.array(
|
|
z
|
|
.object({
|
|
text: z.string(),
|
|
parseMode: z.string().optional(),
|
|
})
|
|
.strict(),
|
|
),
|
|
})
|
|
.passthrough(),
|
|
)
|
|
.parse(vars.proofs);
|
|
const observed = telegramQaObservationsSchema.parse({
|
|
schema: "mantis.telegram-qa-observations.v1",
|
|
scenario: telegramQaScenario,
|
|
cases: rawCases.map((item) =>
|
|
Object.assign(item, {
|
|
acceptedPayloads: item.acceptedPayloads.map((payload) => ({
|
|
text: payload.text,
|
|
parseMode: payload.parseMode ?? null,
|
|
})),
|
|
}),
|
|
),
|
|
});
|
|
const verdict = telegramQaResultSchema.parse({
|
|
schema: "mantis.telegram-qa-result.v1",
|
|
scenario: telegramQaScenario,
|
|
status: result.status,
|
|
steps: result.steps.map(({ name, status }) => ({ name, status })),
|
|
});
|
|
await fs.writeFile(path.join(output, "qa-observations.json"), JSON.stringify(observed) + "\n", {
|
|
flag: "wx",
|
|
});
|
|
await fs.writeFile(path.join(output, "qa-result.json"), JSON.stringify(verdict) + "\n", {
|
|
flag: "wx",
|
|
});
|
|
} finally {
|
|
await rpc?.stop();
|
|
bridge.closeAllConnections();
|
|
await new Promise<void>((resolve) => {
|
|
bridge.close(() => resolve());
|
|
});
|
|
await transport.cleanupAfterGatewayStop();
|
|
}
|