Add maintainer-gated Telegram live QA workflow with Convex hardening (#70427)

This commit is contained in:
Josh Avant
2026-04-22 23:17:09 -05:00
committed by GitHub
parent 6317eda3fe
commit 01e18b6e3b
9 changed files with 479 additions and 31 deletions

View File

@@ -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

View File

@@ -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=<redacted>\nOPENAI_API_KEY=<redacted>\nurl=http://127.0.0.1:18789/#token=<redacted>",
[
"OPENCLAW_GATEWAY_TOKEN=<redacted>",
"OPENAI_API_KEY=<redacted>",
"OPENCLAW_QA_CONVEX_SECRET_CI=<redacted>",
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=<redacted>",
"botToken=<redacted>",
'"driverToken":"<redacted>"',
"sutToken=<redacted>",
"leaseToken=<redacted>",
"url=http://127.0.0.1:18789/#token=<redacted>",
].join("\n"),
);
await expect(readFile(path.join(artifactDir, "gateway.stderr.log"), "utf8")).resolves.toBe(
"Authorization: Bearer <redacted>",

View File

@@ -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<typeof spawn> | null = null;
let cfg: ReturnType<typeof buildQaGatewayConfig> | null = null;
let cfg!: OpenClawConfig;
let rpcClient: Awaited<ReturnType<typeof startQaGatewayRpcClient>> | null = null;
let stagedBundledPluginsRoot: string | null = null;
let env: NodeJS.ProcessEnv | null = null;

View File

@@ -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"<redacted>"`,
);
}
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 = redacted.replace(
new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"),
`$1"<redacted>"`,
);
}
return redacted
.replaceAll(/\bsk-ant-oat01-[A-Za-z0-9_-]+\b/g, "<redacted>")
.replaceAll(/\bBearer\s+[^\s"'<>]{8,}/gi, "Bearer <redacted>")

View File

@@ -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: <redacted>");
expect(message).toContain("- driverBotId: <redacted>");
expect(message).toContain("- driverUsername: <redacted>");
expect(message).toContain("- sutBotId: <redacted>");
expect(message).toContain("- sutUsername: <redacted>");
expect(message).toContain("- driverMessageId: <redacted>");
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";

View File

@@ -73,9 +73,20 @@ type TelegramObservedMessage = {
mediaKinds: string[];
};
type TelegramObservedMessageArtifact = Omit<TelegramObservedMessage, "text" | "caption"> & {
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<TelegramObservedMessageArtifact>((message) =>
params.includeContent
? { ...message }
return params.observedMessages.map<TelegramObservedMessageArtifact>((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}: <redacted>` : `- ${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 ?? "<none>"}`,
`- sutBotId: ${params.sutBotId}`,
`- sutUsername: ${params.sutUsername}`,
`- groupId: ${params.redactMetadata ? "<redacted>" : params.groupId}`,
`- driverBotId: ${params.redactMetadata ? "<redacted>" : params.driverBotId}`,
`- driverUsername: ${params.redactMetadata ? "<redacted>" : (params.driverUsername ?? "<none>")}`,
`- sutBotId: ${params.redactMetadata ? "<redacted>" : params.sutBotId}`,
`- sutUsername: ${params.redactMetadata ? "<redacted>" : 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 ? "<redacted>" : 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 ? "<redacted>" : 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,
}),
);

View File

@@ -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",
]);

View File

@@ -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<typeof import("../../logger.js")>("../../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({

View File

@@ -440,7 +440,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
if (err instanceof SsrFBlockedError) {
const context = params.auditContext ?? "url-fetch";
logWarn(
`security: blocked URL fetch (${context}) target=${parsedUrl.origin}${parsedUrl.pathname} reason=${err.message}`,
`security: blocked URL fetch (${context}) targetOrigin=${parsedUrl.origin} reason=${err.message}`,
);
}
await release(dispatcher);