From eaedcdcd4ed9c16c7b8f577e064ce89c2ade59d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 16:10:41 +0100 Subject: [PATCH] fix(ci): retain Telegram candidate diagnostics (#105472) * fix(ci): retain Telegram candidate diagnostics * fix(ci): stream redacted Telegram diagnostics --- .../openclaw-release-telegram-qa.yml | 154 ++++++++++++++++++ ...nclaw-release-telegram-qa-workflow.test.ts | 38 +++++ 2 files changed, 192 insertions(+) diff --git a/.github/workflows/openclaw-release-telegram-qa.yml b/.github/workflows/openclaw-release-telegram-qa.yml index ddb660287923..b1b0e574e0e7 100644 --- a/.github/workflows/openclaw-release-telegram-qa.yml +++ b/.github/workflows/openclaw-release-telegram-qa.yml @@ -2164,6 +2164,159 @@ jobs: echo "Isolated Telegram SUT UID still owns processes after terminal cleanup." >&2 exit 1 + - name: Capture isolated Telegram runtime diagnostics + id: capture_diagnostics + if: always() && steps.terminate_sut.outputs.quiescent == 'true' && steps.run_lane.outputs.output_dir != '' + env: + OUTPUT_DIR: ${{ steps.run_lane.outputs.output_dir }} + RUNTIME_ROOT: ${{ steps.create_sut.outputs.runtime_root }} + shell: bash + run: | + set -euo pipefail + umask 077 + [[ "$RUNTIME_ROOT" == "/var/lib/openclaw-telegram-sut-runtime-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" ]] + diagnostics_dir="${OUTPUT_DIR}/trusted-runtime-diagnostics" + install -d -m 0700 "$diagnostics_dir" + trusted_temp_root="$(mktemp -d "${RUNNER_TEMP}/openclaw-telegram-diagnostics.XXXXXX")" + trap 'rm -rf -- "$trusted_temp_root"' EXIT + redactor_script="${trusted_temp_root}/redact-gateway-tail.mts" + cat >"$redactor_script" <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + import { pathToFileURL } from "node:url"; + + const [outputPath] = process.argv.slice(2); + const redactorPath = path.resolve( + "extensions/qa-lab/src/gateway-log-redaction.ts", + ); + const { redactQaGatewayDebugText } = await import(pathToFileURL(redactorPath).href); + const limitBytes = 131_072; + const maxInputRecordBytes = 1_048_576; + const oversizedMarker = "[truncated oversized gateway log record] "; + const omittedMarker = "[omitted oversized gateway log record]\n"; + const retained = []; + let retainedBytes = 0; + + function boundRedactedLine(line) { + const redactedLine = `${redactQaGatewayDebugText(line)}\n`; + const lineBuffer = Buffer.from(redactedLine); + if (lineBuffer.length <= limitBytes) { + return redactedLine; + } + const markerBytes = Buffer.byteLength(oversizedMarker); + const suffixBuffer = lineBuffer.subarray( + lineBuffer.length - (limitBytes - markerBytes), + ); + let suffix = suffixBuffer.toString("utf8"); + while (Buffer.byteLength(`${oversizedMarker}${suffix}`) > limitBytes) { + suffix = suffix.slice(1); + } + return `${oversizedMarker}${suffix}`; + } + + function retainLine(boundedLine) { + const lineBytes = Buffer.byteLength(boundedLine); + while (retained.length > 0 && retainedBytes + lineBytes > limitBytes) { + const removed = retained.shift(); + retainedBytes -= Buffer.byteLength(removed); + } + retained.push(boundedLine); + retainedBytes += lineBytes; + } + + let recordParts = []; + let recordBytes = 0; + let recordOmitted = false; + function appendRecordPart(part) { + if (recordOmitted) { + return; + } + if (recordBytes + part.length > maxInputRecordBytes) { + recordParts = []; + recordBytes = 0; + recordOmitted = true; + return; + } + recordParts.push(Buffer.from(part)); + recordBytes += part.length; + } + function finishRecord() { + if (recordOmitted) { + retainLine(omittedMarker); + } else { + const line = Buffer.concat(recordParts, recordBytes).toString("utf8").replace(/\r$/u, ""); + retainLine(boundRedactedLine(line)); + } + recordParts = []; + recordBytes = 0; + recordOmitted = false; + } + + for await (const value of process.stdin) { + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + let offset = 0; + while (offset < chunk.length) { + const newline = chunk.indexOf(0x0a, offset); + const end = newline === -1 ? chunk.length : newline; + appendRecordPart(chunk.subarray(offset, end)); + if (newline === -1) { + break; + } + finishRecord(); + offset = newline + 1; + } + } + if (recordOmitted || recordBytes > 0) { + finishRecord(); + } + fs.writeFileSync(outputPath, retained.join(""), { encoding: "utf8", mode: 0o600 }); + NODE + chmod 0600 "$redactor_script" + + mapfile -t gateway_logs < <( + sudo find "$RUNTIME_ROOT/tmp" -xdev -type f -name 'openclaw-*.log' -print | sort + ) + ((${#gateway_logs[@]} > 0 && ${#gateway_logs[@]} <= 8)) + for index in "${!gateway_logs[@]}"; do + log_path="$(sudo realpath -e "${gateway_logs[$index]}")" + case "$log_path" in + "$RUNTIME_ROOT"/tmp/*) ;; + *) echo "Telegram gateway log escaped the isolated runtime root." >&2; exit 1 ;; + esac + [[ "$(sudo stat -c '%F' "$log_path")" == "regular file" ]] + output_path="${diagnostics_dir}/gateway-internal-tail-$((index + 1)).log" + sudo cat "$log_path" | node --import tsx "$redactor_script" "$output_path" + done + + mapfile -t gateway_configs < <( + sudo find "$RUNTIME_ROOT/tmp" -xdev -type f -name openclaw.json -print | sort + ) + ((${#gateway_configs[@]} > 0 && ${#gateway_configs[@]} <= 8)) + for index in "${!gateway_configs[@]}"; do + config_path="$(sudo realpath -e "${gateway_configs[$index]}")" + case "$config_path" in + "$RUNTIME_ROOT"/tmp/*) ;; + *) echo "Telegram gateway config escaped the isolated runtime root." >&2; exit 1 ;; + esac + proof_path="${diagnostics_dir}/gateway-model-config-$((index + 1)).json" + sudo jq -e ' + { + agentDefaultModel: .agents.defaults.model, + agentModelRefs: ((.agents.defaults.models // {}) | keys), + providers: ( + (.models.providers // {}) | + to_entries | + map({ + id: .key, + api: .value.api, + modelIds: [.value.models[]?.id] + }) + ) + } + ' "$config_path" >"$proof_path" + chmod 0600 "$proof_path" + done + - name: Finalize trusted Telegram process-boundary evidence id: finalize_boundary if: always() && steps.terminate_sut.outputs.quiescent == 'true' && steps.run_lane.outputs.output_dir != '' @@ -2360,6 +2513,7 @@ jobs: ${{ steps.validate_credentials.outcome }} ${{ steps.run_lane.outcome }} ${{ steps.terminate_sut.outcome }} + ${{ steps.capture_diagnostics.outcome }} ${{ steps.finalize_boundary.outcome }} ${{ steps.validate_evidence.outcome }} ${{ steps.remove_sut.outcome }} diff --git a/test/scripts/openclaw-release-telegram-qa-workflow.test.ts b/test/scripts/openclaw-release-telegram-qa-workflow.test.ts index e32a6488ea3d..e6656711176b 100644 --- a/test/scripts/openclaw-release-telegram-qa-workflow.test.ts +++ b/test/scripts/openclaw-release-telegram-qa-workflow.test.ts @@ -557,6 +557,44 @@ describe("release Telegram QA workflow", () => { expect(finalizeStep?.env?.RUN_LANE_OUTCOME).toBe("${{ steps.run_lane.outcome }}"); expect(finalizeStep?.run).toContain('--arg runLaneOutcome "$RUN_LANE_OUTCOME"'); expect(finalizeStep?.run).toContain('if $runLaneOutcome == "success" then 2 else 1 end'); + + const captureStep = job?.steps?.find( + (step) => step.name === "Capture isolated Telegram runtime diagnostics", + ); + expect(captureStep?.if).toContain("steps.terminate_sut.outputs.quiescent == 'true'"); + expect(captureStep?.env?.OUTPUT_DIR).toBe("${{ steps.run_lane.outputs.output_dir }}"); + expect(captureStep?.env?.RUNTIME_ROOT).toBe("${{ steps.create_sut.outputs.runtime_root }}"); + expect(captureStep?.run).toContain("-name 'openclaw-*.log'"); + expect(captureStep?.run).toContain( + 'trusted_temp_root="$(mktemp -d "${RUNNER_TEMP}/openclaw-telegram-diagnostics.XXXXXX")"', + ); + expect(captureStep?.run).not.toContain(".raw"); + expect(captureStep?.run).toContain( + 'sudo cat "$log_path" | node --import tsx "$redactor_script" "$output_path"', + ); + expect(captureStep?.run).toContain("const redactedLine ="); + expect(captureStep?.run).toContain("const limitBytes = 131_072"); + expect(captureStep?.run).toContain("const maxInputRecordBytes = 1_048_576"); + expect(captureStep?.run).toContain("[truncated oversized gateway log record]"); + expect(captureStep?.run).toContain("[omitted oversized gateway log record]"); + expect(captureStep?.run).toContain("chunk.indexOf(0x0a, offset)"); + expect(captureStep?.run).not.toContain("readline.createInterface"); + expect(captureStep?.run).toContain("while (retained.length > 0"); + expect(captureStep?.run).toContain("redactQaGatewayDebugText"); + expect(captureStep?.run).toContain("agentDefaultModel: .agents.defaults.model"); + expect(captureStep?.run).toContain("modelIds: [.value.models[]?.id]"); + expect( + job?.steps?.findIndex( + (step) => step.name === "Capture isolated Telegram runtime diagnostics", + ), + ).toBeLessThan( + job?.steps?.findIndex( + (step) => step.name === "Finalize trusted Telegram process-boundary evidence", + ) ?? -1, + ); + + const recordStep = job?.steps?.find((step) => step.name === "Record Telegram execution status"); + expect(recordStep?.env?.OUTCOMES).toContain("${{ steps.capture_diagnostics.outcome }}"); }); it("serializes stderr behind the workflow-command pause", () => {