mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
Add maintainer-gated Telegram live QA workflow with Convex hardening (#70427)
This commit is contained in:
201
.github/workflows/qa-live-telegram-convex.yml
vendored
Normal file
201
.github/workflows/qa-live-telegram-convex.yml
vendored
Normal 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
|
||||
@@ -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>",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user