diff --git a/.github/workflows/qa-live-telegram-convex.yml b/.github/workflows/qa-live-telegram-convex.yml new file mode 100644 index 00000000000..81a5b904562 --- /dev/null +++ b/.github/workflows/qa-live-telegram-convex.yml @@ -0,0 +1,201 @@ +name: QA-Lab - Live Telegram, Live Frontier + +on: + workflow_dispatch: + inputs: + ref: + description: Ref, tag, or SHA to run + required: true + default: main + type: string + scenario: + description: Optional comma-separated Telegram scenario ids + required: false + type: string + +permissions: + contents: read + pull-requests: read + +concurrency: + group: qa-lab-live-telegram-live-frontier-${{ inputs.ref }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.x" + PNPM_VERSION: "10.33.0" + OPENCLAW_BUILD_PRIVATE_QA: "1" + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" + +jobs: + authorize_actor: + name: Authorize workflow actor + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - name: Require maintainer-level repository access + uses: actions/github-script@v8 + with: + script: | + const allowed = new Set(["admin", "maintain", "write"]); + const { owner, repo } = context.repo; + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: context.actor, + }); + const permission = data.permission; + core.info(`Actor ${context.actor} permission: ${permission}`); + if (!allowed.has(permission)) { + core.setFailed( + `Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`, + ); + } + + validate_selected_ref: + name: Validate selected ref + needs: authorize_actor + runs-on: blacksmith-8vcpu-ubuntu-2404 + outputs: + selected_sha: ${{ steps.validate.outputs.selected_sha }} + trusted_reason: ${{ steps.validate.outputs.trusted_reason }} + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Validate selected ref + id: validate + env: + GH_TOKEN: ${{ github.token }} + INPUT_REF: ${{ inputs.ref }} + shell: bash + run: | + set -euo pipefail + selected_sha="$(git rev-parse HEAD)" + trusted_reason="" + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + + if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then + trusted_reason="main-ancestor" + elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then + trusted_reason="release-tag" + else + pr_head_count="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \ + --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length' + )" + if [[ "$pr_head_count" != "0" ]]; then + trusted_reason="open-pr-head" + fi + fi + + if [[ -z "$trusted_reason" ]]; then + echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for this secret-bearing QA run." >&2 + echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2 + exit 1 + fi + + echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT" + echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT" + { + echo "Validated ref: \`${INPUT_REF}\`" + echo "Resolved SHA: \`$selected_sha\`" + echo "Trust reason: \`$trusted_reason\`" + } >> "$GITHUB_STEP_SUMMARY" + + run_live_telegram: + name: Run Telegram live QA lane with Convex leases + needs: [authorize_actor, validate_selected_ref] + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + environment: qa-live-shared + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} + fetch-depth: 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + + - name: Validate required QA credential env + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} + OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} + shell: bash + run: | + set -euo pipefail + + require_var() { + local key="$1" + if [[ -z "${!key:-}" ]]; then + echo "Missing required ${key}." >&2 + exit 1 + fi + } + + require_var OPENAI_API_KEY + require_var OPENCLAW_QA_CONVEX_SITE_URL + require_var OPENCLAW_QA_CONVEX_SECRET_CI + + - name: Build private QA runtime + run: pnpm build + + - name: Run Telegram live lane + id: run_lane + shell: bash + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} + OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} + OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" + INPUT_SCENARIO: ${{ inputs.scenario }} + run: | + set -euo pipefail + + output_dir=".artifacts/qa-e2e/telegram-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + scenario_args=() + + if [[ -n "${INPUT_SCENARIO// }" ]]; then + IFS=',' read -r -a raw_scenarios <<<"${INPUT_SCENARIO}" + for raw in "${raw_scenarios[@]}"; do + scenario="$(printf '%s' "${raw}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [[ -n "${scenario}" ]]; then + scenario_args+=(--scenario "${scenario}") + fi + done + fi + + echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT" + + pnpm openclaw qa telegram \ + --repo-root . \ + --output-dir "${output_dir}" \ + --provider-mode live-frontier \ + --model openai/gpt-5.4 \ + --alt-model openai/gpt-5.4 \ + --fast \ + --credential-source convex \ + --credential-role ci \ + "${scenario_args[@]}" + + - name: Upload Telegram QA artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.run_lane.outputs.output_dir }} + retention-days: 14 + if-no-files-found: warn diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 4e5660dc34a..d0d2e70cf62 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -238,6 +238,19 @@ describe("buildQaRuntimeEnv", () => { expect(env.OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN).toBeUndefined(); }); + it("does not pass Convex credential broker secrets to the gateway child env", () => { + const env = buildQaRuntimeEnv({ + ...createParams({ + OPENCLAW_QA_CONVEX_SECRET_CI: "convex-ci-secret", + OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "convex-maintainer-secret", + }), + providerMode: "live-frontier", + }); + + expect(env.OPENCLAW_QA_CONVEX_SECRET_CI).toBeUndefined(); + expect(env.OPENCLAW_QA_CONVEX_SECRET_MAINTAINER).toBeUndefined(); + }); + it("requires an Anthropic key for live Claude CLI API-key mode", async () => { const hostHome = await mkdtemp(path.join(os.tmpdir(), "qa-host-home-")); cleanups.push(async () => { @@ -564,7 +577,17 @@ describe("buildQaRuntimeEnv", () => { await mkdir(path.dirname(artifactDir), { recursive: true }); await writeFile( stdoutLogPath, - 'OPENCLAW_GATEWAY_TOKEN=qa-suite-token\nOPENAI_API_KEY="openai-live"\nurl=http://127.0.0.1:18789/#token=abc123', + [ + "OPENCLAW_GATEWAY_TOKEN=qa-suite-token", + 'OPENAI_API_KEY="openai-live"', + "OPENCLAW_QA_CONVEX_SECRET_CI=convex-ci-secret", + "OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=convex-maintainer-secret", + "botToken=12345:AbCdEfGhIjKl", + '"driverToken":"12345:driver-secr3t"', + "sutToken='12345:sut-secr3t'", + "leaseToken=lease-12345", + "url=http://127.0.0.1:18789/#token=abc123", + ].join("\n"), "utf8", ); await writeFile(stderrLogPath, "Authorization: Bearer secret+/token=123456", "utf8"); @@ -585,7 +608,17 @@ describe("buildQaRuntimeEnv", () => { "gateway.stdout.log", ]); await expect(readFile(path.join(artifactDir, "gateway.stdout.log"), "utf8")).resolves.toBe( - "OPENCLAW_GATEWAY_TOKEN=\nOPENAI_API_KEY=\nurl=http://127.0.0.1:18789/#token=", + [ + "OPENCLAW_GATEWAY_TOKEN=", + "OPENAI_API_KEY=", + "OPENCLAW_QA_CONVEX_SECRET_CI=", + "OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=", + "botToken=", + '"driverToken":""', + "sutToken=", + "leaseToken=", + "url=http://127.0.0.1:18789/#token=", + ].join("\n"), ); await expect(readFile(path.join(artifactDir, "gateway.stderr.log"), "utf8")).resolves.toBe( "Authorization: Bearer ", diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index a4e3ed4c586..19a62f3e8e8 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -42,6 +42,10 @@ import type { QaTransportAdapter } from "./qa-transport.js"; export type { QaCliBackendAuthMode } from "./providers/env.js"; const QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS = 5; +const QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS = Object.freeze([ + "OPENCLAW_QA_CONVEX_SECRET_CI", + "OPENCLAW_QA_CONVEX_SECRET_MAINTAINER", +]); export type QaGatewayChildStateMutationContext = { configPath: string; @@ -216,6 +220,9 @@ export function buildQaRuntimeEnv(params: { const normalizedEnv = normalizeQaProviderModeEnv(env, params.providerMode); delete normalizedEnv[QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV]; delete normalizedEnv[QA_LIVE_SETUP_TOKEN_VALUE_ENV]; + for (const envKey of QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS) { + delete normalizedEnv[envKey]; + } return normalizedEnv; } @@ -545,7 +552,7 @@ export async function startQaGatewayChild(params: { let baseUrl = ""; let wsUrl = ""; let child: ReturnType | null = null; - let cfg: ReturnType | null = null; + let cfg!: OpenClawConfig; let rpcClient: Awaited> | null = null; let stagedBundledPluginsRoot: string | null = null; let env: NodeJS.ProcessEnv | null = null; diff --git a/extensions/qa-lab/src/gateway-log-redaction.ts b/extensions/qa-lab/src/gateway-log-redaction.ts index 89443b5de2e..b0c0e664bd9 100644 --- a/extensions/qa-lab/src/gateway-log-redaction.ts +++ b/extensions/qa-lab/src/gateway-log-redaction.ts @@ -4,6 +4,12 @@ const QA_GATEWAY_DEBUG_SECRET_ENV_VARS = Object.freeze([ ...QA_PROVIDER_SECRET_ENV_VARS, "OPENCLAW_GATEWAY_TOKEN", ]); +const QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS = Object.freeze([ + "botToken", + "driverToken", + "sutToken", + "leaseToken", +]); export function redactQaGatewayDebugText(text: string) { let redacted = text; @@ -18,6 +24,17 @@ export function redactQaGatewayDebugText(text: string) { `$1""`, ); } + for (const key of QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS) { + const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); + redacted = redacted.replace( + new RegExp(`\\b(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"), + `$1$2`, + ); + redacted = redacted.replace( + new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"), + `$1""`, + ); + } return redacted .replaceAll(/\bsk-ant-oat01-[A-Za-z0-9_-]+\b/g, "") .replaceAll(/\bBearer\s+[^\s"'<>]{8,}/gi, "Bearer ") diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts index a9e26797952..f4481b0e984 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts @@ -402,6 +402,7 @@ describe("telegram live qa runtime", () => { expect( __testing.buildObservedMessagesArtifact({ includeContent: false, + redactMetadata: false, observedMessages: [ { updateId: 1, @@ -435,6 +436,81 @@ describe("telegram live qa runtime", () => { ]); }); + it("redacts observed message metadata in public mode even when content capture is requested", () => { + const redacted = __testing.buildObservedMessagesArtifact({ + includeContent: true, + redactMetadata: true, + observedMessages: [ + { + updateId: 1, + messageId: 9, + chatId: -100123, + senderId: 42, + senderIsBot: true, + senderUsername: "driver_bot", + text: "secret text", + caption: "secret caption", + replyToMessageId: 8, + timestamp: 1_700_000_000_000, + inlineButtons: ["Approve"], + mediaKinds: ["photo"], + }, + ], + }); + + expect(redacted).toEqual([ + { + senderIsBot: true, + inlineButtonCount: 1, + mediaKinds: ["photo"], + }, + ]); + expect(redacted[0]).not.toHaveProperty("timestamp"); + expect(redacted[0]).not.toHaveProperty("inlineButtons"); + expect(redacted[0]).not.toHaveProperty("text"); + expect(redacted[0]).not.toHaveProperty("caption"); + }); + + it("keeps raw timestamp and inline button text when metadata redaction is disabled", () => { + expect( + __testing.buildObservedMessagesArtifact({ + includeContent: true, + redactMetadata: false, + observedMessages: [ + { + updateId: 1, + messageId: 9, + chatId: -100123, + senderId: 42, + senderIsBot: true, + senderUsername: "driver_bot", + text: "secret text", + caption: "secret caption", + replyToMessageId: 8, + timestamp: 1_700_000_000_000, + inlineButtons: ["Approve"], + mediaKinds: ["photo"], + }, + ], + }), + ).toEqual([ + { + updateId: 1, + messageId: 9, + chatId: -100123, + senderId: 42, + senderIsBot: true, + timestamp: 1_700_000_000_000, + inlineButtons: ["Approve"], + senderUsername: "driver_bot", + replyToMessageId: 8, + text: "secret text", + caption: "secret caption", + mediaKinds: ["photo"], + }, + ]); + }); + it("formats phase-specific canary diagnostics with context", () => { const error = new Error( "SUT bot did not send any group reply after the canary command within 30s.", @@ -464,6 +540,38 @@ describe("telegram live qa runtime", () => { ); }); + it("redacts canary context details in public metadata mode", () => { + const error = new Error("timed out"); + error.name = "TelegramQaCanaryError"; + Object.assign(error, { + phase: "sut_reply_timeout", + context: { + driverMessageId: 55, + }, + }); + + const message = __testing.canaryFailureMessage({ + error, + groupId: "-100123", + driverBotId: 42, + driverUsername: "driver_bot", + redactMetadata: true, + sutBotId: 88, + sutUsername: "sut_bot", + }); + + expect(message).toContain("- groupId: "); + expect(message).toContain("- driverBotId: "); + expect(message).toContain("- driverUsername: "); + expect(message).toContain("- sutBotId: "); + expect(message).toContain("- sutUsername: "); + expect(message).toContain("- driverMessageId: "); + expect(message).not.toContain("-100123"); + expect(message).not.toContain("driver_bot"); + expect(message).not.toContain("sut_bot"); + expect(message).not.toContain("55"); + }); + it("treats null canary context as a non-canary error", () => { const error = new Error("boom"); error.name = "TelegramQaCanaryError"; diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index 763824e1d5c..3fa82a35a49 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -73,9 +73,20 @@ type TelegramObservedMessage = { mediaKinds: string[]; }; -type TelegramObservedMessageArtifact = Omit & { +type TelegramObservedMessageArtifact = { + updateId?: number; + messageId?: number; + chatId?: number; + senderId?: number; + senderIsBot: boolean; + senderUsername?: string; text?: string; caption?: string; + replyToMessageId?: number; + inlineButtonCount?: number; + timestamp?: number; + inlineButtons?: string[]; + mediaKinds: string[]; }; type TelegramQaScenarioResult = { @@ -279,6 +290,8 @@ const TELEGRAM_QA_ENV_KEYS = [ "OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN", "OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN", ] as const; +const TELEGRAM_QA_CAPTURE_CONTENT_ENV = "OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT"; +const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA"; const telegramQaCredentialPayloadSchema = z.object({ groupId: z.string().trim().min(1), @@ -294,6 +307,11 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof TELEGRAM_QA_ENV_KE return value; } +function isTruthyOptIn(value: string | undefined) { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + export function resolveTelegramQaRuntimeEnv( env: NodeJS.ProcessEnv = process.env, ): TelegramQaRuntimeEnv { @@ -561,6 +579,7 @@ async function waitForTelegramChannelRunning( function renderTelegramQaMarkdown(params: { cleanupIssues: string[]; credentialSource: "convex" | "env"; + redactMetadata: boolean; groupId: string; startedAt: string; finishedAt: string; @@ -571,6 +590,7 @@ function renderTelegramQaMarkdown(params: { "", `- Credential source: \`${params.credentialSource}\``, `- Group: \`${params.groupId}\``, + `- Metadata redaction: \`${params.redactMetadata ? "enabled" : "disabled"}\``, `- Started: ${params.startedAt}`, `- Finished: ${params.finishedAt}`, "", @@ -598,23 +618,36 @@ function renderTelegramQaMarkdown(params: { function buildObservedMessagesArtifact(params: { observedMessages: TelegramObservedMessage[]; includeContent: boolean; + redactMetadata: boolean; }) { - return params.observedMessages.map((message) => - params.includeContent - ? { ...message } + return params.observedMessages.map((message) => { + const base = params.redactMetadata + ? { + senderIsBot: message.senderIsBot, + inlineButtonCount: message.inlineButtons.length, + mediaKinds: message.mediaKinds, + } : { + senderIsBot: message.senderIsBot, + timestamp: message.timestamp, + inlineButtons: message.inlineButtons, + mediaKinds: message.mediaKinds, updateId: message.updateId, messageId: message.messageId, chatId: message.chatId, senderId: message.senderId, - senderIsBot: message.senderIsBot, senderUsername: message.senderUsername, replyToMessageId: message.replyToMessageId, - timestamp: message.timestamp, - inlineButtons: message.inlineButtons, - mediaKinds: message.mediaKinds, - }, - ); + }; + if (!params.includeContent || params.redactMetadata) { + return base; + } + return { + ...base, + text: message.text, + caption: message.caption, + }; + }); } function findScenario(ids?: string[]) { @@ -765,11 +798,14 @@ function canaryFailureMessage(params: { groupId: string; driverBotId: number; driverUsername?: string; + redactMetadata?: boolean; sutBotId: number; sutUsername: string; }) { const error = params.error; - const details = formatErrorMessage(error); + const details = params.redactMetadata + ? "details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)" + : formatErrorMessage(error); const phase = isTelegramQaCanaryError(error) ? error.phase : "unknown"; const canonicalContext = new Set([ "groupId", @@ -781,7 +817,9 @@ function canaryFailureMessage(params: { const context = isTelegramQaCanaryError(error) ? Object.entries(error.context) .filter(([key, value]) => value !== undefined && value !== "" && !canonicalContext.has(key)) - .map(([key, value]) => `- ${key}: ${String(value)}`) + .map(([key, value]) => + params.redactMetadata ? `- ${key}: ` : `- ${key}: ${String(value)}`, + ) : []; const remediation = (() => { switch (phase) { @@ -817,11 +855,11 @@ function canaryFailureMessage(params: { `Phase: ${phase}`, details, "Context:", - `- groupId: ${params.groupId}`, - `- driverBotId: ${params.driverBotId}`, - `- driverUsername: ${params.driverUsername ?? ""}`, - `- sutBotId: ${params.sutBotId}`, - `- sutUsername: ${params.sutUsername}`, + `- groupId: ${params.redactMetadata ? "" : params.groupId}`, + `- driverBotId: ${params.redactMetadata ? "" : params.driverBotId}`, + `- driverUsername: ${params.redactMetadata ? "" : (params.driverUsername ?? "")}`, + `- sutBotId: ${params.redactMetadata ? "" : params.sutBotId}`, + `- sutUsername: ${params.redactMetadata ? "" : params.sutUsername}`, ...context, "Remediation:", ...remediation, @@ -867,7 +905,9 @@ export async function runTelegramQaLive(params: { const sutAccountId = params.sutAccountId?.trim() || "sut"; const scenarios = findScenario(params.scenarioIds); const observedMessages: TelegramObservedMessage[] = []; - const includeObservedMessageContent = process.env.OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT === "1"; + const redactPublicMetadata = isTruthyOptIn(process.env[QA_REDACT_PUBLIC_METADATA_ENV]); + const includeObservedMessageContent = + !redactPublicMetadata && isTruthyOptIn(process.env[TELEGRAM_QA_CAPTURE_CONTENT_ENV]); const startedAt = new Date().toISOString(); const scenarioResults: TelegramQaScenarioResult[] = []; const cleanupIssues: string[] = []; @@ -926,6 +966,7 @@ export async function runTelegramQaLive(params: { groupId: runtimeEnv.groupId, driverBotId: driverIdentity.id, driverUsername: driverIdentity.username, + redactMetadata: redactPublicMetadata, sutBotId: sutIdentity.id, sutUsername, }); @@ -974,7 +1015,9 @@ export async function runTelegramQaLive(params: { id: scenario.id, title: scenario.title, status: "pass", - details: `reply message ${matched.message.messageId} matched`, + details: redactPublicMetadata + ? "reply matched" + : `reply message ${matched.message.messageId} matched`, }); } catch (error) { if (!scenarioRun.expectReply) { @@ -995,7 +1038,9 @@ export async function runTelegramQaLive(params: { id: scenario.id, title: scenario.title, status: "fail", - details: formatErrorMessage(error), + details: redactPublicMetadata + ? "details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)" + : formatErrorMessage(error), }); } assertLeaseHealthy(); @@ -1018,18 +1063,21 @@ export async function runTelegramQaLive(params: { } const finishedAt = new Date().toISOString(); + const publishedCleanupIssues = redactPublicMetadata + ? cleanupIssues.map(() => "details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)") + : cleanupIssues; const summary: TelegramQaSummary = { credentials: { source: credentialLease.source, kind: credentialLease.kind, role: credentialLease.role, - ownerId: credentialLease.ownerId, - credentialId: credentialLease.credentialId, + ownerId: redactPublicMetadata ? undefined : credentialLease.ownerId, + credentialId: redactPublicMetadata ? undefined : credentialLease.credentialId, }, - groupId: runtimeEnv.groupId, + groupId: redactPublicMetadata ? "" : runtimeEnv.groupId, startedAt, finishedAt, - cleanupIssues, + cleanupIssues: publishedCleanupIssues, counts: { total: scenarioResults.length, passed: scenarioResults.filter((entry) => entry.status === "pass").length, @@ -1043,9 +1091,10 @@ export async function runTelegramQaLive(params: { await fs.writeFile( reportPath, `${renderTelegramQaMarkdown({ - cleanupIssues, + cleanupIssues: publishedCleanupIssues, credentialSource: credentialLease.source, - groupId: runtimeEnv.groupId, + redactMetadata: redactPublicMetadata, + groupId: redactPublicMetadata ? "" : runtimeEnv.groupId, startedAt, finishedAt, scenarios: scenarioResults, @@ -1062,6 +1111,7 @@ export async function runTelegramQaLive(params: { buildObservedMessagesArtifact({ observedMessages, includeContent: includeObservedMessageContent, + redactMetadata: redactPublicMetadata, }), null, 2, @@ -1085,7 +1135,7 @@ export async function runTelegramQaLive(params: { throw new Error( buildLiveLaneArtifactsError({ heading: "Telegram QA cleanup failed after artifacts were written.", - details: cleanupIssues, + details: publishedCleanupIssues, artifacts: artifactPaths, }), ); diff --git a/extensions/qa-lab/src/providers/env.ts b/extensions/qa-lab/src/providers/env.ts index 461d490b8a0..111d7afca00 100644 --- a/extensions/qa-lab/src/providers/env.ts +++ b/extensions/qa-lab/src/providers/env.ts @@ -42,6 +42,8 @@ export const QA_PROVIDER_SECRET_ENV_VARS = Object.freeze([ "OPENCLAW_LIVE_ANTHROPIC_KEYS", "OPENCLAW_LIVE_GEMINI_KEY", "OPENCLAW_LIVE_OPENAI_KEY", + "OPENCLAW_QA_CONVEX_SECRET_CI", + "OPENCLAW_QA_CONVEX_SECRET_MAINTAINER", "VOYAGE_API_KEY", ]); diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 099566092ee..ee44c0793c3 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -20,6 +20,15 @@ const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({ this.options = options; }), })); +const logWarnMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../logger.js", async () => { + const actual = await vi.importActual("../../logger.js"); + return { + ...actual, + logWarn: logWarnMock, + }; +}); function createPinnedDispatcherCompatibilityError(): Error { const cause = Object.assign(new Error("invalid onRequestStart method"), { @@ -161,6 +170,7 @@ describe("fetchWithSsrFGuard hardening", () => { agentCtor.mockClear(); envHttpProxyAgentCtor.mockClear(); proxyAgentCtor.mockClear(); + logWarnMock.mockClear(); Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); }); @@ -194,6 +204,26 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("logs blocked URL fetches without path/query metadata", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "http://127.0.0.1:8080/private/secret?token=abc#frag", + fetchImpl, + auditContext: "qa-audit", + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(logWarnMock).toHaveBeenCalledTimes(1); + const [warning] = logWarnMock.mock.calls[0] as [string]; + expect(warning).toContain( + "security: blocked URL fetch (qa-audit) targetOrigin=http://127.0.0.1:8080", + ); + expect(warning).not.toContain("/private/secret"); + expect(warning).not.toContain("token=abc"); + expect(warning).not.toContain("#frag"); + }); + it("allows RFC2544 benchmark range IPv4 literal URLs when explicitly opted in", async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(new Response("ok", { status: 200 })); const result = await fetchWithSsrFGuard({ diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 6c95d077eb4..0f053fa8d92 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -440,7 +440,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise