From d9d44981fbe6ebb69e8718ea3aa18d783f33b4a3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 22 Aug 2026 16:30:30 +0530 Subject: [PATCH] feat(e2e): record provider media content facts for Mantis proofs (#127830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(e2e): record provider media content facts * docs(mantis): list staged-media recipe in recipe index * fix(mantis): isolate mock provider evidence * fix(mantis): state provider facts as tamper-evident, not provenance-trusted The mock sidecar makes provider request facts tamper-evident: candidate code cannot alter or remove a recorded request. It cannot make them provenance-authenticated — every process in the SUT container shares the internal network, UID, and gateway config, so nothing distinguishes the gateway flow from any other candidate-originated request. The proof prompt and busy-queue recipe now state exactly that property instead of calling the facts trusted. * fix(mantis): redact unparseable provider request bodies Media redaction walks the parsed JSON body, so a request that fails JSON.parse fell back to logging the raw text — leaking base64 payloads the redactor exists to strip. Unparseable bodies now log a bounded byte-count marker instead; regression posts a malformed body carrying a data URL and asserts the payload never reaches the record. * fix(mantis): expose newest provider records through a seq-stamped tail The lane's requests surface kept the first 100 provider records, so a session longer than the window hid exactly the newest requests a proof asserts on. The mock server now stamps each record with a producer-owned absolute seq ordinal, and the lane reads a bounded 128-record tail — mirroring the sibling botApiRequests window. Regression writes 130 records and asserts the tail keeps seq 3..130; it fails pre-fix. --- .../codex/prompts/mantis-recipes/README.md | 1 + .../busy-queue-scripted-provider.md | 4 +- .../staged-media-provider-proof.md | 30 ++++ .../prompts/mantis-telegram-desktop-proof.md | 14 +- scripts/e2e/mock-openai-server.mjs | 150 +++++++++++++++++- scripts/e2e/telegram-mantis-lane.ts | 20 +-- scripts/e2e/telegram-mantis-sut.ts | 11 +- scripts/e2e/telegram-user-crabbox-proof.ts | 4 +- scripts/mantis/mantis-sut-container.sh | 112 +++++++------ test/scripts/e2e-mock-config-limits.test.ts | 106 +++++++++++++ ...is-telegram-desktop-proof-workflow.test.ts | 53 ++++++- test/scripts/telegram-mantis-lane.test.ts | 77 +++++++++ test/scripts/telegram-mantis-sut.test.ts | 2 + .../telegram-user-crabbox-proof.test.ts | 7 + 14 files changed, 507 insertions(+), 84 deletions(-) create mode 100644 .github/codex/prompts/mantis-recipes/staged-media-provider-proof.md diff --git a/.github/codex/prompts/mantis-recipes/README.md b/.github/codex/prompts/mantis-recipes/README.md index b49dd060df12..be1892a460d3 100644 --- a/.github/codex/prompts/mantis-recipes/README.md +++ b/.github/codex/prompts/mantis-recipes/README.md @@ -7,5 +7,6 @@ even when Telegram Desktop pixels match. - `send-failure-injection.md`: outbound Bot API failures and retry behavior - `busy-queue-scripted-provider.md`: ordered slow/fast multi-turn responses - `callback-data-payload-proof.md`: byte-level callback payload differences +- `staged-media-provider-proof.md`: staged Telegram media proven through provider content facts Return to `mantis-telegram-desktop-proof.md` for limits, cleanup, and publishing. diff --git a/.github/codex/prompts/mantis-recipes/busy-queue-scripted-provider.md b/.github/codex/prompts/mantis-recipes/busy-queue-scripted-provider.md index 882d00243025..54f5b7c0168d 100644 --- a/.github/codex/prompts/mantis-recipes/busy-queue-scripted-provider.md +++ b/.github/codex/prompts/mantis-recipes/busy-queue-scripted-provider.md @@ -29,5 +29,5 @@ $lane finish --lane baseline Repeat for `candidate`. Proof facts: session events and recorded Bot API messages show the slow first and distinct second outcomes without a -control-file race. The provider request log (`scriptEntry` 0 then 1, turn -order in bodies) is diagnostic context, not the comparison evidence. +control-file race. The tamper-evident provider request facts (`scriptEntry` 0 +then 1, turn order in bodies) independently prove provider arrival order. diff --git a/.github/codex/prompts/mantis-recipes/staged-media-provider-proof.md b/.github/codex/prompts/mantis-recipes/staged-media-provider-proof.md new file mode 100644 index 000000000000..d74683a71414 --- /dev/null +++ b/.github/codex/prompts/mantis-recipes/staged-media-provider-proof.md @@ -0,0 +1,30 @@ +# Staged media provider proof + +Use when a change alters how an uploaded document or image reaches the provider. + +```bash +lane="$OPENCLAW_TELEGRAM_MANTIS_LANE_CMD" +media="$MANTIS_OUTPUT_DIR/sample.pdf" +sent="$($lane send --lane baseline --media "$media" --text '@{sut} inspect this document')" +message_id="$(jq -er '.sent.messageId' <<<"$sent")" +$lane observe --lane baseline --seconds 60 --until-provider-requests 1 +requests="$($lane requests --lane baseline)" +jq -e '[.requests[].contentFacts[]? | select(.type == "legacy_media")] | length > 0' \ + <<<"$requests" +$lane finish --lane baseline --focus-message-id "$message_id" +``` + +For a reply-mention turn, first `send --media "$media"` without text, capture its +`.sent.messageId`, then `send --reply-to "$message_id" --text '@{sut} inspect this document'`. +A bare unmentioned upload stages the file but produces no provider turn. + +Repeat for `candidate` with its returned message id, selecting `type == "input_file"`. +Assert the complete selected facts: `filename`, `mimeType`, and `byteLength` when present. +The structured facts are comparison evidence; never scrape `body` strings. + +If baseline needs a tool round trip, take the tool argument from the recorded +`legacy_media.filename`. Build a complete response-events JSON array from +`toolCallEvents()` in `scripts/e2e/mock-openai-server.mjs` +(`response.output_item.added`, `response.function_call_arguments.delta`, +`response.output_item.done`, `response.completed`), then install it before the +next turn with `mock --lane baseline --response-events-file `. diff --git a/.github/codex/prompts/mantis-telegram-desktop-proof.md b/.github/codex/prompts/mantis-telegram-desktop-proof.md index ce944468a953..3c8442bf7693 100644 --- a/.github/codex/prompts/mantis-telegram-desktop-proof.md +++ b/.github/codex/prompts/mantis-telegram-desktop-proof.md @@ -80,7 +80,8 @@ Use `$OPENCLAW_TELEGRAM_MANTIS_LANE_CMD` with `--lane baseline|candidate`: [--until-provider-requests N]` (returns early when all supplied conditions hold; event/text conditions count only events after the cursor, provider count is cumulative for the lane) -- `requests` (redacted provider requests; zero is a valid recorded fact) +- `requests` (redacted provider requests; media/file items appear as structured + `contentFacts`; zero is a valid recorded fact) - `press --message-id ID --button INDEX` - `delete --message-id ID` (only user messages sent in this session) - `desktop --actions-file [--timeout-seconds N]` (run an @@ -126,10 +127,13 @@ baseline lane that reproduces the defect is a successful capture. A PR-level pass claim requires an observed, material baseline/candidate difference caused by the changed behavior. That difference may be trusted Bot API payload/status facts even when pixels are identical; screenshots remain comparison context. -Provider request logs are diagnostic and pacing signals, not standalone -comparison evidence. Identical pixels alone do not force `block` when the -trusted recorded facts differ materially. If neither pixels nor trusted facts prove a -difference, use `block`. When the expected result is silence, focus the +Provider request facts are tamper-evident comparison evidence: the provider +sidecar records them outside the candidate runtime, so candidate code cannot +alter or remove a recorded request after the fact. Requests still originate +inside the SUT, so the facts prove what the candidate runtime sent — the +behavior under proof — not who sent it. Identical pixels alone do not force `block` +when the recorded facts differ materially. If neither pixels nor recorded facts +prove a difference, use `block`. When the expected result is silence, focus the session-owned user message that triggered the silent outcome. Decide before finalizing each lane. If its setup did not exercise the intended behavior, call `block`; do not call `finish` and describe the block only in prose. diff --git a/scripts/e2e/mock-openai-server.mjs b/scripts/e2e/mock-openai-server.mjs index c2e641c69b09..96dd844367c7 100644 --- a/scripts/e2e/mock-openai-server.mjs +++ b/scripts/e2e/mock-openai-server.mjs @@ -18,14 +18,149 @@ const port = process.env.MOCK_PORT != null ? readTcpPortEnv("MOCK_PORT") : readTcpPortEnv("OPENCLAW_MOCK_OPENAI_PORT"); +const bindHost = process.env.MOCK_BIND_HOST ?? "127.0.0.1"; const successMarker = process.env.SUCCESS_MARKER ?? "OPENCLAW_E2E_OK"; const requestLog = process.env.MOCK_REQUEST_LOG; +// Absolute record ordinal, stamped at the producer: consumers expose a bounded +// tail of the log, so entries must carry their own position. The server starts +// once per run, so the counter spans the whole session. +let requestLogSeq = 0; const initialResponseChunkDelayMs = process.env.MOCK_RESPONSE_CHUNK_DELAY_MS ? readPositiveIntEnv("MOCK_RESPONSE_CHUNK_DELAY_MS", undefined) : 0; const responseControl = process.env.MOCK_RESPONSE_CONTROL; +const MAX_CONTENT_FACTS = 128; +const MAX_CONTENT_FACT_FILENAME_LENGTH = 1024; +const LEGACY_MEDIA_PATTERN = + /\[media attached: ([^\]\r\n]+?) \(([a-z][a-z0-9.+-]*\/[a-z0-9.+-]+)\)(?: \| [^\]\r\n]+)?\]/giu; +const MEDIA_DATA_URL_PATTERN = + /^data:([a-z][a-z0-9.+-]*\/[a-z0-9.+-]+)(?:;[^,]*)*;base64,([\s\S]*)$/iu; let scriptState; +function parseMediaDataUrl(value) { + if (typeof value !== "string") { + return undefined; + } + const match = MEDIA_DATA_URL_PATTERN.exec(value); + if (!match) { + return undefined; + } + const encoded = (match[2] ?? "").replace(/\s/gu, ""); + const valid = /^[A-Za-z0-9+/]*={0,2}$/u.test(encoded) && encoded.length % 4 !== 1; + const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; + return { + byteLength: valid ? Math.floor((encoded.length * 3) / 4) - padding : undefined, + mimeType: match[1]?.toLowerCase(), + }; +} + +function requestContentFact(item) { + const type = typeof item.type === "string" ? item.type.slice(0, 64) : undefined; + if (!type) { + return undefined; + } + const fact = { type }; + if (type !== "input_file" && type !== "input_image") { + return fact; + } + const imageUrl = + typeof item.image_url === "string" + ? item.image_url + : item.image_url && typeof item.image_url === "object" + ? item.image_url.url + : undefined; + const dataUrl = parseMediaDataUrl(type === "input_file" ? item.file_data : imageUrl); + const filename = + typeof item.filename === "string" + ? item.filename.slice(0, MAX_CONTENT_FACT_FILENAME_LENGTH) + : undefined; + const mimeType = + (typeof item.mimeType === "string" ? item.mimeType.slice(0, 128) : undefined) ?? + (typeof item.mime_type === "string" ? item.mime_type.slice(0, 128) : undefined) ?? + dataUrl?.mimeType; + return { + ...fact, + ...(filename ? { filename } : {}), + ...(mimeType ? { mimeType } : {}), + ...(dataUrl?.byteLength === undefined ? {} : { byteLength: dataUrl.byteLength }), + }; +} + +function summarizeRequestContent(body) { + const facts = []; + let firstFact = 0; + let truncated = false; + const appendFact = (fact) => { + if (facts.length < MAX_CONTENT_FACTS) { + facts.push(fact); + return; + } + facts[firstFact] = fact; + firstFact = (firstFact + 1) % MAX_CONTENT_FACTS; + truncated = true; + }; + const appendLegacyMediaFacts = (text) => { + for (const match of text.matchAll(LEGACY_MEDIA_PATTERN)) { + appendFact({ + type: "legacy_media", + filename: match[1].trim().slice(0, MAX_CONTENT_FACT_FILENAME_LENGTH), + mimeType: match[2].toLowerCase(), + }); + } + }; + const visit = (value) => { + if (typeof value === "string") { + appendFact({ type: "input_text" }); + appendLegacyMediaFacts(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) { + visit(item); + } + return; + } + if (!value || typeof value !== "object") { + return; + } + if (value.type === "message" || typeof value.type !== "string") { + visit(value.content); + return; + } + const fact = requestContentFact(value); + if (!fact) { + return; + } + appendFact(fact); + if ( + (fact.type === "input_text" || fact.type === "output_text") && + typeof value.text === "string" + ) { + appendLegacyMediaFacts(value.text); + } + }; + + visit(body?.input ?? body?.messages); + const contentFacts = truncated + ? [...facts.slice(firstFact), ...facts.slice(0, firstFact)] + : facts; + return { contentFacts, ...(truncated ? { contentFactsTruncated: true } : {}) }; +} + +function redactRequestLogMedia(body, bodyText) { + let redacted = false; + const sanitized = JSON.stringify(body, (_key, value) => { + const dataUrl = parseMediaDataUrl(value); + if (!dataUrl) { + return value; + } + redacted = true; + const bytes = dataUrl.byteLength === undefined ? "unknown" : dataUrl.byteLength; + return `data:${dataUrl.mimeType};base64,[redacted:${bytes} bytes]`; + }); + return redacted ? sanitized : bodyText; +} + function readResponseEntry(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} is invalid`); @@ -676,19 +811,26 @@ const server = http.createServer((req, res) => { } throw error; } - let body; + let body = {}; + let requestLogBody; try { body = bodyText ? JSON.parse(bodyText) : {}; + requestLogBody = redactRequestLogMedia(body, bodyText); } catch { - body = {}; + // Redaction walks the parsed JSON, so an unparseable body would bypass it + // and leak raw base64 media into the provider record. Log a bounded + // marker instead of the text. + requestLogBody = `[unparseable request body redacted: ${Buffer.byteLength(bodyText)} bytes]`; } if ( writeRequestLogEntryOrFail(res, { requestLog, entry: { + seq: (requestLogSeq += 1), method: req.method, path: url.pathname, - body: boundedRequestLogBody(bodyText, bodyText), + body: boundedRequestLogBody(requestLogBody, requestLogBody), + ...summarizeRequestContent(body), ...(selectedResponse?.scriptEntry ? { scriptEntry: selectedResponse.scriptEntry } : {}), }, }) @@ -821,6 +963,6 @@ const server = http.createServer((req, res) => { }); }); -server.listen(port, "127.0.0.1", () => { +server.listen(port, bindHost, () => { console.log(`mock-openai listening on ${port}`); }); diff --git a/scripts/e2e/telegram-mantis-lane.ts b/scripts/e2e/telegram-mantis-lane.ts index 72fc28aba9af..0b79632fa703 100644 --- a/scripts/e2e/telegram-mantis-lane.ts +++ b/scripts/e2e/telegram-mantis-lane.ts @@ -683,20 +683,12 @@ function redact(value: unknown, secret: string): unknown { } function providerRequests(state: ActiveSession, secret: string): unknown[] { - if (!fs.existsSync(state.sut.requestLog)) { - return []; - } - return fs - .readFileSync(state.sut.requestLog, "utf8") - .split("\n") - .filter(Boolean) - .slice(0, 100) - .map((line, index) => - Object.assign( - { index: index + 1 }, - redact(JSON.parse(line), secret) as Record, - ), - ); + // Tail window, like botApiRequests: a long session must surface its newest + // provider turns. Entries carry a producer-stamped `seq` ordinal, so the + // window keeps absolute order without rereading the whole file. + return boundedNdjson(state.sut.requestLog, 128).map( + (entry) => redact(entry, secret) as Record, + ); } function boundedNdjson(file: string, limit: number): unknown[] { diff --git a/scripts/e2e/telegram-mantis-sut.ts b/scripts/e2e/telegram-mantis-sut.ts index 882cc8053a0c..0a3fa3fd2fe8 100644 --- a/scripts/e2e/telegram-mantis-sut.ts +++ b/scripts/e2e/telegram-mantis-sut.ts @@ -183,6 +183,7 @@ export function writeSutConfig(params: { gatewayPort: number; groupId: string; mcpAppFixture?: boolean; + mockHost: string; mockPort: number; outputDir: string; repoRoot?: string; @@ -278,7 +279,7 @@ export function writeSutConfig(params: { openai: { api: "openai-responses", apiKey: { id: "OPENAI_API_KEY", provider: "default", source: "env" }, - baseUrl: `http://127.0.0.1:${params.mockPort}/v1`, + baseUrl: `http://${params.mockHost}:${params.mockPort}/v1`, models: [ { api: "openai-responses", @@ -569,15 +570,15 @@ export async function startMantisSut(params: { onRuntimeDisposed?: () => void; }): Promise { const drained = await drainSutUpdates(params.sutToken); - const config = writeSutConfig(params); + const config = writeSutConfig({ ...params, mockHost: "mock-openai" }); // The root wrapper relocates tempRoot into its bounded filesystem, then restores this // exact path as a symlink before Docker starts. Keep controller and claim paths anchored // here so live log reads, mock updates, stop, and destroy all share one runtime identity. - const requestLog = path.join(config.tempRoot, "mock-openai-requests.ndjson"); - const mockLog = path.join(config.tempRoot, "mock-openai.log"); const mockResponseControlDir = path.join(config.tempRoot, "mock-control"); fs.mkdirSync(mockResponseControlDir, { mode: 0o700 }); const mockResponseControl = path.join(mockResponseControlDir, "response.json"); + const requestLog = path.join(mockResponseControlDir, "mock-openai-requests.ndjson"); + const mockLog = path.join(mockResponseControlDir, "mock-openai.log"); fs.writeFileSync( mockResponseControl, `${JSON.stringify({ @@ -586,6 +587,8 @@ export async function startMantisSut(params: { })}\n`, { mode: 0o600 }, ); + fs.writeFileSync(requestLog, "", { mode: 0o600 }); + fs.writeFileSync(mockLog, "", { mode: 0o600 }); const proxyControlDir = path.join(config.tempRoot, "proxy-control"); fs.mkdirSync(proxyControlDir, { mode: 0o700 }); const proxyControl = path.join(proxyControlDir, "control.json"); diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index 729fb4461fd9..4f9ba5e31085 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -951,7 +951,7 @@ export async function startLocalSut( let mock: ReturnType | undefined; try { const drained = await drainUpdates(params.sutToken); - const config = writeConfig(params); + const config = writeConfig({ ...params, mockHost: "127.0.0.1" }); const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson"); mock = spawnLoggedCommand( params.nodeBin ?? process.execPath, @@ -1154,7 +1154,7 @@ async function startLocalSutDaemon(params: { }; } const drained = await drainSutUpdates(params.sutToken); - const config = writeSutConfig(params); + const config = writeSutConfig({ ...params, mockHost: "127.0.0.1" }); const gatewayPassword = params.mcpAppFixture ? randomUUID() : undefined; const runtimeLogRoot = params.sutContainer ? config.tempRoot : params.outputDir; const requestLog = path.join(runtimeLogRoot, "mock-openai-requests.ndjson"); diff --git a/scripts/mantis/mantis-sut-container.sh b/scripts/mantis/mantis-sut-container.sh index 8920a843b23a..cff95affc5f6 100644 --- a/scripts/mantis/mantis-sut-container.sh +++ b/scripts/mantis/mantis-sut-container.sh @@ -337,6 +337,24 @@ remove_container_or_fail() { fi } +wait_for_mock_openai() { + local container_name="$1" + local log_path="$2" + local attempt=0 + until grep -q "mock-openai listening" "$log_path" 2>/dev/null; do + if [[ "$("$docker_bin" inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null)" != "true" ]]; then + tail -n 20 "$log_path" >&2 || true + die "mock OpenAI container exited before readiness" + fi + attempt=$((attempt + 1)) + if ((attempt >= 100)); then + tail -n 20 "$log_path" >&2 || true + die "mock OpenAI container did not become ready within 10 seconds" + fi + /bin/sleep 0.1 + done +} + create_bounded_filesystem() { local name="$1" local size="$2" @@ -621,29 +639,7 @@ run_network_probe() { # shellcheck disable=SC2016 readonly sut_command=' set -eu - mock_pid="" - gateway_pid="" - cleanup() { - exit_code=$? - trap - EXIT INT TERM - if [ -n "$gateway_pid" ]; then kill "$gateway_pid" 2>/dev/null || true; fi - if [ -n "$mock_pid" ]; then kill "$mock_pid" 2>/dev/null || true; fi - wait 2>/dev/null || true - exit "$exit_code" - } - trap cleanup EXIT INT TERM - node /opt/mantis/mock-openai-server.mjs >"$MOCK_LOG" 2>&1 & - mock_pid=$! - attempt=0 - until grep -q "mock-openai listening" "$MOCK_LOG" 2>/dev/null; do - kill -0 "$mock_pid" 2>/dev/null || exit 1 - attempt=$((attempt + 1)) - [ "$attempt" -lt 100 ] || exit 1 - sleep 0.1 - done - node openclaw.mjs gateway --port "$OPENCLAW_GATEWAY_PORT" >"$GATEWAY_LOG" 2>&1 & - gateway_pid=$! - wait "$gateway_pid" + exec node openclaw.mjs gateway --port "$OPENCLAW_GATEWAY_PORT" >"$GATEWAY_LOG" 2>&1 ' command="${1:-}" @@ -794,13 +790,17 @@ case "$command" in [[ "$(stat -c %a "$response_control_dir")" == "700" ]] \ || die "mock response control directory mode mismatch" response_control="$response_control_dir/response.json" - [[ -f "$response_control" && ! -L "$response_control" ]] || die "invalid mock response control" - [[ "$(stat -c %u "$response_control")" == "$(id -u mantis-sut)" ]] \ - || die "mock response control owner mismatch" - [[ "$(stat -c %a "$response_control")" == "600" ]] \ - || die "mock response control mode mismatch" - [[ "$(stat -c %h "$response_control")" == "1" ]] \ - || die "mock response control must not be hard-linked" + request_log="$response_control_dir/mock-openai-requests.ndjson" + mock_log="$response_control_dir/mock-openai.log" + for file in "$response_control" "$request_log" "$mock_log"; do + [[ -f "$file" && ! -L "$file" ]] || die "invalid mock control or evidence file" + [[ "$(stat -c %u "$file")" == "$(id -u mantis-sut)" ]] \ + || die "mock control or evidence file owner mismatch" + [[ "$(stat -c %a "$file")" == "600" ]] \ + || die "mock control or evidence file mode mismatch" + [[ "$(stat -c %h "$file")" == "1" ]] \ + || die "mock control or evidence file must not be hard-linked" + done proxy_control_dir="$safe_runtime/proxy-control" [[ -d "$proxy_control_dir" && ! -L "$proxy_control_dir" ]] \ || die "invalid Telegram proxy control directory" @@ -819,7 +819,7 @@ case "$command" in [[ "$(stat -c %h "$file")" == "1" ]] \ || die "Telegram proxy control file must not be hard-linked" done - for name in gateway.log mock-openai.log mock-openai-requests.ndjson sut-attestation.json; do + for name in gateway.log sut-attestation.json; do [[ ! -e "$safe_runtime/$name" && ! -L "$safe_runtime/$name" ]] \ || die "runtime output was pre-created" done @@ -831,7 +831,6 @@ case "$command" in telegram_bot_id="${telegram_bot_token%%:*}" [[ "$telegram_bot_id" =~ ^[1-9][0-9]*$ ]] || die "invalid Telegram bot token" telegram_alias_token="${telegram_bot_id}:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - export SUCCESS_MARKER="$success_marker" export TELEGRAM_BOT_TOKEN="$telegram_alias_token" mock_response_chunk_delay_ms="$(jq -r '.mockResponseChunkDelayMs // ""' "$input_file")" gateway_password="$(jq -r '.gatewayPassword // ""' "$input_file")" @@ -839,19 +838,11 @@ case "$command" in trap - EXIT gateway_log="$runtime_source/gateway.log" - mock_log="$runtime_source/mock-openai.log" - request_log="$runtime_source/mock-openai-requests.ndjson" install -T -o mantis-sut -g mantis-proof -m 0600 /dev/null "$safe_runtime/gateway.log" - install -T -o mantis-sut -g mantis-proof -m 0600 /dev/null "$safe_runtime/mock-openai.log" - install -T -o mantis-sut -g mantis-proof -m 0600 /dev/null "$safe_runtime/mock-openai-requests.ndjson" export CI=1 export GATEWAY_LOG="$gateway_log" export GIT_COMMIT="$attested_sha" export HOME="$runtime_source/container-home" - export MOCK_LOG="$mock_log" - export MOCK_PORT="$mock_port" - export MOCK_REQUEST_LOG="$request_log" - export MOCK_RESPONSE_CONTROL="$runtime_source/mock-control/response.json" export NODE_DISABLE_COMPILE_CACHE=1 export OPENAI_API_KEY=sk-openclaw-e2e-mock export OPENCLAW_BUILD_PRIVATE_QA=1 @@ -861,19 +852,17 @@ case "$command" in export OPENCLAW_STATE_DIR="$runtime_source/state" if [[ -n "$mock_response_chunk_delay_ms" ]]; then require_positive_integer "$mock_response_chunk_delay_ms" - export MOCK_RESPONSE_CHUNK_DELAY_MS="$mock_response_chunk_delay_ms" fi if [[ -n "$gateway_password" ]]; then export OPENCLAW_GATEWAY_PASSWORD="$gateway_password" fi forwarded_env=( - CI GATEWAY_LOG GIT_COMMIT HOME MOCK_LOG MOCK_PORT MOCK_REQUEST_LOG MOCK_RESPONSE_CONTROL NODE_DISABLE_COMPILE_CACHE + CI GATEWAY_LOG GIT_COMMIT HOME NODE_DISABLE_COMPILE_CACHE OPENAI_API_KEY OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_CONFIG_PATH OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_GATEWAY_PORT OPENCLAW_STATE_DIR - SUCCESS_MARKER TELEGRAM_BOT_TOKEN + TELEGRAM_BOT_TOKEN ) - [[ -z "${MOCK_RESPONSE_CHUNK_DELAY_MS:-}" ]] || forwarded_env+=(MOCK_RESPONSE_CHUNK_DELAY_MS) [[ -z "${OPENCLAW_GATEWAY_PASSWORD:-}" ]] || forwarded_env+=(OPENCLAW_GATEWAY_PASSWORD) docker_env=() for name in "${forwarded_env[@]}"; do @@ -882,6 +871,7 @@ case "$command" in network_name="${container_name}-net" egress_network_name="${container_name}-egress" + mock_container_name="${container_name}-mock-openai" proxy_container_name="${container_name}-telegram-proxy" [[ -f "$telegram_proxy_script" && ! -L "$telegram_proxy_script" ]] \ || die "missing trusted Telegram Bot API proxy" @@ -899,6 +889,7 @@ case "$command" in cleanup_run() { local result=0 remove_container_or_fail "$container_name" || result=$? + remove_container_or_fail "$mock_container_name" || result=$? remove_container_or_fail "$proxy_container_name" || result=$? cleanup_network "$network_name" || result=$? cleanup_network "$egress_network_name" || result=$? @@ -921,20 +912,44 @@ case "$command" in "$image" node /opt/mantis/telegram-bot-api-proxy.mjs >/dev/null "$docker_bin" network connect --alias telegram-api-proxy "$network_name" "$proxy_container_name" require_runtime_claim_active "$container_name" + mock_env=( + --env MOCK_BIND_HOST=0.0.0.0 + --env MOCK_PORT="$mock_port" + --env MOCK_REQUEST_LOG=/opt/mantis/mock-control/mock-openai-requests.ndjson + --env MOCK_RESPONSE_CONTROL=/opt/mantis/mock-control/response.json + --env SUCCESS_MARKER="$success_marker" + ) + if [[ -n "$mock_response_chunk_delay_ms" ]]; then + mock_env+=(--env MOCK_RESPONSE_CHUNK_DELAY_MS="$mock_response_chunk_delay_ms") + fi + "$docker_bin" run --detach --name "$mock_container_name" --network "$network_name" \ + --network-alias mock-openai \ + "${container_security_args[@]}" "${proxy_resource_args[@]}" \ + --mount "type=bind,src=$mock_server_script,dst=/opt/mantis/mock-openai-server.mjs,readonly" \ + --mount "type=bind,src=$response_control_dir,dst=/opt/mantis/mock-control" \ + --user "$(id -u mantis-sut):$(id -g mantis-sut)" \ + "${mock_env[@]}" \ + "$image" sh -c 'exec node /opt/mantis/mock-openai-server.mjs >/opt/mantis/mock-control/mock-openai.log 2>&1' \ + >/dev/null + wait_for_mock_openai "$mock_container_name" "$mock_log" + require_runtime_claim_active "$container_name" # proxy-control holds the proxy's fault rules and recorded Bot API facts. # The SUT runs untrusted candidate code as the same mantis-sut UID, so an # inaccessible tmpfs must shadow the directory inside the runtime mount; # without it the lane under test could rewrite its own trusted evidence. + # mock-control holds provider controls and evidence. Shadow it inside the SUT + # so candidate code cannot read controls or forge provider evidence. "$docker_bin" run --rm --init --name "$container_name" --network "$network_name" \ "${container_security_args[@]}" "${runtime_resource_args[@]}" \ --mount "type=bind,src=$repo_root,dst=$repo_root,readonly" \ - --mount "type=bind,src=$mock_server_script,dst=/opt/mantis/mock-openai-server.mjs,readonly" \ --mount "type=bind,src=$safe_runtime,dst=$runtime_source" \ + --mount "type=tmpfs,dst=$runtime_source/mock-control,tmpfs-size=65536,tmpfs-mode=0000" \ --mount "type=tmpfs,dst=$runtime_source/proxy-control,tmpfs-size=65536,tmpfs-mode=0000" \ --workdir "$repo_root" \ --user "$(id -u mantis-sut):$(id -g mantis-sut)" \ "${docker_env[@]}" \ "$image" sh -c "$sut_command" + remove_container_or_fail "$mock_container_name" remove_container_or_fail "$proxy_container_name" cleanup_network "$network_name" cleanup_network "$egress_network_name" @@ -956,6 +971,7 @@ case "$command" in terminate_runtime_claim stop_result=0 remove_container_or_fail "$1" || stop_result=1 + remove_container_or_fail "${1}-mock-openai" || stop_result=1 remove_container_or_fail "${1}-telegram-proxy" || stop_result=1 cleanup_network "${1}-net" || stop_result=1 cleanup_network "${1}-egress" || stop_result=1 @@ -989,6 +1005,12 @@ case "$command" in exists_result=$? ((exists_result == 1)) || exit "$exists_result" fi + if container_exists "${1}-mock-openai"; then + die "refusing to destroy a running mock OpenAI container" + else + exists_result=$? + ((exists_result == 1)) || exit "$exists_result" + fi for network_name in "${1}-net" "${1}-egress"; do if network_exists "$network_name"; then die "refusing to destroy an active SUT network" diff --git a/test/scripts/e2e-mock-config-limits.test.ts b/test/scripts/e2e-mock-config-limits.test.ts index caeb79d2a628..3f5fb1935323 100644 --- a/test/scripts/e2e-mock-config-limits.test.ts +++ b/test/scripts/e2e-mock-config-limits.test.ts @@ -17,6 +17,7 @@ const scrubbedEnvKeys = [ "CLICKCLACK_FIXTURE_PORT", "CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES", "FIXTURE_PORT", + "MOCK_BIND_HOST", "MOCK_PORT", "MOCK_REQUEST_LOG", "MOCK_RESPONSE_CHUNK_DELAY_MS", @@ -370,6 +371,111 @@ describe("mock OpenAI response markers", () => { } }); + it("records bounded media facts without provider payload bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "openclaw-mock-content-facts-")); + const requestLog = join(root, "requests.ndjson"); + const pdfBytes = "private-pdf-bytes"; + const pdfBase64 = Buffer.from(pdfBytes).toString("base64"); + try { + await writeFile(requestLog, ""); + await withMockServer(mockOpenAiPath, { MOCK_REQUEST_LOG: requestLog }, async (baseUrl) => { + const send = async (input: unknown) => { + const response = await fetch(`${baseUrl}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ input, stream: false }), + }); + expect(response.status).toBe(200); + }; + await send([ + { + type: "message", + role: "user", + content: Array.from({ length: 128 }, (_, index) => ({ + type: "input_text", + text: `historical turn ${index}`, + })), + }, + { + type: "message", + role: "user", + content: [ + { + type: "input_file", + filename: "proof.pdf", + file_data: `data:application/pdf;base64,${pdfBase64}`, + }, + { type: "input_text", text: "Summarize the staged document." }, + ], + }, + ]); + await send([ + { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: "[media attached: /tmp/session/proof.pdf (application/pdf)]\nSummarize it.", + }, + ], + }, + ]); + + const recorded = await readFile(requestLog, "utf8"); + const entries = recorded + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(entries[0]?.contentFacts).toHaveLength(128); + expect(entries[0]?.contentFactsTruncated).toBe(true); + expect(entries[0]?.contentFacts.slice(-2)).toEqual([ + { + type: "input_file", + filename: "proof.pdf", + mimeType: "application/pdf", + byteLength: Buffer.byteLength(pdfBytes), + }, + { type: "input_text" }, + ]); + expect(entries[1]?.contentFacts).toEqual([ + { type: "input_text" }, + { + type: "legacy_media", + filename: "/tmp/session/proof.pdf", + mimeType: "application/pdf", + }, + ]); + expect(recorded).not.toContain(pdfBase64); + expect(entries[0]?.body).toContain("data:application/pdf;base64,[redacted:17 bytes]"); + expect(entries.map((entry) => entry.seq)).toEqual([1, 2]); + + // Redaction walks parsed JSON, so an unparseable body must never be + // logged as raw text — that path would leak the base64 payload. + const malformed = `{"input": "data:application/pdf;base64,${pdfBase64}"`; + const response = await fetch(`${baseUrl}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: malformed, + }); + expect(response.status).toBe(200); + const withMalformed = await readFile(requestLog, "utf8"); + expect(withMalformed).not.toContain(pdfBase64); + const malformedEntry = withMalformed + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + .at(-1); + expect(malformedEntry?.body).toBe( + `[unparseable request body redacted: ${Buffer.byteLength(malformed)} bytes]`, + ); + expect(malformedEntry?.seq).toBe(3); + }); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it("supports scripted connection drops", async () => { const root = await mkdtemp(join(tmpdir(), "openclaw-mock-response-drop-")); const control = join(root, "response.json"); diff --git a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts index edf4e38eddbe..f1dac487680a 100644 --- a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts +++ b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts @@ -5,6 +5,7 @@ import { parse } from "yaml"; const PROOF_SCRIPT = "scripts/e2e/telegram-user-crabbox-proof.ts"; const MANTIS_SUT_SCRIPT = "scripts/e2e/telegram-mantis-sut.ts"; +const MOCK_OPENAI_SERVER = "scripts/e2e/mock-openai-server.mjs"; const MANTIS_LANE_SCRIPT = "scripts/e2e/telegram-mantis-lane.ts"; const DESKTOP_CRABBOX_SCRIPT = "scripts/e2e/telegram-desktop-crabbox.ts"; const SUT_CONTAINER_WRAPPER = "scripts/mantis/mantis-sut-container.sh"; @@ -773,6 +774,13 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(prompt).toContain("`requests`"); expect(prompt).toContain("`finish [--focus-message-id ID]`"); expect(prompt).toContain("Identical pixels alone do not force `block`"); + // Precise trust claim: the sidecar makes facts tamper-evident (candidate + // cannot rewrite records), but requests originate inside the untrusted SUT, + // so the prompt must not present them as provenance-authenticated. + expect(prompt).toContain("Provider request facts are tamper-evident comparison evidence"); + expect(prompt).toContain("not who sent it"); + expect(prompt).not.toContain("trusted, tamper-protected"); + expect(prompt).not.toContain("Provider request logs are diagnostic and pacing signals"); expect(prompt).toContain("mantis-recipes/"); expect(prompt).toContain("recipe-suggestion.md"); expect(prompt).toContain("do not call `finish` and describe the block only in prose"); @@ -1157,6 +1165,7 @@ describe("Mantis Telegram Desktop proof workflow", () => { it("does not pass the full workflow environment into the local Telegram SUT", () => { const sutScript = readFileSync(MANTIS_SUT_SCRIPT, "utf8"); const laneScript = readFileSync(MANTIS_LANE_SCRIPT, "utf8"); + const mockServer = readFileSync(MOCK_OPENAI_SERVER, "utf8"); const prompt = readFileSync(PROMPT, "utf8"); const workflow = readFileSync(WORKFLOW, "utf8"); const wrapper = readFileSync(SUT_CONTAINER_WRAPPER, "utf8"); @@ -1228,10 +1237,7 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(workflow).toContain( 'sudo install -m 0444 "$toolchain_build/scripts/e2e/mock-openai-server.mjs"', ); - expect(wrapper).toContain("node /opt/mantis/mock-openai-server.mjs"); - expect(wrapper).toContain( - '--mount "type=bind,src=$mock_server_script,dst=/opt/mantis/mock-openai-server.mjs,readonly"', - ); + expect(wrapper).not.toContain('node /opt/mantis/mock-openai-server.mjs >"$MOCK_LOG"'); expect(wrapper).not.toContain("node scripts/e2e/mock-openai-server.mjs"); expect(workflow).toContain('sudo usermod -aG mantis-proof "$recorder_user"'); expect(workflow).toContain( @@ -1284,6 +1290,25 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(wrapper).toContain( "--env TELEGRAM_PROXY_RECORD_FILE=/opt/mantis/proxy-control/requests.ndjson", ); + const mockContainerSpec = wrapper.slice( + wrapper.indexOf('"$docker_bin" run --detach --name "$mock_container_name"'), + wrapper.indexOf('wait_for_mock_openai "$mock_container_name"'), + ); + expect(mockContainerSpec).toContain('--network "$network_name"'); + expect(mockContainerSpec).toContain("--network-alias mock-openai"); + expect(mockContainerSpec).not.toContain("$egress_network_name"); + expect(mockContainerSpec).toContain( + '--mount "type=bind,src=$mock_server_script,dst=/opt/mantis/mock-openai-server.mjs,readonly"', + ); + expect(mockContainerSpec).toContain( + '--mount "type=bind,src=$response_control_dir,dst=/opt/mantis/mock-control"', + ); + expect(wrapper).toContain("--env MOCK_BIND_HOST=0.0.0.0"); + expect(mockContainerSpec).toContain('--user "$(id -u mantis-sut):$(id -g mantis-sut)"'); + expect(mockServer).toContain('const bindHost = process.env.MOCK_BIND_HOST ?? "127.0.0.1"'); + expect(mockServer).toContain("server.listen(port, bindHost"); + expect(wrapper).toContain('wait_for_mock_openai "$mock_container_name" "$mock_log"'); + expect(wrapper).toContain("mock OpenAI container exited before readiness"); // Candidate code shares the mantis-sut UID with the proxy record sink, so // the SUT container must shadow proxy-control; otherwise the lane under // test could rewrite its own trusted Bot API evidence before publication. @@ -1293,8 +1318,16 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(wrapper.indexOf(proxyControlShadow)).toBeGreaterThan( wrapper.indexOf('--mount "type=bind,src=$safe_runtime,dst=$runtime_source"'), ); + const mockControlShadow = + '--mount "type=tmpfs,dst=$runtime_source/mock-control,tmpfs-size=65536,tmpfs-mode=0000"'; + expect(wrapper).toContain(mockControlShadow); + expect(wrapper.indexOf(mockControlShadow)).toBeGreaterThan( + wrapper.indexOf('--mount "type=bind,src=$safe_runtime,dst=$runtime_source"'), + ); expect(wrapper).toContain('export TELEGRAM_BOT_TOKEN="$telegram_alias_token"'); expect(wrapper).not.toContain('export TELEGRAM_BOT_TOKEN="$telegram_bot_token"'); + expect(wrapper.match(/remove_container_or_fail "\$mock_container_name"/gu)).toHaveLength(2); + expect(wrapper).toContain('remove_container_or_fail "${1}-mock-openai"'); expect(wrapper).toContain('remove_container_or_fail "${1}-telegram-proxy"'); expect(workflow).toContain( "/usr/local/lib/mantis-toolchain/scripts/e2e/telegram-bot-api-proxy.mjs", @@ -1338,17 +1371,21 @@ describe("Mantis Telegram Desktop proof workflow", () => { 'const proxyControlDir = path.join(config.tempRoot, "proxy-control")', ); expect(sutScript).toContain( - 'const requestLog = path.join(config.tempRoot, "mock-openai-requests.ndjson")', + 'const requestLog = path.join(mockResponseControlDir, "mock-openai-requests.ndjson")', ); - expect(wrapper).toContain( - 'export MOCK_RESPONSE_CONTROL="$runtime_source/mock-control/response.json"', + expect(sutScript).toContain( + 'const mockLog = path.join(mockResponseControlDir, "mock-openai.log")', ); const forwardedEnv = wrapper.slice( wrapper.indexOf("forwarded_env=("), wrapper.indexOf("docker_env=()"), ); - expect(forwardedEnv).toContain("MOCK_RESPONSE_CONTROL"); + expect(forwardedEnv).not.toContain("MOCK_RESPONSE_CONTROL"); + expect(forwardedEnv).not.toContain("MOCK_REQUEST_LOG"); + expect(forwardedEnv).not.toContain("MOCK_LOG"); + expect(forwardedEnv).not.toContain("MOCK_PORT"); expect(wrapper).toContain("refusing to destroy a running SUT container"); + expect(wrapper).toContain("refusing to destroy a running mock OpenAI container"); expect(wrapper).toContain('destroy_bounded_filesystem "$runtime_root"'); expect(wrapper).toContain('create_runtime_claim "$container_name" "$runtime_source"'); expect(wrapper).toContain('cancel_runtime_claim "$1" "$runtime_source"'); diff --git a/test/scripts/telegram-mantis-lane.test.ts b/test/scripts/telegram-mantis-lane.test.ts index 2b6bc4b0adb8..d5b4aa7f8500 100644 --- a/test/scripts/telegram-mantis-lane.test.ts +++ b/test/scripts/telegram-mantis-lane.test.ts @@ -922,6 +922,83 @@ exit 1 } }); + it("exposes provider content facts through requests and terminal lane facts", async () => { + const harness = await setupHarness({ userOnlyEvents: true }); + const contentFacts = [ + { + type: "input_file", + filename: "proof.pdf", + mimeType: "application/pdf", + byteLength: 17, + }, + ]; + fs.writeFileSync( + harness.requestLog, + `${JSON.stringify({ + seq: 1, + body: "credential=123456:secret-sut-token", + contentFacts, + path: "/v1/responses", + })}\n`, + ); + try { + const requests = JSON.parse( + (await runLane(harness.env, ["requests", "--lane", "candidate"])).stdout, + ); + expect(requests).toEqual({ + count: 1, + requests: [ + { + seq: 1, + body: "credential=[redacted]", + contentFacts, + path: "/v1/responses", + }, + ], + }); + + // Tail window: a session with more records than the window must expose + // its newest requests — the ones under proof — with their absolute seq. + fs.writeFileSync( + harness.requestLog, + Array.from( + { length: 130 }, + (_, i) => `${JSON.stringify({ seq: i + 1, body: `turn ${i + 1}` })}\n`, + ).join(""), + ); + const tail = JSON.parse( + (await runLane(harness.env, ["requests", "--lane", "candidate"])).stdout, + ); + expect(tail.count).toBe(128); + expect(tail.requests[0]).toEqual({ seq: 3, body: "turn 3" }); + expect(tail.requests.at(-1)).toEqual({ seq: 130, body: "turn 130" }); + + // Restore the single-record log so terminal lane facts mirror the + // requests assertion above. + fs.writeFileSync( + harness.requestLog, + `${JSON.stringify({ + seq: 1, + body: "credential=123456:secret-sut-token", + contentFacts, + path: "/v1/responses", + })}\n`, + ); + await runLane(harness.env, ["send", "--lane", "candidate", "--text", "persist facts"]); + await runLane(harness.env, ["finish", "--lane", "candidate"]); + const facts = JSON.parse( + fs.readFileSync( + path.join(harness.outputRoot, "candidate", "mantis-lane-facts.json"), + "utf8", + ), + ); + expect(facts.providerRequests).toEqual(requests.requests); + expect(JSON.stringify(facts.providerRequests)).not.toContain("secret-sut-token"); + } finally { + await harness.close(); + } + }); + it("finishes an expected-silence proof on the triggering user message", async () => { const harness = await setupHarness({ userOnlyEvents: true }); try { diff --git a/test/scripts/telegram-mantis-sut.test.ts b/test/scripts/telegram-mantis-sut.test.ts index e3d5b0463419..4d9f5e456107 100644 --- a/test/scripts/telegram-mantis-sut.test.ts +++ b/test/scripts/telegram-mantis-sut.test.ts @@ -201,6 +201,7 @@ describe("Telegram Mantis SUT", () => { }, gatewayPort: 19_879, groupId: "-100123456789", + mockHost: "mock-openai", mockPort: 19_882, outputDir, testerId: "12345", @@ -212,6 +213,7 @@ describe("Telegram Mantis SUT", () => { expect(config.channels.telegram.streaming).toEqual({ mode: "partial" }); expect(config.channels.telegram).not.toHaveProperty("replyToMode"); expect(config.commands.ownerAllowFrom).toEqual(["telegram:12345"]); + expect(config.models.providers.openai.baseUrl).toBe("http://mock-openai:19882/v1"); expect(config.session.sendPolicy).toEqual({ default: "deny" }); }); }); diff --git a/test/scripts/telegram-user-crabbox-proof.test.ts b/test/scripts/telegram-user-crabbox-proof.test.ts index 3d18f87b7d59..4948e5a762c1 100644 --- a/test/scripts/telegram-user-crabbox-proof.test.ts +++ b/test/scripts/telegram-user-crabbox-proof.test.ts @@ -493,6 +493,7 @@ describe("telegram user Crabbox proof log polling", () => { gatewayPort: 19042, groupId: "group", mcpAppFixture: true, + mockHost: "127.0.0.1", mockPort: 19043, outputDir: makeTempDir(tempDirs, "openclaw-telegram-proof-"), repoRoot: "/repo", @@ -520,6 +521,7 @@ describe("telegram user Crabbox proof log polling", () => { const configRoot = writeSutConfig({ gatewayPort: 19042, groupId: "group", + mockHost: "127.0.0.1", mockPort: 19043, outputDir: makeTempDir(tempDirs, "openclaw-telegram-proof-"), testerId: "tester", @@ -532,6 +534,7 @@ describe("telegram user Crabbox proof log polling", () => { executionIdentity: true, messages: "direct", }); + expect(config.models.providers.openai.baseUrl).toBe("http://127.0.0.1:19043/v1"); }); it("injects the requested Telegram link-preview setting before startup", () => { @@ -539,6 +542,7 @@ describe("telegram user Crabbox proof log polling", () => { configPatch: { channels: { telegram: { linkPreview: false } } }, gatewayPort: 19042, groupId: "group", + mockHost: "127.0.0.1", mockPort: 19043, outputDir: makeTempDir(tempDirs, "openclaw-telegram-proof-"), testerId: "tester", @@ -546,6 +550,7 @@ describe("telegram user Crabbox proof log polling", () => { const defaultConfigRoot = writeSutConfig({ gatewayPort: 19044, groupId: "group", + mockHost: "127.0.0.1", mockPort: 19045, outputDir: makeTempDir(tempDirs, "openclaw-telegram-proof-"), testerId: "tester", @@ -570,6 +575,7 @@ describe("telegram user Crabbox proof log polling", () => { }, gatewayPort: 19042, groupId: "group", + mockHost: "127.0.0.1", mockPort: 19043, outputDir: makeTempDir(tempDirs, "openclaw-telegram-proof-"), testerId: "tester", @@ -577,6 +583,7 @@ describe("telegram user Crabbox proof log polling", () => { const defaultConfigRoot = writeSutConfig({ gatewayPort: 19044, groupId: "group", + mockHost: "127.0.0.1", mockPort: 19045, outputDir: makeTempDir(tempDirs, "openclaw-telegram-proof-"), testerId: "tester",