mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
10e0e690df
Makes the Mantis Telegram Desktop proof agent programmable at its trust boundaries: declarative Bot API fault rules (drop/status per method), per-request scripted mock-provider responses, observe-until predicates (post-cursor events/text, cumulative provider count), and bounded proxy-side recording of outbound Bot API requests as trusted lane facts. Adds a reusable recipe library under .github/codex/prompts/mantis-recipes/ and raises the proof agent's reasoning effort to high. Security: the SUT container shadows proxy-control with an inaccessible tmpfs so candidate PR code sharing the mantis-sut uid cannot read or rewrite the proxy's recorded evidence; unmount is blocked by cap-drop/no-new-privileges. Proof doctrine now treats proxy-recorded Bot API facts as trusted comparison evidence and provider request logs as diagnostics. Follow-up named in PR: move the mock OpenAI server out of the SUT container so provider request facts also become candidate-tamper-proof.
376 lines
12 KiB
JavaScript
376 lines
12 KiB
JavaScript
#!/usr/bin/env -S node --import tsx
|
|
|
|
import { appendFileSync, closeSync, fstatSync, openSync, readFileSync } from "node:fs";
|
|
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
|
import https from "node:https";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const MAX_RECORDED_BODY_BYTES = 32 * 1024;
|
|
const MAX_TRUNCATED_PREVIEW_BYTES = 8 * 1024;
|
|
|
|
const hopByHopHeaders = new Set([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
]);
|
|
|
|
function requiredEnv(name: string): string {
|
|
const value = process.env[name]?.trim();
|
|
if (!value) {
|
|
throw new Error(`${name} is required`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
}
|
|
|
|
export function rewriteTelegramBotApiPath(
|
|
requestPath: string,
|
|
aliasToken: string,
|
|
upstreamToken: string,
|
|
): string | undefined {
|
|
const alias = escapeRegExp(aliasToken);
|
|
const methodMatch = new RegExp(`^/bot${alias}/([A-Za-z][A-Za-z0-9_]{0,63})(\\?.*)?$`, "u").exec(
|
|
requestPath,
|
|
);
|
|
if (methodMatch) {
|
|
return `/bot${upstreamToken}/${methodMatch[1]}${methodMatch[2] ?? ""}`;
|
|
}
|
|
const fileMatch = new RegExp(`^/file/bot${alias}/([^?#]+)(\\?.*)?$`, "u").exec(requestPath);
|
|
const filePath = fileMatch?.[1];
|
|
if (filePath && !filePath.split("/").includes("..")) {
|
|
return `/file/bot${upstreamToken}/${filePath}${fileMatch[2] ?? ""}`;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function forwardedHeaders(headers: IncomingMessage["headers"]): http.OutgoingHttpHeaders {
|
|
return Object.fromEntries(
|
|
Object.entries(headers).filter(
|
|
([name, value]) => value !== undefined && !hopByHopHeaders.has(name),
|
|
),
|
|
);
|
|
}
|
|
|
|
type FaultRule = {
|
|
method: string;
|
|
mode?: "drop";
|
|
status?: number;
|
|
times?: number;
|
|
};
|
|
|
|
type FaultControlState = {
|
|
remaining: number[];
|
|
rules: FaultRule[];
|
|
version: string;
|
|
};
|
|
|
|
type RequestBodyCapture = {
|
|
contentType?: string;
|
|
requestBody?: unknown;
|
|
};
|
|
|
|
function botApiMethod(requestPath: string, aliasToken: string): string | undefined {
|
|
const match = new RegExp(
|
|
`^/bot${escapeRegExp(aliasToken)}/([A-Za-z][A-Za-z0-9_]{0,63})(?:\\?.*)?$`,
|
|
"u",
|
|
).exec(requestPath);
|
|
return match?.[1];
|
|
}
|
|
|
|
function parseFaultRules(value: unknown): FaultRule[] {
|
|
if (!value || typeof value !== "object" || !("rules" in value) || !Array.isArray(value.rules)) {
|
|
throw new Error("Telegram proxy control rules are invalid");
|
|
}
|
|
return value.rules.map((rawRule) => {
|
|
if (!rawRule || typeof rawRule !== "object" || Array.isArray(rawRule)) {
|
|
throw new Error("Telegram proxy fault rule is invalid");
|
|
}
|
|
const method = "method" in rawRule ? rawRule.method : undefined;
|
|
const times = "times" in rawRule ? rawRule.times : undefined;
|
|
const status = "status" in rawRule ? rawRule.status : undefined;
|
|
const mode = "mode" in rawRule ? rawRule.mode : undefined;
|
|
if (typeof method !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/u.test(method)) {
|
|
throw new Error("Telegram proxy fault rule method is invalid");
|
|
}
|
|
if (times !== undefined && (!Number.isInteger(times) || Number(times) < 1)) {
|
|
throw new Error("Telegram proxy fault rule times is invalid");
|
|
}
|
|
if (
|
|
status !== undefined &&
|
|
(!Number.isInteger(status) || Number(status) < 400 || Number(status) > 599)
|
|
) {
|
|
throw new Error("Telegram proxy fault rule status is invalid");
|
|
}
|
|
if (mode !== undefined && mode !== "drop") {
|
|
throw new Error("Telegram proxy fault rule mode is invalid");
|
|
}
|
|
if (mode === "drop" && status !== undefined) {
|
|
throw new Error("Telegram proxy fault rule cannot combine status and drop");
|
|
}
|
|
return {
|
|
method,
|
|
...(times === undefined ? {} : { times: Number(times) }),
|
|
...(status === undefined ? {} : { status: Number(status) }),
|
|
...(mode === undefined ? {} : { mode }),
|
|
};
|
|
});
|
|
}
|
|
|
|
function readControlFile(file: string): { text: string; version: string } {
|
|
const descriptor = openSync(file, "r");
|
|
try {
|
|
const stat = fstatSync(descriptor, { bigint: true });
|
|
return {
|
|
text: readFileSync(descriptor, "utf8"),
|
|
version: `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`,
|
|
};
|
|
} finally {
|
|
closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function matchingFaultRule(
|
|
controlFile: string,
|
|
method: string,
|
|
state: { current?: FaultControlState },
|
|
): FaultRule | undefined {
|
|
// Atomic lane writes replace the inode. Read and version one descriptor so a
|
|
// replacement cannot pair new rules with an old finite-use counter.
|
|
const { text, version } = readControlFile(controlFile);
|
|
if (state.current?.version !== version) {
|
|
const rules = parseFaultRules(JSON.parse(text));
|
|
state.current = {
|
|
remaining: rules.map((rule) => rule.times ?? -1),
|
|
rules,
|
|
version,
|
|
};
|
|
}
|
|
const current = state.current;
|
|
for (let index = 0; index < current.rules.length; index += 1) {
|
|
const remaining = current.remaining[index];
|
|
if (current.rules[index]?.method !== method || remaining === undefined || remaining === 0) {
|
|
continue;
|
|
}
|
|
if (remaining !== -1) {
|
|
current.remaining[index] = remaining - 1;
|
|
}
|
|
return current.rules[index];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function boundedBodyMarker(buffer: Buffer, byteLength: number, reason: string): unknown {
|
|
return {
|
|
__mantisTruncated: true,
|
|
byteLength,
|
|
preview: buffer.subarray(0, MAX_TRUNCATED_PREVIEW_BYTES).toString("utf8"),
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function captureRequestBody(request: IncomingMessage): Promise<RequestBodyCapture> {
|
|
const contentType = request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase();
|
|
const multipart = contentType === "multipart/form-data";
|
|
return new Promise((resolve) => {
|
|
const chunks: Buffer[] = [];
|
|
let capturedBytes = 0;
|
|
let byteLength = 0;
|
|
const finish = () => {
|
|
if (multipart) {
|
|
resolve({ contentType: request.headers["content-type"] });
|
|
return;
|
|
}
|
|
const buffer = Buffer.concat(chunks, capturedBytes);
|
|
if (byteLength > MAX_RECORDED_BODY_BYTES) {
|
|
resolve({ requestBody: boundedBodyMarker(buffer, byteLength, "body exceeds 32 KiB") });
|
|
return;
|
|
}
|
|
if (buffer.length === 0) {
|
|
resolve({ requestBody: null });
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(buffer.toString("utf8"));
|
|
if (Buffer.byteLength(JSON.stringify(parsed)) > MAX_RECORDED_BODY_BYTES) {
|
|
resolve({
|
|
requestBody: boundedBodyMarker(buffer, byteLength, "parsed JSON exceeds 32 KiB"),
|
|
});
|
|
return;
|
|
}
|
|
resolve({ requestBody: parsed });
|
|
} catch {
|
|
resolve({ requestBody: boundedBodyMarker(buffer, byteLength, "body is not JSON") });
|
|
}
|
|
};
|
|
let finished = false;
|
|
const finishOnce = () => {
|
|
if (!finished) {
|
|
finished = true;
|
|
finish();
|
|
}
|
|
};
|
|
request.on("data", (chunk: Buffer) => {
|
|
byteLength += chunk.length;
|
|
if (!multipart && capturedBytes < MAX_RECORDED_BODY_BYTES) {
|
|
const captured = chunk.subarray(0, MAX_RECORDED_BODY_BYTES - capturedBytes);
|
|
chunks.push(captured);
|
|
capturedBytes += captured.length;
|
|
}
|
|
});
|
|
request.once("end", finishOnce);
|
|
request.once("aborted", finishOnce);
|
|
request.once("error", finishOnce);
|
|
});
|
|
}
|
|
|
|
function appendRecord(recordFile: string | undefined, entry: Record<string, unknown>): void {
|
|
if (recordFile) {
|
|
appendFileSync(recordFile, `${JSON.stringify(entry)}\n`);
|
|
}
|
|
}
|
|
|
|
export function createTelegramBotApiProxy(params: {
|
|
aliasToken: string;
|
|
controlFile?: string;
|
|
recordFile?: string;
|
|
upstreamOrigin?: URL;
|
|
upstreamToken: string;
|
|
}): http.Server {
|
|
const upstreamOrigin = params.upstreamOrigin ?? new URL("https://api.telegram.org");
|
|
const transport = upstreamOrigin.protocol === "https:" ? https : http;
|
|
const faultState: { current?: FaultControlState } = {};
|
|
return http.createServer((request: IncomingMessage, response: ServerResponse) => {
|
|
const upstreamPath = rewriteTelegramBotApiPath(
|
|
request.url ?? "",
|
|
params.aliasToken,
|
|
params.upstreamToken,
|
|
);
|
|
if (!upstreamPath) {
|
|
response.writeHead(404).end();
|
|
return;
|
|
}
|
|
const method = botApiMethod(request.url ?? "", params.aliasToken);
|
|
const startedAt = Date.now();
|
|
let injectedRule: FaultRule | undefined;
|
|
try {
|
|
injectedRule =
|
|
method && params.controlFile
|
|
? matchingFaultRule(params.controlFile, method, faultState)
|
|
: undefined;
|
|
} catch (error) {
|
|
response.writeHead(500, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: false, description: "mantis proxy control invalid" }));
|
|
console.error(error);
|
|
return;
|
|
}
|
|
const bodyCapture =
|
|
method && (params.recordFile || injectedRule)
|
|
? captureRequestBody(request)
|
|
: Promise.resolve({});
|
|
if (method && injectedRule) {
|
|
void bodyCapture.then((body) => {
|
|
const status = injectedRule.mode === "drop" ? null : (injectedRule.status ?? 500);
|
|
try {
|
|
appendRecord(params.recordFile, {
|
|
at: new Date().toISOString(),
|
|
method,
|
|
status,
|
|
ok: false,
|
|
injected: true,
|
|
durationMs: Date.now() - startedAt,
|
|
...body,
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
request.socket.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
return;
|
|
}
|
|
if (injectedRule.mode === "drop") {
|
|
request.socket.destroy();
|
|
return;
|
|
}
|
|
response.writeHead(status ?? 500, { "content-type": "application/json" });
|
|
response.end(
|
|
JSON.stringify({
|
|
ok: false,
|
|
error_code: status ?? 500,
|
|
description: "mantis injected fault",
|
|
}),
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
let recorded = false;
|
|
const record = async (status: number) => {
|
|
if (!method || recorded) {
|
|
return;
|
|
}
|
|
recorded = true;
|
|
appendRecord(params.recordFile, {
|
|
at: new Date().toISOString(),
|
|
method,
|
|
status,
|
|
ok: status >= 200 && status < 300,
|
|
injected: false,
|
|
durationMs: Date.now() - startedAt,
|
|
...(await bodyCapture),
|
|
});
|
|
};
|
|
const upstream = transport.request(
|
|
{
|
|
headers: { ...forwardedHeaders(request.headers), host: upstreamOrigin.host },
|
|
hostname: upstreamOrigin.hostname,
|
|
method: request.method,
|
|
path: upstreamPath,
|
|
port: upstreamOrigin.port || undefined,
|
|
protocol: upstreamOrigin.protocol,
|
|
},
|
|
(upstreamResponse) => {
|
|
const status = upstreamResponse.statusCode ?? 502;
|
|
response.writeHead(status, forwardedHeaders(upstreamResponse.headers));
|
|
upstreamResponse.once("end", () => {
|
|
void record(status).catch((error: unknown) => {
|
|
console.error(error);
|
|
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
});
|
|
});
|
|
upstreamResponse.pipe(response);
|
|
},
|
|
);
|
|
upstream.setTimeout(90_000, () => upstream.destroy(new Error("Telegram Bot API timed out")));
|
|
upstream.on("error", () => {
|
|
void record(502).catch((error: unknown) => console.error(error));
|
|
if (!response.headersSent) {
|
|
response.writeHead(502);
|
|
}
|
|
response.end();
|
|
});
|
|
request.pipe(upstream);
|
|
});
|
|
}
|
|
|
|
function main(): void {
|
|
const server = createTelegramBotApiProxy({
|
|
aliasToken: requiredEnv("TELEGRAM_PROXY_ALIAS_TOKEN"),
|
|
controlFile: process.env.TELEGRAM_PROXY_CONTROL?.trim() || undefined,
|
|
recordFile: process.env.TELEGRAM_PROXY_RECORD_FILE?.trim() || undefined,
|
|
upstreamToken: requiredEnv("TELEGRAM_PROXY_UPSTREAM_TOKEN"),
|
|
});
|
|
server.listen(8080, "0.0.0.0", () => console.log("Telegram Bot API proxy listening"));
|
|
const close = () => server.close();
|
|
process.once("SIGINT", close);
|
|
process.once("SIGTERM", close);
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main();
|
|
}
|