test(buzz): add live QA Lab channel lane (#116298)

* feat(buzz): add live QA runner

* ci(qa): add gated Buzz live lane

* docs(qa): document Buzz live testing

* fix(buzz): keep QA relay type private

* test(buzz): type QA suite mock

* fix(buzz): preserve racing QA reply relations

* fix(qa): harden Buzz credential redaction

* fix(buzz): ignore retained QA relay events

* fix(buzz): clean up observer setup failures

* test(buzz): return complete racing messages

* fix(buzz): default QA credentials to file

* fix(qa): validate pooled Buzz secrets

* fix(qa): redact structured Buzz auth tags

* fix(buzz): preserve QA credential env override

* fix(qa): redact inspected Buzz auth tags

* test(buzz): limit live QA to supported scenarios

* ci(qa): run Buzz lane in release checks

* fix(qa): require secure remote Buzz relays

* fix(qa): satisfy Buzz QA validation gates

* fix(qa): inject credential leasing into transport adapters
This commit is contained in:
Patrick Erichsen
2026-07-30 17:44:27 -07:00
committed by GitHub
parent 8c61044576
commit 61cf4b9246
33 changed files with 2010 additions and 33 deletions
+30 -2
View File
@@ -81,7 +81,7 @@ on:
- qa-parity
- qa-live
live_suite_filter:
description: Optional exact live/E2E suite id, or comma-separated QA live lane ids (qa-live-matrix, qa-live-telegram, qa-live-discord, qa-live-whatsapp, qa-live-slack); blank runs all selected live suites
description: Optional exact live/E2E suite id, or comma-separated QA live lane ids (qa-live-matrix, qa-live-buzz, qa-live-telegram, qa-live-discord, qa-live-whatsapp, qa-live-slack); blank runs all selected live suites
required: false
default: ""
type: string
@@ -146,6 +146,7 @@ jobs:
repo_live_suite_filter: ${{ steps.inputs.outputs.repo_live_suite_filter }}
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
qa_live_matrix_enabled: ${{ steps.inputs.outputs.qa_live_matrix_enabled }}
qa_live_buzz_enabled: ${{ steps.inputs.outputs.qa_live_buzz_enabled }}
qa_live_telegram_enabled: ${{ steps.inputs.outputs.qa_live_telegram_enabled }}
qa_live_discord_enabled: ${{ steps.inputs.outputs.qa_live_discord_enabled }}
qa_live_whatsapp_enabled: ${{ steps.inputs.outputs.qa_live_whatsapp_enabled }}
@@ -333,6 +334,7 @@ jobs:
run: |
set -euo pipefail
qa_live_matrix_enabled=true
qa_live_buzz_enabled=true
qa_live_telegram_enabled=true
qa_live_discord_ci_enabled="$(printf '%s' "$RELEASE_QA_DISCORD_LIVE_CI_ENABLED" | tr '[:upper:]' '[:lower:]')"
if [[ "$qa_live_discord_ci_enabled" != "true" && "$qa_live_discord_ci_enabled" != "1" && "$qa_live_discord_ci_enabled" != "yes" ]]; then
@@ -408,6 +410,7 @@ jobs:
qa_filter_seen=false
repo_filter_tokens=()
matrix_selected=false
buzz_selected=false
telegram_selected=false
discord_selected=false
whatsapp_selected=false
@@ -424,6 +427,7 @@ jobs:
qa-live|qa-live-all|qa-all)
qa_filter_seen=true
matrix_selected=true
buzz_selected=true
telegram_selected=true
discord_selected="$qa_live_discord_ci_enabled"
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
@@ -435,6 +439,7 @@ jobs:
qa-live-non-slack|qa-non-slack|non-slack|no-slack|without-slack)
qa_filter_seen=true
matrix_selected=true
buzz_selected=true
telegram_selected=true
discord_selected="$qa_live_discord_ci_enabled"
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
@@ -445,6 +450,10 @@ jobs:
qa_filter_seen=true
matrix_selected=true
;;
qa-live-buzz|qa-buzz|buzz)
qa_filter_seen=true
buzz_selected=true
;;
qa-live-telegram|qa-telegram|telegram)
qa_filter_seen=true
telegram_selected=true
@@ -482,6 +491,7 @@ jobs:
if [[ "$qa_filter_seen" == "true" ]]; then
qa_live_matrix_enabled="$matrix_selected"
qa_live_buzz_enabled="$buzz_selected"
qa_live_telegram_enabled="$telegram_selected"
qa_live_discord_enabled="$discord_selected"
qa_live_whatsapp_enabled="$whatsapp_selected"
@@ -503,6 +513,7 @@ jobs:
printf 'repo_live_suite_filter=%s\n' "$repo_live_suite_filter"
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
printf 'qa_live_matrix_enabled=%s\n' "$qa_live_matrix_enabled"
printf 'qa_live_buzz_enabled=%s\n' "$qa_live_buzz_enabled"
printf 'qa_live_telegram_enabled=%s\n' "$qa_live_telegram_enabled"
printf 'qa_live_discord_enabled=%s\n' "$qa_live_discord_enabled"
printf 'qa_live_whatsapp_enabled=%s\n' "$qa_live_whatsapp_enabled"
@@ -551,7 +562,7 @@ jobs:
if [[ -n "${RELEASE_CROSS_OS_SUITE_FILTER// }" ]]; then
echo "- Cross-OS suite filter: \`${RELEASE_CROSS_OS_SUITE_FILTER}\`"
fi
echo "- QA live lanes: Matrix \`${{ steps.inputs.outputs.qa_live_matrix_enabled }}\`, Telegram \`${{ steps.inputs.outputs.qa_live_telegram_enabled }}\`, Discord \`${{ steps.inputs.outputs.qa_live_discord_enabled }}\`, WhatsApp \`${{ steps.inputs.outputs.qa_live_whatsapp_enabled }}\`, Slack \`${{ steps.inputs.outputs.qa_live_slack_enabled }}\`"
echo "- QA live lanes: Matrix \`${{ steps.inputs.outputs.qa_live_matrix_enabled }}\`, Buzz \`${{ steps.inputs.outputs.qa_live_buzz_enabled }}\`, Telegram \`${{ steps.inputs.outputs.qa_live_telegram_enabled }}\`, Discord \`${{ steps.inputs.outputs.qa_live_discord_enabled }}\`, WhatsApp \`${{ steps.inputs.outputs.qa_live_whatsapp_enabled }}\`, Slack \`${{ steps.inputs.outputs.qa_live_slack_enabled }}\`"
if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Release package spec: \`${RELEASE_PACKAGE_SPEC}\`"
fi
@@ -1655,6 +1666,20 @@ jobs:
run_matrix: true
fail_fast: ${{ fromJSON(needs.resolve_target.outputs.fail_fast) }}
qa_live_buzz_release_checks:
name: Run QA Lab live Buzz lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_buzz_enabled == 'true'
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/qa-live-transports-convex.yml
with:
ref: ${{ needs.resolve_target.outputs.revision }}
expected_sha: ${{ needs.resolve_target.outputs.revision }}
run_buzz: true
buzz_scenario: channel-canary,channel-mention-gating
# The dispatched child owns Telegram evidence/status artifacts; this blocking job
# carries its exact conclusion into the parent summary without copying secrets or artifacts.
qa_live_telegram_release_checks:
@@ -2108,6 +2133,7 @@ jobs:
- qa_lab_runtime_parity_release_checks
- runtime_tool_coverage_release_checks
- qa_live_release_checks
- qa_live_buzz_release_checks
- qa_live_telegram_release_checks
- qa_live_discord_release_checks
- qa_live_whatsapp_release_checks
@@ -2143,6 +2169,7 @@ jobs:
QA_LAB_RUNTIME_PARITY_RELEASE_CHECKS_RESULT: ${{ needs.qa_lab_runtime_parity_release_checks.result }}
RUNTIME_TOOL_COVERAGE_RELEASE_CHECKS_RESULT: ${{ needs.runtime_tool_coverage_release_checks.result }}
QA_LIVE_RELEASE_CHECKS_RESULT: ${{ needs.qa_live_release_checks.result }}
QA_LIVE_BUZZ_RELEASE_CHECKS_RESULT: ${{ needs.qa_live_buzz_release_checks.result }}
QA_LIVE_TELEGRAM_RELEASE_CHECKS_RESULT: ${{ needs.qa_live_telegram_release_checks.result }}
QA_LIVE_TELEGRAM_SELECTED: ${{ contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_telegram_enabled == 'true' }}
QA_LIVE_DISCORD_RELEASE_CHECKS_RESULT: ${{ needs.qa_live_discord_release_checks.result }}
@@ -2169,6 +2196,7 @@ jobs:
"qa_lab_runtime_parity_release_checks=${QA_LAB_RUNTIME_PARITY_RELEASE_CHECKS_RESULT}"
"runtime_tool_coverage_release_checks=${RUNTIME_TOOL_COVERAGE_RELEASE_CHECKS_RESULT}"
"qa_live_release_checks=${QA_LIVE_RELEASE_CHECKS_RESULT}"
"qa_live_buzz_release_checks=${QA_LIVE_BUZZ_RELEASE_CHECKS_RESULT}"
"qa_live_telegram_release_checks=${QA_LIVE_TELEGRAM_RELEASE_CHECKS_RESULT}"
"qa_live_discord_release_checks=${QA_LIVE_DISCORD_RELEASE_CHECKS_RESULT}"
"qa_live_whatsapp_release_checks=${QA_LIVE_WHATSAPP_RELEASE_CHECKS_RESULT}"
@@ -18,6 +18,16 @@ on:
required: false
default: false
type: boolean
run_buzz:
description: Run the Buzz live lane
required: false
default: false
type: boolean
buzz_scenario:
description: Optional comma-separated Buzz scenario ids
required: false
default: ""
type: string
run_matrix:
description: Run the Matrix live lane
required: false
@@ -55,6 +65,15 @@ on:
required: true
default: main
type: string
run_buzz:
description: Run the Buzz live lane (requires a pooled Buzz credential)
required: false
default: false
type: boolean
buzz_scenario:
description: Optional comma-separated Buzz scenario ids
required: false
type: string
scenario:
description: Optional comma-separated Telegram scenario ids
required: false
@@ -427,6 +446,95 @@ jobs:
retention-days: 14
if-no-files-found: error
run_live_buzz:
name: Run Buzz live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
if: inputs.run_buzz
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
concurrency:
group: qa-live-buzz-shared
cancel-in-progress: false
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Validate required Buzz QA credential env
env:
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
for key in OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CONVEX_SECRET_CI; do
if [[ -z "${!key:-}" ]]; then
echo "Missing required ${key}." >&2
exit 1
fi
done
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Buzz live lane
id: run_lane
shell: bash
env:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
INPUT_SCENARIO: ${{ inputs.buzz_scenario || '' }}
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/buzz-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 buzz \
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode mock-openai \
--credential-source convex \
--credential-role ci \
"${scenario_args[@]}"
- name: Upload Buzz QA artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ inputs.expected_sha != '' && format('release-qa-live-buzz-{0}-{1}', inputs.expected_sha, github.run_attempt) || format('qa-live-buzz-{0}-{1}', github.run_id, github.run_attempt) }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
run_live_telegram:
name: Run Telegram live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
+26
View File
@@ -338,6 +338,32 @@ openclaw message send \
For a full round trip, have an allowed Buzz user mention the bot and confirm that
OpenClaw replies in the room.
### QA Lab round trip
Source checkouts can exercise the production Buzz channel path with two
dedicated test identities:
```bash
pnpm openclaw qa buzz \
--credential-file /secure/path/buzz-qa-credentials.json \
--provider-mode mock-openai
```
The command runs a real relay canary and mention-gating check while using the
deterministic mock model. The private JSON credential
file contains `relayUrl`, `roomId`, `driverPrivateKey`, and `sutPrivateKey`, plus
optional `driverAuthTag` and `sutAuthTag` values for closed relays. Both test
public keys must be room members, and the SUT public key must have the **Bot**
role. A closed relay may require both public keys to be enrolled separately.
Use `--credential-source convex` for pooled QA credentials.
Use `wss://` for hosted relays. Plaintext `ws://` credential URLs are accepted
only for loopback development relays.
Never use a human owner or admin private key. Private keys and optional
authorization values are parent-harness secrets and must not appear in logs,
artifacts, screenshots, shell history, or source control.
## Rotate the bot identity
Bot identity rotation requires admin approval for the new public key:
+53 -11
View File
@@ -47,6 +47,7 @@ script aliases; both forms work.
| `qa aimock` | Start only the AIMock provider server. |
| `qa mock-openai` | Start only the scenario-aware `mock-openai` provider server. |
| `qa credentials doctor` / `add` / `list` / `remove` | Manage the shared Convex credential pool. |
| `qa buzz` | Live transport lane against a real Buzz relay room with dedicated driver and SUT identities. |
| `qa discord` | Live transport lane against a real private Discord guild channel. |
| `qa matrix` | QA Lab Matrix catalog scenarios against a disposable Tuwunel homeserver. See [Matrix live lane](#matrix-live-lane). |
| `qa slack` | Live transport lane against a real private Slack channel. |
@@ -291,6 +292,7 @@ decision still comes from the Discord REST oracle.
For the other transport-real smoke lanes:
```bash
pnpm openclaw qa buzz
pnpm openclaw qa discord
pnpm openclaw qa slack
pnpm openclaw qa telegram
@@ -299,8 +301,8 @@ pnpm openclaw qa whatsapp
They target a pre-existing real channel with two bots or accounts (driver +
SUT). Required env vars, scenario lists, output artifacts, and the Convex
credential pool for those four transports are documented in
[Discord, Slack, Telegram, and WhatsApp QA reference](#discord-slack-telegram-and-whatsapp-qa-reference)
credential pool for those five transports are documented in
[Buzz, Discord, Slack, Telegram, and WhatsApp QA reference](#buzz-discord-slack-telegram-and-whatsapp-qa-reference)
below.
### Mantis Slack desktop and visual-task runners
@@ -453,16 +455,16 @@ guest: env-based provider keys, the QA live provider config path, and
`CODEX_HOME` when present. Keep `--output-dir` under the repo root so the
guest can write back through the mounted workspace.
## Discord, Slack, Telegram, and WhatsApp QA reference
## Buzz, Discord, Slack, Telegram, and WhatsApp QA reference
The Matrix adapter uses the disposable Docker-backed lane documented above.
Discord, Slack, Telegram, and WhatsApp run against pre-existing real
Buzz, Discord, Slack, Telegram, and WhatsApp run against pre-existing real
transports, so their reference lives here.
### Shared CLI flags
These lanes register through
`extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts` and
These lanes register through the shared QA runner CLI contract. Transport
plugins may own the registration while QA Lab remains the suite host. They
accept the same flags:
| Flag | Default | Description |
@@ -471,11 +473,12 @@ accept the same flags:
| `--output-dir <path>` | `<repo>/.artifacts/qa-e2e/<transport>-<timestamp>` | Where reports, summaries, evidence, transport-specific artifacts, and the output log are written. Relative paths resolve against `--repo-root`. |
| `--repo-root <path>` | `process.cwd()` | Repository root when invoking from a neutral cwd. |
| `--sut-account <id>` | `sut` | Temporary account id inside the QA gateway config. |
| `--provider-mode <mode>` | `live-frontier` | `mock-openai`, `aimock`, or `live-frontier`. |
| `--provider-mode <mode>` | `live-frontier` (Buzz: `mock-openai`) | `mock-openai`, `aimock`, or `live-frontier`. |
| `--model <ref>` / `--alt-model <ref>` | provider default | Primary/alternate model refs. |
| `--fast` | off | Provider fast mode where supported. |
| `--credential-source <env\|convex>` | `env` | See [Convex credential pool](#convex-credential-pool). |
| `--credential-source <source>` | `env` (Buzz: `file`) | Existing lanes use `env` or `convex`; Buzz uses `file` or `convex`. See [Convex credential pool](#convex-credential-pool). |
| `--credential-role <maintainer\|ci>` | `ci` in CI, `maintainer` otherwise | Role used when `--credential-source convex`. |
| `--credential-file <path>` | - | Buzz-only JSON credential file for local runs. |
| `--allow-failures` | off | Write artifacts without returning a failing exit code when scenarios fail. |
Each lane exits non-zero on any failed scenario. `--allow-failures` writes
@@ -483,6 +486,40 @@ artifacts without setting a failing exit code. Telegram also accepts
`--list-scenarios` to print available scenario ids and exit; the other lanes
do not expose that flag.
### Buzz QA
```bash
pnpm openclaw qa buzz \
--credential-file /secure/path/buzz-qa-credentials.json
```
Targets one real Buzz room with two dedicated Nostr identities. The driver
publishes inbound room events; the SUT identity is configured in the child
OpenClaw Gateway and its outbound events are observed from the relay. The
default `mock-openai` provider proves the real Buzz transport without requiring
a model-provider credential.
Local runs use `--credential-file <path>` with a private JSON file containing
`relayUrl`, `roomId`, `driverPrivateKey`, and `sutPrivateKey`. Closed relays may
also need `driverAuthTag` and `sutAuthTag`. Relative paths resolve from
`--repo-root`. Hosted relays must use `wss://`; plaintext `ws://` is accepted
only for loopback development relays.
Both identities must be members of the dedicated room, and the SUT public key
must have the **Bot** role. A hosted closed relay may also require both public
keys to be enrolled as relay members. Use dedicated QA identities only; never
use a human owner or admin private key. Keep all private keys and authorization
values out of logs, command lines, artifacts, screenshots, and source control.
The default scenarios are:
- `channel-canary`
- `channel-mention-gating`
Each run writes `qa-suite-report.md`, `qa-suite-summary.json`, and
`qa-evidence.json` under the selected output directory. The report identifies
the real Buzz relay path but omits credential values.
### Telegram QA
```bash
@@ -1024,15 +1061,20 @@ Output artifacts:
### Convex credential pool
Discord, Slack, Telegram, and WhatsApp lanes can lease credentials from a
Buzz, Discord, Slack, Telegram, and WhatsApp lanes can lease credentials from a
shared Convex pool instead of reading the env vars above. Pass
`--credential-source convex` (or set `OPENCLAW_QA_CREDENTIAL_SOURCE=convex`);
QA Lab acquires an exclusive lease, heartbeats it for the duration of the
run, and releases it on shutdown. Pool kinds are `"discord"`, `"slack"`,
`"telegram"`, and `"whatsapp"`.
run, and releases it on shutdown. Pool kinds are `"buzz"`, `"discord"`,
`"slack"`, `"telegram"`, and `"whatsapp"`.
Payload shapes the broker validates on `admin/add`:
- Buzz (`kind: "buzz"`): `{ relayUrl: string, roomId: string,
driverPrivateKey: string, sutPrivateKey: string, driverAuthTag?: string,
sutAuthTag?: string }` - `relayUrl` must use `wss://`, with `ws://` allowed only
for loopback relays; `roomId` must be a channel UUID, and the identities must
be distinct.
- Discord (`kind: "discord"`): `{ guildId: string, channelId: string,
driverBotToken: string, sutBotToken: string, sutApplicationId: string }`.
- Telegram (`kind: "telegram"`): `{ groupId: string, driverToken: string,
+3 -1
View File
@@ -313,6 +313,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Manual configuration
- H3: Bot key storage
- H2: Verify the connection
- H3: QA Lab round trip
- H2: Rotate the bot identity
- H2: Current limits and roadmap
- H2: Troubleshooting
@@ -2955,8 +2956,9 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Mantis Slack desktop and visual-task runners
- H3: Credential pool health check
- H2: Canonical scenario coverage
- H2: Discord, Slack, Telegram, and WhatsApp QA reference
- H2: Buzz, Discord, Slack, Telegram, and WhatsApp QA reference
- H3: Shared CLI flags
- H3: Buzz QA
- H3: Telegram QA
- H3: Discord QA
- H3: Slack QA
+15 -2
View File
@@ -400,6 +400,18 @@ gh workflow run package-acceptance.yml --ref main \
- `pnpm openclaw qa aimock`
- Starts only the local AIMock provider server for direct protocol smoke
testing.
- `pnpm openclaw qa buzz`
- Runs the Buzz live QA lane against a real relay room using dedicated driver
and SUT identities.
- Local runs use `--credential-file <path>` with `relayUrl`, `roomId`,
`driverPrivateKey`, and `sutPrivateKey`. Closed relays may also need
`driverAuthTag` and `sutAuthTag`. Hosted relays require `wss://`; `ws://` is
accepted only for loopback development relays.
- Defaults to `mock-openai` and runs canary and mention-gating scenarios
through the real Buzz plugin path.
- Supports `--credential-source convex` with a pooled `kind: "buzz"` row.
Both public keys must be relay/room members, and the SUT must have the
**Bot** room role. Never use a human owner or admin private key.
- `pnpm openclaw qa matrix`
- Runs the Matrix live QA lane against a disposable Docker-backed Tuwunel
homeserver. Source-checkout only - packaged installs do not ship
@@ -488,8 +500,8 @@ drift; the per-lane coverage matrix lives in
When `--credential-source convex` (or `OPENCLAW_QA_CREDENTIAL_SOURCE=convex`)
is enabled for live transport QA, QA lab acquires an exclusive lease from a
Convex-backed pool, heartbeats that lease while the lane is running, and
releases the lease on shutdown. The section name predates Discord, Slack, and
WhatsApp support; the lease contract is shared across kinds.
releases the lease on shutdown. The section name predates Buzz, Discord, Slack,
and WhatsApp support; the lease contract is shared across kinds.
Reference Convex project scaffold: `qa/convex-credential-broker/`
@@ -575,6 +587,7 @@ Payload shape for Telegram real-user kind:
Broker-validated multi-channel payloads:
- Buzz: `{ relayUrl: string, roomId: string, driverPrivateKey: string, sutPrivateKey: string, driverAuthTag?: string, sutAuthTag?: string }`
- Discord: `{ guildId: string, channelId: string, driverBotToken: string, sutBotToken: string, sutApplicationId: string, voiceChannelId?: string }`
- WhatsApp: `{ driverPhoneE164: string, sutPhoneE164: string, driverAuthArchiveBase64: string, sutAuthArchiveBase64: string, groupJid?: string }`
+7 -6
View File
@@ -124,7 +124,7 @@ it before dispatching. A narrower `rerun_group` skips this preflight.
| Docker assets preflight | **Job:** `Verify Docker runtime image assets`<br />**Child workflow:** none<br />**Proves:** the `runtime-assets` Docker build target still succeeds before any other stage dispatches. Runs only for `rerun_group=all`.<br />**Rerun:** rerun the umbrella with `rerun_group=all`. |
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.<br />**Rerun:** `rerun_group=ci`. |
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**Proves:** release-only plugin static checks, agentic plugin coverage, full plugin batch shards, plugin prerelease Docker lanes, and a non-blocking `plugin-inspector-advisory` artifact for compatibility triage.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix and Telegram, plus gated advisory Discord, WhatsApp, and Slack lanes. Stable and full profiles also run exhaustive live/E2E suites and Docker release-path chunks; beta can opt in with `run_release_soak=true`.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, Buzz, and Telegram, plus gated advisory Discord, WhatsApp, and Slack lanes. Stable and full profiles also run exhaustive live/E2E suites and Docker release-path chunks; beta can opt in with `run_release_soak=true`.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**Proves:** a focused published-package Telegram E2E when `release_package_spec` or `npm_telegram_package_spec` is set. Full candidate validation uses the canonical Package Acceptance Telegram E2E instead.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
| Product performance | **Job:** `Run product performance evidence`<br />**Child workflow:** `OpenClaw Performance`<br />**Proves:** release-profile performance run (`profile=release`, `repeat=3`, `fail_on_regression=true`, `publish_reports=false`) against the target SHA. Kova output stays in workflow artifacts and the child must prove its report publisher was skipped. Required (blocking) only for `rerun_group=all` or `rerun_group=performance`; not required for narrower rerun groups.<br />**Rerun:** `rerun_group=performance`. |
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.<br />**Rerun:** rerun only this job after rerunning a failed child to green. |
@@ -175,6 +175,7 @@ artifact when package or Docker-facing stages need it.
| QA runtime parity | **Job:** `Verify QA Lab runtime-pair lanes`<br />**Backing workflow:** direct job<br />**Tests:** the canonical core `openclaw`/`codex` lane (`pnpm openclaw qa suite --runtime-pair openclaw,codex --runtime-pair-lane core`) and, with `run_release_soak=true`, the soak lane. Advisory: individual lane jobs do not block the release-check verifier.<br />**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. |
| QA runtime tool coverage | **Job:** `Enforce QA Lab runtime tool coverage`<br />**Backing workflow:** direct job<br />**Tests:** dynamic tool drift between `openclaw` and `codex` in the canonical core runtime-pair lane (`pnpm openclaw qa coverage --tools`), using that lane's output. Blocking: this job is not advisory-overridable.<br />**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. |
| QA live Matrix | **Job:** `Run QA Live Matrix catalog`<br />**Backing workflow:** `QA-Lab - All Lanes` reusable workflow<br />**Tests:** catalog-derived YAML scenarios through the shared Matrix live adapter in the `qa-live-shared` environment, distributed across deterministic shards.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`; use `live_suite_filter=qa-live-matrix` for a focused Matrix rerun. |
| QA live Buzz | **Job:** `Run QA Lab live Buzz lane`<br />**Backing workflow:** `QA-Lab - All Lanes` reusable workflow<br />**Tests:** signed canary and mention-gating round trips through the real Buzz plugin using dedicated Convex-leased identities and a hosted relay room.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`; use `live_suite_filter=qa-live-buzz` for a focused Buzz rerun. |
| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`<br />**Backing workflow:** trusted `OpenClaw Release Telegram QA` dispatch<br />**Tests:** live Telegram QA with Convex CI credential leases.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
| QA live Discord | **Job:** `Run QA Lab live Discord lane`<br />**Backing workflow:** direct advisory job<br />**Tests:** live Discord QA with Convex CI credential leases when `OPENCLAW_RELEASE_QA_DISCORD_LIVE_CI_ENABLED` is enabled.<br />**Rerun:** `rerun_group=qa-live` with `live_suite_filter=qa-live-discord`. |
| QA live WhatsApp | **Job:** `Run QA Lab live WhatsApp lane`<br />**Backing workflow:** direct advisory job<br />**Tests:** live WhatsApp QA with Convex CI credential leases when `OPENCLAW_RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED` is enabled.<br />**Rerun:** `rerun_group=qa-live` with `live_suite_filter=qa-live-whatsapp`. |
@@ -251,7 +252,7 @@ Use `rerun_group` to avoid repeating unrelated release boxes:
| `package` | Package Acceptance. |
| `qa` | QA parity plus QA live lanes. |
| `qa-parity` | QA parity lanes and report only. |
| `qa-live` | QA live Matrix/Telegram plus gated Discord, WhatsApp, and Slack lanes when enabled. |
| `qa-live` | QA live Matrix, Buzz, and Telegram plus gated Discord, WhatsApp, and Slack lanes when enabled. |
| `npm-telegram` | Published-package Telegram E2E; requires `release_package_spec` or `npm_telegram_package_spec`. |
| `performance` | Product performance evidence only. |
@@ -264,8 +265,8 @@ Valid filter ids are defined in the reusable live/E2E workflow, including
`live-codex-harness-docker`.
For a focused QA transport rerun, set `rerun_group=qa-live` and use the
canonical selector `qa-live-matrix`, `qa-live-telegram`, `qa-live-discord`,
`qa-live-whatsapp`, or `qa-live-slack`.
canonical selector `qa-live-matrix`, `qa-live-buzz`, `qa-live-telegram`,
`qa-live-discord`, `qa-live-whatsapp`, or `qa-live-slack`.
The `live-gateway-advisory-docker` handle is an aggregate rerun handle for its
three provider shards, so it still fans out to all advisory Docker gateway jobs.
@@ -310,8 +311,8 @@ Useful artifacts:
- Docker release-path artifacts under `.artifacts/docker-tests/`
- Package Acceptance `package-under-test` and Docker acceptance artifacts
- Cross-OS release-check artifacts for each OS and suite
- QA parity, runtime parity, and selected Matrix, Telegram, Discord, WhatsApp,
or Slack artifacts
- QA parity, runtime parity, and selected Matrix, Buzz, Telegram, Discord,
WhatsApp, or Slack artifacts
## Workflow files
+6
View File
@@ -6,6 +6,12 @@
"onStartup": false
},
"channels": ["buzz"],
"qaRunners": [
{
"commandName": "buzz",
"description": "Run the Buzz live QA lane against a dedicated relay room"
}
],
"channelConfigs": {
"buzz": {
"label": "Buzz",
+4
View File
@@ -1,2 +1,6 @@
import { buzzQaCliRegistration } from "./src/qa/cli.js";
export type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
export type { ChannelPlugin, PluginRuntime } from "openclaw/plugin-sdk/core";
export const qaRunnerCliRegistrations = [buzzQaCliRegistration];
@@ -0,0 +1,343 @@
import type {
QaBusInboundMessageInput,
QaBusMessage,
} from "openclaw/plugin-sdk/qa-channel-protocol";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parseBuzzQaCredentialPayload } from "./credentials.js";
const credentials = parseBuzzQaCredentialPayload({
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: "01".repeat(32),
sutPrivateKey: "02".repeat(32),
});
const release = vi.hoisted(() => vi.fn(async () => {}));
const heartbeatStop = vi.hoisted(() => vi.fn(async () => {}));
const acquireQaCredentialLease = vi.hoisted(() =>
vi.fn(async (_options: unknown) => ({
source: "env" as const,
kind: "buzz",
payload: credentials,
heartbeatIntervalMs: 0,
leaseTtlMs: 0,
heartbeat: vi.fn(async () => {}),
release,
})),
);
const startQaCredentialLeaseHeartbeat = vi.hoisted(() =>
vi.fn((_lease: unknown) => ({
getFailure: () => null,
stop: heartbeatStop,
throwIfFailed: vi.fn(),
})),
);
const readBuzzQaCredentialFile = vi.hoisted(() => vi.fn());
const sendMessage = vi.hoisted(() =>
vi.fn(async () => ({ eventId: "native-inbound", timestamp: 1_750_000_000_000 })),
);
const closeRelay = vi.hoisted(() => vi.fn(async () => {}));
const relayDriverState = vi.hoisted(() => ({
onMessage: undefined as
| ((message: {
id: string;
senderPubkey: string;
text: string;
channelId: string;
createdAt: number;
threadId?: string;
replyToId?: string;
mentionedPubkeys: string[];
}) => Promise<void>)
| undefined,
}));
const createBuzzQaRelayDriver = vi.hoisted(() =>
vi.fn(async (params: { onMessage: NonNullable<typeof relayDriverState.onMessage> }) => {
relayDriverState.onMessage = params.onMessage;
return {
assertHealthy: vi.fn(),
close: closeRelay,
sendMessage,
};
}),
);
vi.mock("./credentials.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./credentials.js")>()),
readBuzzQaCredentialFile,
}));
vi.mock("./relay-client.js", () => ({ createBuzzQaRelayDriver }));
import { createBuzzQaTransportAdapter } from "./adapter.runtime.js";
const credentialHost: Parameters<typeof createBuzzQaTransportAdapter>[0]["credentials"] = {
acquire: async (options) => (await acquireQaCredentialLease(options)) as never,
startHeartbeat: startQaCredentialLeaseHeartbeat,
};
describe("Buzz QA transport adapter", () => {
beforeEach(() => {
vi.clearAllMocks();
relayDriverState.onMessage = undefined;
sendMessage.mockResolvedValue({
eventId: "native-inbound",
timestamp: 1_750_000_000_000,
});
readBuzzQaCredentialFile.mockResolvedValue(credentials);
});
it("uses private file credentials by default", async () => {
await createBuzzQaTransportAdapter({
adapterOptions: {
credentialFile: "private/buzz-qa.json",
repoRoot: "/repo",
},
channelId: "buzz",
credentials: credentialHost,
driver: "live",
messages: {
addInboundMessage: vi.fn(),
addOutboundMessage: vi.fn(),
editMessage: vi.fn(),
},
outputDir: ".artifacts/qa-e2e/buzz",
});
expect(readBuzzQaCredentialFile).toHaveBeenCalledWith({
filePath: "private/buzz-qa.json",
repoRoot: "/repo",
});
expect(acquireQaCredentialLease).toHaveBeenCalledWith(
expect.objectContaining({ kind: "buzz", source: "env" }),
);
});
it("sends a portable mentioned message through the native Buzz relay driver", async () => {
const addInboundMessage = vi.fn(async (input) => ({
...input,
id: "bus-inbound",
direction: "inbound" as const,
timestamp: input.timestamp ?? 1_750_000_000_000,
}));
const adapter = await createBuzzQaTransportAdapter({
adapterOptions: { credentialSource: "convex", sutAccountId: "sut" },
channelId: "buzz",
credentials: credentialHost,
driver: "live",
messages: {
addInboundMessage,
addOutboundMessage: vi.fn(),
editMessage: vi.fn(),
},
outputDir: ".artifacts/qa-e2e/buzz",
});
const message = await adapter.sendInbound({
accountId: "sut",
conversation: { id: "qa-routing-primary", kind: "group" },
senderId: "driver",
senderName: "QA Driver",
text: "@openclaw reply exactly: QA-CHANNEL-CANARY-OK",
});
expect(sendMessage).toHaveBeenCalledWith({
mentionSut: true,
text: "@openclaw reply exactly: QA-CHANNEL-CANARY-OK",
});
expect(addInboundMessage).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "sut",
senderId: credentials.driverPublicKey,
timestamp: 1_750_000_000_000,
}),
);
expect(message.id).toBe("bus-inbound");
const gateway = {
call: vi.fn(async () => ({
channelAccounts: { buzz: [{ accountId: "default", running: true }] },
})),
};
await adapter.waitReady({ gateway });
expect(gateway.call).toHaveBeenCalledWith(
"channels.status",
{ probe: false, timeoutMs: 2_000 },
{ timeoutMs: 5_000 },
);
});
it("maps native Buzz reply relations back to portable thread ids", async () => {
let inboundIndex = 0;
let outboundIndex = 0;
const addInboundMessage = vi.fn(async (input) => ({
...input,
id: `bus-inbound-${++inboundIndex}`,
direction: "inbound" as const,
timestamp: input.timestamp ?? 1_750_000_000_000,
}));
const addOutboundMessage = vi.fn(async (input) => ({
...input,
id: `bus-outbound-${++outboundIndex}`,
direction: "outbound" as const,
conversation: { id: "main", kind: "group" as const },
}));
sendMessage
.mockResolvedValueOnce({ eventId: "native-root", timestamp: 1_750_000_000_000 })
.mockResolvedValueOnce({ eventId: "native-follow-up", timestamp: 1_750_000_001_000 });
const adapter = await createBuzzQaTransportAdapter({
adapterOptions: { credentialSource: "convex", sutAccountId: "sut" },
channelId: "buzz",
credentials: credentialHost,
driver: "live",
messages: {
addInboundMessage,
addOutboundMessage,
editMessage: vi.fn(),
},
outputDir: ".artifacts/qa-e2e/buzz",
});
const root = await adapter.sendInbound({
accountId: "sut",
conversation: { id: "main", kind: "group" },
senderId: "driver",
senderName: "QA Driver",
text: "@openclaw root",
});
await relayDriverState.onMessage?.({
id: "native-sut-root",
senderPubkey: credentials.sutPublicKey,
text: "root reply",
channelId: credentials.roomId,
createdAt: 1_750_000_000,
threadId: "native-root",
replyToId: "native-root",
mentionedPubkeys: [],
});
const followUp = await adapter.sendInbound({
accountId: "sut",
conversation: { id: "main", kind: "group" },
senderId: "driver",
senderName: "QA Driver",
text: "@openclaw follow-up",
threadId: root.id,
});
await relayDriverState.onMessage?.({
id: "native-sut-follow-up",
senderPubkey: credentials.sutPublicKey,
text: "thread reply",
channelId: credentials.roomId,
createdAt: 1_750_000_001,
threadId: "native-root",
replyToId: "native-follow-up",
mentionedPubkeys: [],
});
expect(sendMessage).toHaveBeenLastCalledWith({
mentionSut: true,
text: "@openclaw follow-up",
threadId: "native-root",
});
expect(addOutboundMessage).toHaveBeenLastCalledWith(
expect.objectContaining({
threadId: root.id,
replyToId: followUp.id,
}),
);
});
it("waits for a racing inbound id mapping before recording reply relations", async () => {
let releaseInbound = () => {};
const inboundGate = new Promise<void>((resolve) => {
releaseInbound = resolve;
});
const addInboundMessage = vi.fn(async (input: QaBusInboundMessageInput) => {
await inboundGate;
return {
...input,
id: "bus-inbound",
accountId: input.accountId ?? "sut",
direction: "inbound",
timestamp: input.timestamp ?? 1_750_000_000_000,
reactions: [],
} satisfies QaBusMessage;
});
const addOutboundMessage = vi.fn(async (input) => ({
...input,
id: "bus-outbound",
direction: "outbound" as const,
conversation: { id: "main", kind: "group" as const },
}));
const adapter = await createBuzzQaTransportAdapter({
adapterOptions: { credentialSource: "convex", sutAccountId: "sut" },
channelId: "buzz",
credentials: credentialHost,
driver: "live",
messages: {
addInboundMessage,
addOutboundMessage,
editMessage: vi.fn(),
},
outputDir: ".artifacts/qa-e2e/buzz",
});
const inboundPromise = adapter.sendInbound({
accountId: "sut",
conversation: { id: "main", kind: "group" },
senderId: "driver",
senderName: "QA Driver",
text: "@openclaw root",
});
await vi.waitFor(() => expect(addInboundMessage).toHaveBeenCalledOnce());
const outboundPromise = relayDriverState.onMessage?.({
id: "native-sut-reply",
senderPubkey: credentials.sutPublicKey,
text: "fast reply",
channelId: credentials.roomId,
createdAt: 1_750_000_000,
threadId: "native-inbound",
replyToId: "native-inbound",
mentionedPubkeys: [],
});
expect(addOutboundMessage).not.toHaveBeenCalled();
releaseInbound();
await inboundPromise;
await outboundPromise;
expect(addOutboundMessage).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "bus-inbound",
replyToId: "bus-inbound",
}),
);
});
it("closes relay observation before stopping and releasing the credential lease", async () => {
const adapter = await createBuzzQaTransportAdapter({
adapterOptions: { credentialSource: "convex" },
channelId: "buzz",
credentials: credentialHost,
driver: "live",
messages: {
addInboundMessage: vi.fn(),
addOutboundMessage: vi.fn(),
editMessage: vi.fn(),
},
outputDir: ".artifacts/qa-e2e/buzz",
});
await adapter.cleanup?.();
await adapter.cleanupAfterGatewayStop?.();
expect(closeRelay).toHaveBeenCalledOnce();
expect(heartbeatStop).toHaveBeenCalledOnce();
expect(release).toHaveBeenCalledOnce();
expect(closeRelay.mock.invocationCallOrder[0]).toBeLessThan(
heartbeatStop.mock.invocationCallOrder[0] ?? 0,
);
expect(heartbeatStop.mock.invocationCallOrder[0]).toBeLessThan(
release.mock.invocationCallOrder[0] ?? 0,
);
});
});
+230
View File
@@ -0,0 +1,230 @@
import { setTimeout as sleep } from "node:timers/promises";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime";
import type { BuzzInboundMessage } from "../message-event.js";
import { buildBuzzTarget } from "../target.js";
import { parseBuzzQaCredentialPayload, readBuzzQaCredentialFile } from "./credentials.js";
import { createBuzzQaRelayDriver } from "./relay-client.js";
type AdapterFactory = NonNullable<QaRunnerCliRegistration["adapterFactory"]>;
type FactoryContext = Parameters<AdapterFactory["create"]>[0];
type AdapterDefinition = Awaited<ReturnType<AdapterFactory["create"]>> & {
cleanupAfterGatewayStop?: () => Promise<void>;
};
const BUZZ_GATEWAY_ACCOUNT_ID = "default";
const BUZZ_MESSAGE_ID_MAPPING_TIMEOUT_MS = 5_000;
function isBuzzMention(text: string) {
return /(^|\s)@openclaw\b/iu.test(text);
}
async function waitForBuzzChannelRunning(params: {
accountId: string;
gateway: Parameters<AdapterDefinition["waitReady"]>[0]["gateway"];
pollIntervalMs?: number;
timeoutMs?: number;
}) {
const timeoutMs = params.timeoutMs ?? 60_000;
const pollIntervalMs = params.pollIntervalMs ?? 500;
const startedAt = Date.now();
let lastStatus: unknown;
while (Date.now() - startedAt < timeoutMs) {
const payload = (await params.gateway.call(
"channels.status",
{ probe: false, timeoutMs: 2_000 },
{ timeoutMs: 5_000 },
)) as { channelAccounts?: Record<string, Array<Record<string, unknown>>> };
const status = payload.channelAccounts?.buzz?.find(
(entry) => entry.accountId === params.accountId,
);
lastStatus = status;
if (status?.running === true && status.restartPending !== true) {
return;
}
await sleep(pollIntervalMs);
}
throw new Error(
`buzz account "${params.accountId}" did not become ready; last status: ${JSON.stringify(lastStatus)}`,
);
}
export async function createBuzzQaTransportAdapter(
context: FactoryContext,
): Promise<AdapterDefinition> {
const options = context.adapterOptions ?? {};
const requestedCredentialSource = options.credentialSource?.trim().toLowerCase() || "file";
if (requestedCredentialSource !== "file" && requestedCredentialSource !== "convex") {
throw new Error('Buzz QA credential source must be "file" or "convex".');
}
const credentialFile = options.credentialFile?.trim();
if (requestedCredentialSource === "file" && !credentialFile) {
throw new Error("Buzz QA file credentials require --credential-file <path>.");
}
if (requestedCredentialSource === "file" && options.credentialRole?.trim()) {
throw new Error("Buzz QA --credential-role is only valid with --credential-source convex.");
}
if (requestedCredentialSource === "convex" && credentialFile) {
throw new Error(
"Buzz QA --credential-file cannot be combined with --credential-source convex.",
);
}
const fileCredentials =
requestedCredentialSource === "file" && credentialFile
? await readBuzzQaCredentialFile({ filePath: credentialFile, repoRoot: options.repoRoot })
: undefined;
const lease = await context.credentials.acquire({
kind: "buzz",
source: requestedCredentialSource === "convex" ? "convex" : "env",
role: options.credentialRole,
resolveEnvPayload: () => {
if (!fileCredentials) {
throw new Error("Buzz QA file credentials are unavailable.");
}
return fileCredentials;
},
parsePayload: parseBuzzQaCredentialPayload,
});
const heartbeat = context.credentials.startHeartbeat(lease);
const credentials = lease.payload;
const accountId = options.sutAccountId?.trim() || "sut";
const nativeMessageIds = new Map<string, string>();
const busMessageIds = new Map<string, string>();
let logicalConversationId = credentials.roomId;
let relayDriver: Awaited<ReturnType<typeof createBuzzQaRelayDriver>>;
const resolveBusMessageId = async (nativeId: string | undefined) => {
if (!nativeId) {
return undefined;
}
const startedAt = Date.now();
while (Date.now() - startedAt < BUZZ_MESSAGE_ID_MAPPING_TIMEOUT_MS) {
const busId = busMessageIds.get(nativeId);
if (busId) {
return busId;
}
// A fast relay response can arrive before sendInbound records the
// published event's portable id. Preserve native reply relations until
// that canonical mapping is available instead of dropping them.
await sleep(10);
}
throw new Error(`Buzz QA could not resolve the portable id for native message ${nativeId}.`);
};
const recordOutbound = async (message: BuzzInboundMessage) => {
const [threadId, replyToId] = await Promise.all([
resolveBusMessageId(message.threadId),
resolveBusMessageId(message.replyToId),
]);
const outbound = await context.messages.addOutboundMessage({
accountId,
to: `group:${logicalConversationId}`,
senderId: credentials.sutPublicKey,
text: message.text,
timestamp: message.createdAt * 1_000,
threadId,
replyToId,
});
nativeMessageIds.set(outbound.id, message.id);
busMessageIds.set(message.id, outbound.id);
};
try {
relayDriver = await createBuzzQaRelayDriver({ credentials, onMessage: recordOutbound });
} catch (error) {
try {
await heartbeat.stop();
} finally {
await lease.release();
}
throw error;
}
return {
id: "buzz",
label: "Buzz live",
accountId,
requiredPluginIds: ["buzz"],
supportedActions: [],
assertTransportHealthy() {
heartbeat.throwIfFailed();
relayDriver.assertHealthy();
},
async sendInbound(input) {
heartbeat.throwIfFailed();
relayDriver.assertHealthy();
logicalConversationId = input.conversation.id;
const sent = await relayDriver.sendMessage({
text: input.text,
mentionSut: isBuzzMention(input.text),
...(input.threadId ? { threadId: nativeMessageIds.get(input.threadId) } : {}),
...(input.replyToId ? { replyToId: nativeMessageIds.get(input.replyToId) } : {}),
});
const inbound = await context.messages.addInboundMessage({
...input,
accountId,
senderId: credentials.driverPublicKey,
timestamp: sent.timestamp,
});
nativeMessageIds.set(inbound.id, sent.eventId);
busMessageIds.set(sent.eventId, inbound.id);
return inbound;
},
resetTransport() {
logicalConversationId = credentials.roomId;
nativeMessageIds.clear();
busMessageIds.clear();
},
createGatewayConfig: () =>
({
channels: {
buzz: {
enabled: true,
relayUrl: credentials.relayUrl,
privateKey: credentials.sutPrivateKey,
...(credentials.sutAuthTag ? { authTag: credentials.sutAuthTag } : {}),
groupPolicy: "allowlist",
groupAllowFrom: [credentials.driverPublicKey],
groups: {
[credentials.roomId]: {
enabled: true,
requireMention: options.transportPolicy?.requireGroupMention ?? true,
},
},
defaultTo: buildBuzzTarget(credentials.roomId),
},
},
}) as Pick<OpenClawConfig, "channels" | "messages">,
waitReady: async ({ gateway, timeoutMs, pollIntervalMs }) =>
await waitForBuzzChannelRunning({
// Buzz is currently single-account; the QA bus keeps its portable SUT
// label while Gateway status reports the plugin's canonical account id.
accountId: BUZZ_GATEWAY_ACCOUNT_ID,
gateway,
timeoutMs,
pollIntervalMs,
}),
buildAgentDelivery: () => ({
channel: "buzz",
to: buildBuzzTarget(credentials.roomId),
replyChannel: "buzz",
replyTo: buildBuzzTarget(credentials.roomId),
}),
async handleAction() {
throw new Error("Buzz live QA adapter does not implement transport actions");
},
createReportNotes: () => [
"Runs through a real authenticated Buzz relay room; credential values are omitted.",
],
async cleanup() {
await relayDriver.close();
},
async cleanupAfterGatewayStop() {
try {
await heartbeat.stop();
} finally {
await lease.release();
}
},
};
}
+51
View File
@@ -0,0 +1,51 @@
import { Command } from "commander";
import type { LiveTransportQaSuiteCommandOptions } from "openclaw/plugin-sdk/qa-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runLiveTransportQaSuiteCommand = vi.hoisted(() =>
vi.fn<(params: LiveTransportQaSuiteCommandOptions) => Promise<void>>(async () => {}),
);
vi.mock("openclaw/plugin-sdk/qa-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/qa-runtime")>()),
runLiveTransportQaSuiteCommand,
}));
import { buzzQaCliRegistration } from "./cli.js";
describe("Buzz QA CLI", () => {
beforeEach(() => {
runLiveTransportQaSuiteCommand.mockClear();
});
it("runs the portable canary and mention-gating scenarios", async () => {
const qa = new Command();
buzzQaCliRegistration.register(qa);
await qa.parseAsync([
"node",
"openclaw",
"buzz",
"--provider-mode",
"mock-openai",
"--credential-file",
"/secure/buzz-qa.json",
]);
const params = runLiveTransportQaSuiteCommand.mock.calls[0]?.[0];
expect(params).toMatchObject({
channelId: "buzz",
defaultProviderMode: "mock-openai",
options: {
providerMode: "mock-openai",
credentialFile: "/secure/buzz-qa.json",
},
});
expect(
params?.selectScenarioIds({
primaryModel: "openai/gpt-5.4",
providerMode: "mock-openai",
}),
).toEqual(["channel-canary", "channel-mention-gating"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
import {
createLazyCliRuntimeLoader,
createLiveTransportQaCliRegistration,
runLiveTransportQaSuiteCommand,
type LiveTransportQaCliRegistration,
type LiveTransportQaCommandOptions,
} from "openclaw/plugin-sdk/qa-runtime";
const DEFAULT_BUZZ_QA_SCENARIOS = ["channel-canary", "channel-mention-gating"] as const;
const loadBuzzQaAdapterRuntime = createLazyCliRuntimeLoader<typeof import("./adapter.runtime.js")>(
() => import("./adapter.runtime.js"),
);
async function runQaBuzz(options: LiveTransportQaCommandOptions) {
await runLiveTransportQaSuiteCommand({
channelId: "buzz",
defaultProviderMode: "mock-openai",
options,
selectScenarioIds: ({ scenarioIds }) =>
scenarioIds?.length ? [...scenarioIds] : [...DEFAULT_BUZZ_QA_SCENARIOS],
});
}
export const buzzQaCliRegistration: LiveTransportQaCliRegistration =
createLiveTransportQaCliRegistration({
commandName: "buzz",
credentialFileHelp: "JSON credential file for local Buzz QA",
adapterFactory: {
id: "buzz",
matches: ({ channelId, driver }) => channelId === "buzz" && driver === "live",
async create(context) {
return await (await loadBuzzQaAdapterRuntime()).createBuzzQaTransportAdapter(context);
},
},
credentialOptions: {
sourceDescription: "Credential source for Buzz QA: file or convex (default: file)",
roleDescription:
"Credential role for convex auth: maintainer or ci (default: ci in CI, maintainer otherwise)",
},
defaultProviderMode: "mock-openai",
description: "Run the Buzz live QA lane against a dedicated relay room",
providerModeHelp: "Provider mode: mock-openai, aimock, or live-frontier",
outputDirHelp: "Buzz QA artifact directory",
allowFailuresHelp: "Write artifacts without setting a failing exit code when scenarios fail",
scenarioHelp: "Run only the named Buzz QA scenario (repeatable)",
sutAccountHelp: "Normalized Buzz SUT account id in QA artifacts",
run: runQaBuzz,
});
+131
View File
@@ -0,0 +1,131 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseBuzzQaCredentialPayload, readBuzzQaCredentialFile } from "./credentials.js";
const DRIVER_PRIVATE_KEY = "01".repeat(32);
const SUT_PRIVATE_KEY = "02".repeat(32);
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dir) => await fs.rm(dir, { force: true, recursive: true })),
);
});
describe("Buzz QA credentials", () => {
it("accepts a strict two-identity room payload", () => {
const credentials = parseBuzzQaCredentialPayload({
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: DRIVER_PRIVATE_KEY,
sutPrivateKey: SUT_PRIVATE_KEY,
driverAuthTag: '["auth","driver","conditions","signature"]',
sutAuthTag: '["auth","sut","conditions","signature"]',
});
expect(credentials).toMatchObject({
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: DRIVER_PRIVATE_KEY,
sutPrivateKey: SUT_PRIVATE_KEY,
});
expect(credentials.driverPublicKey).toMatch(/^[a-f0-9]{64}$/u);
expect(credentials.sutPublicKey).toMatch(/^[a-f0-9]{64}$/u);
expect(credentials.driverPublicKey).not.toBe(credentials.sutPublicKey);
});
it.each(["ws://localhost:8080", "ws://127.0.0.1:8080", "ws://[::1]:8080"])(
"allows plaintext loopback relay URL %s",
(relayUrl) => {
expect(
parseBuzzQaCredentialPayload({
relayUrl,
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: DRIVER_PRIVATE_KEY,
sutPrivateKey: SUT_PRIVATE_KEY,
}).relayUrl,
).toBe(relayUrl);
},
);
it("rejects plaintext remote relay URLs", () => {
expect(() =>
parseBuzzQaCredentialPayload({
relayUrl: "ws://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: DRIVER_PRIVATE_KEY,
sutPrivateKey: SUT_PRIVATE_KEY,
}),
).toThrow("Buzz QA credentials are missing or malformed.");
});
it("never includes credential values in validation errors", () => {
const privateKey = "not-a-private-key-value";
const authTag = "not-an-auth-tag-value";
expect(() =>
parseBuzzQaCredentialPayload({
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: privateKey,
sutPrivateKey: SUT_PRIVATE_KEY,
driverAuthTag: authTag,
}),
).toThrow("Buzz QA credentials are missing or malformed.");
try {
parseBuzzQaCredentialPayload({
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: privateKey,
sutPrivateKey: SUT_PRIVATE_KEY,
driverAuthTag: authTag,
});
} catch (error) {
expect(String(error)).not.toContain(privateKey);
expect(String(error)).not.toContain(authTag);
}
});
it("reads a repo-relative private JSON credential file", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-buzz-qa-"));
tempDirs.push(repoRoot);
const filePath = path.join("private", "buzz.json");
await fs.mkdir(path.join(repoRoot, "private"));
await fs.writeFile(
path.join(repoRoot, filePath),
JSON.stringify({
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: DRIVER_PRIVATE_KEY,
sutPrivateKey: SUT_PRIVATE_KEY,
}),
{ mode: 0o600 },
);
await expect(readBuzzQaCredentialFile({ filePath, repoRoot })).resolves.toMatchObject({
relayUrl: "wss://relay.qa.example",
driverPrivateKey: DRIVER_PRIVATE_KEY,
sutPrivateKey: SUT_PRIVATE_KEY,
});
});
it("does not echo malformed file contents", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-buzz-qa-"));
tempDirs.push(repoRoot);
const filePath = "buzz.json";
const secret = "private-key-material-must-not-leak";
await fs.writeFile(path.join(repoRoot, filePath), `{${secret}`, { mode: 0o600 });
await expect(readBuzzQaCredentialFile({ filePath, repoRoot })).rejects.not.toThrow(secret);
await expect(readBuzzQaCredentialFile({ filePath, repoRoot })).rejects.toThrow(
"is not valid JSON",
);
try {
await readBuzzQaCredentialFile({ filePath, repoRoot });
} catch (error) {
expect((error as Error & { cause?: unknown }).cause).toBeUndefined();
}
});
});
+87
View File
@@ -0,0 +1,87 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import { z } from "zod";
import { parseBuzzAuthTag } from "../relay-auth.js";
import { parseBuzzTarget } from "../target.js";
import { resolveBuzzPublicKey } from "../types.js";
function isSafeBuzzQaRelayUrl(value: string): boolean {
try {
const relayUrl = new URL(value);
return (
relayUrl.protocol === "wss:" ||
(relayUrl.protocol === "ws:" && isLoopbackHost(relayUrl.hostname))
);
} catch {
return false;
}
}
const buzzQaCredentialPayloadSchema = z
.object({
// QA credentials may contain relay auth tags, so plaintext WebSockets must
// stay on loopback and never cross a network boundary.
relayUrl: z.string().url().refine(isSafeBuzzQaRelayUrl),
roomId: z.string().min(1),
driverPrivateKey: z.string().min(1),
sutPrivateKey: z.string().min(1),
driverAuthTag: z.string().optional(),
sutAuthTag: z.string().optional(),
})
.strict();
export type BuzzQaCredentials = z.output<typeof buzzQaCredentialPayloadSchema> & {
driverPublicKey: string;
sutPublicKey: string;
};
export function parseBuzzQaCredentialPayload(payload: unknown): BuzzQaCredentials {
const parsed = buzzQaCredentialPayloadSchema.safeParse(payload);
if (!parsed.success) {
throw new Error("Buzz QA credentials are missing or malformed.");
}
let roomId: string;
let driverPublicKey: string;
let sutPublicKey: string;
try {
roomId = parseBuzzTarget(parsed.data.roomId);
driverPublicKey = resolveBuzzPublicKey(parsed.data.driverPrivateKey);
sutPublicKey = resolveBuzzPublicKey(parsed.data.sutPrivateKey);
parseBuzzAuthTag(parsed.data.driverAuthTag ?? "");
parseBuzzAuthTag(parsed.data.sutAuthTag ?? "");
} catch {
throw new Error("Buzz QA credentials are missing or malformed.");
}
if (driverPublicKey === sutPublicKey) {
throw new Error("Buzz QA requires distinct driver and SUT identities.");
}
return {
...parsed.data,
roomId,
driverPublicKey,
sutPublicKey,
};
}
export async function readBuzzQaCredentialFile(params: {
filePath: string;
repoRoot?: string;
}): Promise<BuzzQaCredentials> {
const resolvedPath = path.resolve(params.repoRoot ?? process.cwd(), params.filePath);
let raw: string;
try {
raw = await fs.readFile(resolvedPath, "utf8");
} catch (error) {
throw new Error(`Unable to read Buzz QA credential file ${resolvedPath}.`, { cause: error });
}
let payload: unknown;
try {
payload = JSON.parse(raw);
} catch {
// JSON.parse errors may include source snippets, so never retain the
// secret-bearing parser error as a cause.
throw new Error(`Buzz QA credential file ${resolvedPath} is not valid JSON.`);
}
return parseBuzzQaCredentialPayload(payload);
}
+134
View File
@@ -0,0 +1,134 @@
import { getPublicKey, type Event, type Filter } from "nostr-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parseBuzzQaCredentialPayload } from "./credentials.js";
const relayMocks = vi.hoisted(() => ({
auth: vi.fn(async () => "ok"),
close: vi.fn(),
connect: vi.fn(async () => {}),
publish: vi.fn(async () => "ok"),
replayedMessage: undefined as Event | undefined,
subscriptions: [] as Array<{
filter: Filter;
handlers: {
onevent?: (event: Event) => void;
oneose?: () => void;
onclose?: (reason: string) => void;
};
}>,
}));
vi.mock("nostr-tools", async (importOriginal) => {
const actual = await importOriginal<typeof import("nostr-tools")>();
return {
...actual,
Relay: class {
onauth?: (template: unknown) => Promise<unknown>;
auth = relayMocks.auth;
close = relayMocks.close;
connect = relayMocks.connect;
publish = relayMocks.publish;
subscribe(
filters: Filter[],
handlers: (typeof relayMocks.subscriptions)[number]["handlers"],
) {
const filter = filters[0] ?? {};
relayMocks.subscriptions.push({ filter, handlers });
if (filter.kinds?.includes(39002)) {
handlers.onevent?.({
id: "membership",
kind: 39002,
pubkey: "f".repeat(64),
created_at: 1_750_000_000,
content: "",
sig: "e".repeat(128),
tags: [
["d", "123e4567-e89b-42d3-a456-426614174000"],
[
"p",
getPublicKey(Uint8Array.from(Buffer.from("01".repeat(32), "hex"))),
"",
"member",
],
["p", getPublicKey(Uint8Array.from(Buffer.from("02".repeat(32), "hex"))), "", "bot"],
],
});
handlers.oneose?.();
} else {
if (relayMocks.replayedMessage) {
handlers.onevent?.(relayMocks.replayedMessage);
}
handlers.oneose?.();
}
return { close: vi.fn() };
}
},
};
});
import { createBuzzQaRelayDriver } from "./relay-client.js";
const credentials = parseBuzzQaCredentialPayload({
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: "01".repeat(32),
sutPrivateKey: "02".repeat(32),
});
describe("Buzz QA relay driver", () => {
beforeEach(() => {
vi.clearAllMocks();
relayMocks.subscriptions.length = 0;
relayMocks.replayedMessage = undefined;
});
it("authenticates, verifies membership, and publishes a native mentioned thread event", async () => {
const driver = await createBuzzQaRelayDriver({
credentials,
onMessage: vi.fn(async () => {}),
});
const sent = await driver.sendMessage({
text: "@openclaw hello",
mentionSut: true,
threadId: "root-event",
replyToId: "parent-event",
});
expect(sent.eventId).toMatch(/^[a-f0-9]{64}$/u);
expect(relayMocks.publish).toHaveBeenCalledWith(
expect.objectContaining({
id: sent.eventId,
kind: 9,
content: "@openclaw hello",
tags: [
["h", credentials.roomId],
["e", "root-event", "", "root"],
["e", "parent-event", "", "reply"],
["p", credentials.sutPublicKey],
],
}),
);
await driver.close();
expect(relayMocks.close).toHaveBeenCalledOnce();
});
it("ignores retained SUT messages before the observer reaches live events", async () => {
const onMessage = vi.fn(async () => {});
relayMocks.replayedMessage = {
id: "retained-sut-message",
kind: 9,
pubkey: credentials.sutPublicKey,
created_at: 1_750_000_000,
content: "old response",
sig: "e".repeat(128),
tags: [["h", credentials.roomId]],
};
const driver = await createBuzzQaRelayDriver({ credentials, onMessage });
expect(onMessage).not.toHaveBeenCalled();
await driver.close();
});
});
+245
View File
@@ -0,0 +1,245 @@
import { Relay, finalizeEvent, type Event } from "nostr-tools";
import {
buildBuzzMessageTags,
parseBuzzMessageEvent,
type BuzzInboundMessage,
} from "../message-event.js";
import { authenticateBuzzRelay, createBuzzAuthSigner, parseBuzzAuthTag } from "../relay-auth.js";
import {
BUZZ_ROOM_MEMBERSHIP_KIND,
isNewerBuzzRoomMembership,
parseBuzzRoomMembershipEvent,
type BuzzRoomMembership,
} from "../room-membership.js";
import { decodeBuzzPrivateKey } from "../types.js";
import type { BuzzQaCredentials } from "./credentials.js";
const BUZZ_MESSAGE_KIND = 9;
const MEMBERSHIP_TIMEOUT_MS = 10_000;
const OBSERVER_READY_TIMEOUT_MS = 10_000;
type BuzzQaRelayDriver = {
assertHealthy(): void;
close(): Promise<void>;
sendMessage(input: {
text: string;
mentionSut: boolean;
threadId?: string;
replyToId?: string;
}): Promise<{ eventId: string; timestamp: number }>;
};
async function loadBuzzQaRoomMembership(params: {
relay: Relay;
roomId: string;
}): Promise<BuzzRoomMembership> {
return await new Promise<BuzzRoomMembership>((resolve, reject) => {
let latest: BuzzRoomMembership | undefined;
let settled = false;
const subscriptionRef: { current?: ReturnType<Relay["subscribe"]> } = {};
const finish = (error?: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
subscriptionRef.current?.close("membership loaded");
if (error) {
reject(error);
} else if (latest) {
resolve(latest);
} else {
reject(new Error(`Buzz QA room ${params.roomId} has no membership roster.`));
}
};
const timeout = setTimeout(
() => finish(new Error(`Timed out loading Buzz QA room ${params.roomId} membership.`)),
MEMBERSHIP_TIMEOUT_MS,
);
subscriptionRef.current = params.relay.subscribe(
[{ kinds: [BUZZ_ROOM_MEMBERSHIP_KIND], "#d": [params.roomId], limit: 1 }],
{
onevent: (event) => {
const membership = parseBuzzRoomMembershipEvent(event);
if (
membership?.roomId === params.roomId &&
isNewerBuzzRoomMembership(membership, latest)
) {
latest = membership;
}
},
oneose: () => finish(),
onclose: (reason) => {
if (reason !== "membership loaded") {
finish(new Error(`Buzz QA membership subscription closed: ${reason}`));
}
},
},
);
if (settled) {
subscriptionRef.current.close("membership loaded");
}
});
}
function assertBuzzQaMembership(membership: BuzzRoomMembership, credentials: BuzzQaCredentials) {
if (!membership.members.has(credentials.driverPublicKey)) {
throw new Error(
`Buzz QA driver ${credentials.driverPublicKey} is not a member of room ${credentials.roomId}.`,
);
}
if (
!membership.members.has(credentials.sutPublicKey) ||
membership.roles.get(credentials.sutPublicKey) !== "bot"
) {
throw new Error(
`Buzz QA SUT ${credentials.sutPublicKey} must have the Bot role in room ${credentials.roomId}.`,
);
}
}
export async function createBuzzQaRelayDriver(params: {
credentials: BuzzQaCredentials;
onMessage: (message: BuzzInboundMessage) => Promise<void>;
}): Promise<BuzzQaRelayDriver> {
const credentials = params.credentials;
const secretKey = decodeBuzzPrivateKey(credentials.driverPrivateKey);
const relay = new Relay(credentials.relayUrl, { enableReconnect: false });
const lifecycleAbort = new AbortController();
const signAuth = createBuzzAuthSigner({
secretKey,
authTag: parseBuzzAuthTag(credentials.driverAuthTag ?? ""),
});
let transportError: Error | undefined;
let messageQueue = Promise.resolve();
const observedEventIds = new Set<string>();
try {
await relay.connect({ abort: lifecycleAbort.signal });
await authenticateBuzzRelay({ relay, signAuth, signal: lifecycleAbort.signal });
relay.onauth = signAuth;
assertBuzzQaMembership(
await loadBuzzQaRoomMembership({ relay, roomId: credentials.roomId }),
credentials,
);
} catch (error) {
lifecycleAbort.abort(error);
relay.close();
throw error;
}
let observerReady = false;
let resolveObserverReady: (() => void) | undefined;
let rejectObserverReady: ((error: Error) => void) | undefined;
const observerReadyPromise = new Promise<void>((resolve, reject) => {
resolveObserverReady = resolve;
rejectObserverReady = reject;
});
const observerReadyTimeout = setTimeout(() => {
rejectObserverReady?.(new Error("Timed out waiting for the Buzz QA message observer."));
}, OBSERVER_READY_TIMEOUT_MS);
let subscription: ReturnType<Relay["subscribe"]>;
try {
subscription = relay.subscribe(
[
{
kinds: [BUZZ_MESSAGE_KIND],
authors: [credentials.sutPublicKey],
"#h": [credentials.roomId],
since: Math.floor(Date.now() / 1_000) - 5,
},
],
{
onevent: (event: Event) => {
if (!observerReady) {
return;
}
if (observedEventIds.has(event.id)) {
return;
}
observedEventIds.add(event.id);
const message = parseBuzzMessageEvent(event);
if (
!message ||
message.channelId !== credentials.roomId ||
message.senderPubkey !== credentials.sutPublicKey
) {
return;
}
messageQueue = messageQueue
.then(async () => await params.onMessage(message))
.catch((error: unknown) => {
transportError = error instanceof Error ? error : new Error(String(error));
});
},
oneose: () => {
observerReady = true;
clearTimeout(observerReadyTimeout);
resolveObserverReady?.();
},
onclose: (reason) => {
if (!observerReady) {
clearTimeout(observerReadyTimeout);
rejectObserverReady?.(
new Error(`Buzz QA message observer closed before it was ready: ${reason}`),
);
return;
}
if (reason !== "shutdown" && reason !== "relay connection closed by us") {
transportError = new Error(`Buzz QA message subscription closed: ${reason}`);
}
},
},
);
} catch (error) {
clearTimeout(observerReadyTimeout);
lifecycleAbort.abort(error);
relay.close();
throw error;
}
try {
await observerReadyPromise;
} catch (error) {
lifecycleAbort.abort(error);
subscription.close("shutdown");
relay.close();
throw error;
}
return {
assertHealthy() {
if (transportError) {
throw transportError;
}
},
async sendMessage(input) {
if (transportError) {
throw transportError;
}
const tags = buildBuzzMessageTags({
channelId: credentials.roomId,
threadId: input.threadId,
replyToId: input.replyToId,
});
if (input.mentionSut) {
tags.push(["p", credentials.sutPublicKey]);
}
const event = finalizeEvent(
{
kind: BUZZ_MESSAGE_KIND,
content: input.text,
created_at: Math.floor(Date.now() / 1_000),
tags,
},
secretKey,
);
await relay.publish(event);
return { eventId: event.id, timestamp: event.created_at * 1_000 };
},
async close() {
lifecycleAbort.abort(new Error("Buzz QA relay driver closed"));
subscription.close("shutdown");
relay.close();
await messageQueue;
},
};
}
+5
View File
@@ -41,6 +41,11 @@ export {
setQaChannelRuntime,
} from "./src/runtime-api.js";
export { startQaLiveLaneGateway } from "./src/live-transports/shared/live-gateway.runtime.js";
export { runLiveTransportQaSuiteCommand } from "./src/live-transports/shared/live-transport-suite.runtime.js";
export {
acquireQaCredentialLease,
startQaCredentialLeaseHeartbeat,
} from "./src/live-transports/shared/credential-lease.runtime.js";
export {
createQaChannelDriverLifecycle,
runQaChannelDriverLifecycleScenarios,
+2
View File
@@ -160,6 +160,7 @@ export type QaSuiteCommandOptions = QaScenarioRunCommandOptions & {
runtimePair?: string;
runtimePairLane?: string[];
sutAccountId?: string;
credentialFile?: string;
credentialSource?: string;
credentialRole?: string;
explicitScenarioSelection?: boolean;
@@ -1019,6 +1020,7 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) {
adapterOptions: {
repoRoot,
sutAccountId: opts.sutAccountId,
credentialFile: opts.credentialFile,
credentialSource: opts.credentialSource,
credentialRole: opts.credentialRole,
explicitScenarioSelection:
@@ -26,6 +26,40 @@ describe("gateway log redaction", () => {
expect(formatQaGatewayLogsForError(raw)).not.toContain(token);
});
it("redacts Buzz QA private keys and authorization tags", () => {
const privateKey = "01".repeat(32);
const authTag = '["auth","pubkey","conditions","signature"]';
const raw = [
`privateKey: ${privateKey}`,
`authTag: ${authTag}`,
`{"privateKey":"${privateKey}"}`,
JSON.stringify({ authTag }),
`channels.buzz.authTag: ${authTag}`,
[
"channels.buzz.authTag: [",
' "auth",',
' "pubkey",',
' "conditions",',
' "signature"',
"]",
].join("\n"),
[
"channels.buzz.authTag: [",
" 'auth',",
" 'pubkey',",
" 'conditions',",
" 'escaped\\'signature'",
"]",
].join("\n"),
].join("\n");
const redacted = redactQaGatewayDebugText(raw);
expect(redacted).not.toContain(privateKey);
expect(redacted).not.toContain(authTag);
expect(redacted).not.toContain("signature");
expect(redacted.match(/<redacted>/gu)).toHaveLength(7);
});
it("neutralizes GitHub workflow commands at every line boundary", () => {
const raw = [
"::set-output name=output_dir::/tmp/attacker",
+28 -3
View File
@@ -20,6 +20,8 @@ const QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS = Object.freeze([
"client_secret",
"cookie",
"driverToken",
"privateKey",
"authTag",
"sutToken",
"leaseToken",
"refreshToken",
@@ -58,21 +60,41 @@ function redactSecretEnvKeyPattern(text: string, pattern: RegExp) {
function redactSecretValueKey(text: string, key: string) {
const escapedKey = escapeRegExp(key);
const valuePattern = `[^\\s"';,]+|"(?:\\\\.|[^"\\\\])*"|'[^']*'`;
return text
.replace(new RegExp(`([?#&]${escapedKey}=)[^&\\s]+`, "gi"), "$1<redacted>")
.replace(
new RegExp(`(^|\\s)(--${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
new RegExp(`(^|\\s)(--${escapedKey})(\\s*[=:]\\s*)(${valuePattern})`, "gi"),
`$1$2$3<redacted>`,
)
.replace(
new RegExp(`(^|[^\\w?#&-])(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
new RegExp(`(^|[^\\w?#&-])(${escapedKey})(\\s*[=:]\\s*)(${valuePattern})`, "gi"),
`$1$2$3<redacted>`,
)
.replace(new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"), `$1"<redacted>"`);
.replace(new RegExp(`("${escapedKey}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi"), `$1"<redacted>"`);
}
function redactStructuredSecretLine(text: string, key: string) {
const escapedKey = escapeRegExp(key);
return text.replace(
new RegExp(`(^|[\\r\\n])(\\s*"?${escapedKey}"?\\s*[:=]\\s*).*$`, "gim"),
`$1$2<redacted>`,
);
}
function redactStructuredSecretArray(text: string, key: string) {
const escapedKey = escapeRegExp(key);
const quotedString = `"(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*'`;
const quotedStringArray = `\\[\\s*(?:${quotedString})(?:\\s*,\\s*(?:${quotedString}))*\\s*\\]`;
return text.replace(
new RegExp(`("?${escapedKey}"?\\s*[:=]\\s*)${quotedStringArray}`, "gi"),
`$1<redacted>`,
);
}
export function redactQaGatewayDebugText(text: string) {
let redacted = redactSensitiveText(redactTelegramBotTokens(text), { mode: "tools" });
redacted = redactStructuredSecretArray(redacted, "authTag");
for (const key of QA_GATEWAY_DEBUG_SECRET_HEADER_KEYS) {
const escapedKey = escapeRegExp(key);
redacted = redacted.replace(
@@ -97,6 +119,9 @@ export function redactQaGatewayDebugText(text: string) {
for (const key of QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS) {
redacted = redactSecretValueKey(redacted, key);
}
for (const key of ["authTag", "privateKey"]) {
redacted = redactStructuredSecretLine(redacted, key);
}
return redacted
.replaceAll(/\bsk-ant-oat01-[A-Za-z0-9_-]+\b/g, "<redacted>")
.replaceAll(/\bBearer\s+[^\s"'<>]{8,}/gi, "Bearer <redacted>")
@@ -91,6 +91,10 @@ describe("live transport adapter factories", () => {
expect.objectContaining({
adapterOptions,
channelId,
credentials: {
acquire: expect.any(Function),
startHeartbeat: expect.any(Function),
},
driver: "live",
messages: expect.objectContaining({
addInboundMessage: expect.any(Function),
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const runQaSuiteCommand = vi.hoisted(() => vi.fn());
@@ -8,9 +8,14 @@ import { runLiveTransportQaSuiteCommand } from "./live-transport-suite.runtime.j
describe("live transport suite runtime", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_QA_CREDENTIAL_SOURCE", "");
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("normalizes one live command into the shared suite host", async () => {
await runLiveTransportQaSuiteCommand({
channelId: "slack",
@@ -23,6 +28,7 @@ describe("live transport suite runtime", () => {
fastMode: true,
allowFailures: true,
failFast: true,
credentialFile: "/secure/slack-qa.json",
credentialSource: " convex ",
credentialRole: " ci ",
sutAccountId: "slack-sut",
@@ -49,6 +55,7 @@ describe("live transport suite runtime", () => {
concurrency: 1,
scenarioIds: ["slack-canary"],
sutAccountId: "slack-sut",
credentialFile: "/secure/slack-qa.json",
credentialSource: "convex",
credentialRole: "ci",
explicitScenarioSelection: false,
@@ -71,6 +78,21 @@ describe("live transport suite runtime", () => {
);
});
it("normalizes the shared credential source environment override", async () => {
vi.stubEnv("OPENCLAW_QA_CREDENTIAL_SOURCE", " convex ");
await runLiveTransportQaSuiteCommand({
channelId: "buzz",
defaultProviderMode: "mock-openai",
options: {},
selectScenarioIds: () => ["channel-canary"],
});
expect(runQaSuiteCommand).toHaveBeenCalledWith(
expect.objectContaining({ credentialSource: "convex" }),
);
});
it("rejects shared credentials for disposable transports", async () => {
await expect(
runLiveTransportQaSuiteCommand({
@@ -20,10 +20,11 @@ export async function runLiveTransportQaSuiteCommand(params: {
selectScenarioIds: LiveTransportScenarioSelection;
}) {
const options = params.options;
const credentialSource =
options.credentialSource?.trim() || process.env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim();
if (params.credentialMode === "env-only") {
const laneLabel = params.laneLabel ?? params.channelId;
const credentialSource = options.credentialSource?.trim().toLowerCase();
if (credentialSource && credentialSource !== "env") {
if (credentialSource && credentialSource.toLowerCase() !== "env") {
throw new Error(
`QA Lab ${laneLabel} supports only --credential-source env${params.envCredentialReason ? ` because ${params.envCredentialReason}` : "."}`,
);
@@ -58,10 +59,11 @@ export async function runLiveTransportQaSuiteCommand(params: {
concurrency: 1,
scenarioIds: selectedScenarioIds,
sutAccountId: options.sutAccountId,
...(options.credentialFile ? { credentialFile: options.credentialFile } : {}),
...(params.credentialMode === "env-only"
? {}
: {
credentialSource: options.credentialSource?.trim(),
credentialSource,
credentialRole: options.credentialRole?.trim(),
}),
explicitScenarioSelection: Boolean(options.scenarioIds?.length),
@@ -1,6 +1,10 @@
import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime";
// Qa Lab plugin module implements qa transport registry behavior.
import type { QaBusState } from "./bus-state.js";
import {
acquireQaCredentialLease,
startQaCredentialLeaseHeartbeat,
} from "./live-transports/shared/credential-lease.runtime.js";
import {
createQaChannelTransport,
QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY,
@@ -102,6 +106,10 @@ function createQaTransportAdapterFactoryRegistry(
const definition = await factory.create({
adapterOptions: context.adapterOptions,
channelId: context.channelId,
credentials: {
acquire: acquireQaCredentialLease,
startHeartbeat: startQaCredentialLeaseHeartbeat,
},
driver: context.driver,
messages: {
addInboundMessage: (input) => context.state.addInboundMessage(input),
+18 -2
View File
@@ -59,13 +59,17 @@ Maintainers can manage rows without using the Convex dashboard:
```bash
pnpm openclaw qa credentials add \
--kind telegram \
--payload-file qa/telegram-credential.json
--kind buzz \
--payload-file qa/buzz-credential.json
pnpm openclaw qa credentials add \
--kind discord \
--payload-file qa/discord-credential.json
pnpm openclaw qa credentials add \
--kind telegram \
--payload-file qa/telegram-credential.json
pnpm openclaw qa credentials list --kind telegram
pnpm openclaw qa credentials remove --credential-id <credential-id>
@@ -146,6 +150,18 @@ For `kind: "telegram"`, broker `admin/add` validates that payload includes:
- non-empty `driverToken`
- non-empty `sutToken`
For `kind: "buzz"`, broker `admin/add` validates that payload includes:
- `relayUrl` as a `wss://` URL, or `ws://` only for a loopback relay
- `roomId` as a channel UUID
- valid, distinct `driverPrivateKey` and `sutPrivateKey` values in nsec or
64-character hex form
- optional `driverAuthTag` and `sutAuthTag` values matching the four-string
Buzz authorization tag JSON shape
Use dedicated QA identities only. Never add a human owner or admin private key
to the shared pool.
For `kind: "telegram-user"`, broker `admin/add` validates one exclusive real-user
credential for both the TDLib CLI driver and the Telegram Desktop visual witness:
@@ -1,4 +1,6 @@
// Payload Validation module supports OpenClaw QA credential workflows.
import { getPublicKey, nip19 } from "nostr-tools";
class CredentialPayloadValidationError extends Error {
code: string;
httpStatus: number;
@@ -15,6 +17,9 @@ type PayloadValidationFailureFactory = (httpStatus: number, code: string, messag
const DISCORD_SNOWFLAKE_RE = /^\d{17,20}$/u;
const E164_RE = /^\+[1-9]\d{6,14}$/u;
const BUZZ_ROOM_ID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
const BUZZ_PRIVATE_KEY_HEX_RE = /^[0-9a-f]{64}$/iu;
const SHA256_HEX_RE = /^[a-f0-9]{64}$/u;
const TELEGRAM_CHAT_ID_RE = /^-?\d+$/u;
const TELEGRAM_USER_ID_RE = /^\d+$/u;
@@ -65,6 +70,132 @@ function requireDiscordSnowflakePayloadString(
return value;
}
function decodeBuzzPrivateKey(value: string) {
if (BUZZ_PRIVATE_KEY_HEX_RE.test(value)) {
const bytes = value.match(/.{2}/gu);
if (bytes?.length === 32) {
return Uint8Array.from(bytes.map((byte) => Number.parseInt(byte, 16)));
}
}
const decoded = nip19.decode(value);
if (decoded.type !== "nsec") {
throw new Error("not a Buzz private key");
}
return decoded.data;
}
function requireBuzzPrivateKey(
payload: Record<string, unknown>,
key: "driverPrivateKey" | "sutPrivateKey",
createFailure: PayloadValidationFailureFactory,
) {
const value = requirePayloadString(payload, key, "buzz", createFailure);
try {
return { value, publicKey: getPublicKey(decodeBuzzPrivateKey(value)) };
} catch {
throwPayloadError(
createFailure,
`Credential payload for kind "buzz" must include "${key}" as an nsec or 64-character hex private key.`,
);
}
}
function requireBuzzAuthTag(
payload: Record<string, unknown>,
key: "driverAuthTag" | "sutAuthTag",
createFailure: PayloadValidationFailureFactory,
) {
const value = requirePayloadString(payload, key, "buzz", createFailure);
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
parsed = undefined;
}
if (
!Array.isArray(parsed) ||
parsed.length !== 4 ||
parsed[0] !== "auth" ||
parsed.some((entry) => typeof entry !== "string")
) {
throwPayloadError(
createFailure,
`Credential payload for kind "buzz" must include "${key}" as an auth tag JSON array.`,
);
}
return value;
}
function normalizeBuzzCredentialPayload(
payload: Record<string, unknown>,
createFailure: PayloadValidationFailureFactory,
) {
const kind = "buzz";
const relayUrl = requirePayloadString(payload, "relayUrl", kind, createFailure);
let parsedRelayUrl: URL | undefined;
try {
parsedRelayUrl = new URL(relayUrl);
} catch {
parsedRelayUrl = undefined;
}
const relayProtocol = parsedRelayUrl?.protocol;
const relayUsesSafeTransport =
relayProtocol === "wss:" ||
(relayProtocol === "ws:" && isBuzzLoopbackHostname(parsedRelayUrl?.hostname ?? ""));
if (!relayUsesSafeTransport) {
throwPayloadError(
createFailure,
'Credential payload for kind "buzz" must include "relayUrl" using wss:// (ws:// is allowed only for loopback).',
);
}
const roomId = requirePayloadString(payload, "roomId", kind, createFailure).toLowerCase();
if (!BUZZ_ROOM_ID_RE.test(roomId)) {
throwPayloadError(
createFailure,
'Credential payload for kind "buzz" must include "roomId" as a channel UUID.',
);
}
const driverIdentity = requireBuzzPrivateKey(payload, "driverPrivateKey", createFailure);
const sutIdentity = requireBuzzPrivateKey(payload, "sutPrivateKey", createFailure);
if (driverIdentity.publicKey === sutIdentity.publicKey) {
throwPayloadError(
createFailure,
'Credential payload for kind "buzz" must use distinct driver and SUT identities.',
);
}
const optionalString = (key: "driverAuthTag" | "sutAuthTag") => {
if (payload[key] === undefined) {
return undefined;
}
return requireBuzzAuthTag(payload, key, createFailure);
};
const driverAuthTag = optionalString("driverAuthTag");
const sutAuthTag = optionalString("sutAuthTag");
return {
relayUrl,
roomId,
driverPrivateKey: driverIdentity.value,
sutPrivateKey: sutIdentity.value,
...(driverAuthTag ? { driverAuthTag } : {}),
...(sutAuthTag ? { sutAuthTag } : {}),
} satisfies Record<string, unknown>;
}
function isBuzzLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
if (normalized === "localhost" || normalized === "::1") {
return true;
}
const ipv4 = normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
const octets = ipv4.split(".");
return (
octets.length === 4 &&
octets[0] === "127" &&
octets.every((octet) => /^\d{1,3}$/u.test(octet) && Number(octet) <= 255)
);
}
function normalizeTelegramCredentialPayload(
payload: Record<string, unknown>,
createFailure: PayloadValidationFailureFactory,
@@ -263,6 +394,7 @@ const credentialPayloadNormalizers: Record<
createFailure: PayloadValidationFailureFactory,
) => Record<string, unknown>
> = {
buzz: normalizeBuzzCredentialPayload,
discord: normalizeDiscordCredentialPayload,
telegram: normalizeTelegramCredentialPayload,
"telegram-user": normalizeTelegramUserCredentialPayload,
+2 -1
View File
@@ -10,6 +10,7 @@
"dev": "convex dev"
},
"dependencies": {
"convex": "1.42.3"
"convex": "1.42.3",
"nostr-tools": "2.24.1"
}
}
+42
View File
@@ -29,6 +29,7 @@ type QaRunnerAdapterOptions = {
repoRoot?: string;
scenarioIds?: readonly string[];
sutAccountId?: string;
credentialFile?: string;
credentialSource?: string;
credentialRole?: string;
transportPolicy?: QaRunnerTransportPolicy;
@@ -40,6 +41,46 @@ type QaRunnerMessageRecorder = {
editMessage: (input: QaBusEditMessageInput) => QaBusMessage | Promise<QaBusMessage>;
};
type QaRunnerCredentialLease<TPayload> = {
credentialId?: string;
heartbeat(): Promise<void>;
heartbeatIntervalMs: number;
kind: string;
leaseToken?: string;
leaseTtlMs: number;
ownerId?: string;
payload: TPayload;
release(): Promise<void>;
role?: "ci" | "maintainer";
source: "convex" | "env";
};
type QaRunnerCredentialLeaseOptions<TPayload> = {
kind: string;
parsePayload: (payload: unknown) => TPayload;
resolveEnvPayload: () => TPayload;
role?: string;
source?: string;
};
type QaRunnerCredentialHeartbeat = {
getFailure(): Error | null;
stop(): Promise<void>;
throwIfFailed(): void;
};
type QaRunnerCredentialHost = {
acquire<TPayload>(
options: QaRunnerCredentialLeaseOptions<TPayload>,
): Promise<QaRunnerCredentialLease<TPayload>>;
startHeartbeat(
lease: Pick<
QaRunnerCredentialLease<unknown>,
"heartbeat" | "heartbeatIntervalMs" | "kind" | "source"
>,
): QaRunnerCredentialHeartbeat;
};
type QaRunnerTransportFlowPreparationInput = {
config: Record<string, unknown>;
scenarioId: string;
@@ -147,6 +188,7 @@ type QaRunnerTransportFactory = {
create: (context: {
adapterOptions?: QaRunnerAdapterOptions;
channelId: string;
credentials: QaRunnerCredentialHost;
driver: string;
messages: QaRunnerMessageRecorder;
outputDir: string;
+28
View File
@@ -118,6 +118,30 @@ describe("plugin-sdk qa-runtime", () => {
expect(module.isQaRuntimeAvailable()).toBe(false);
});
it("runs a plugin-owned transport through the private QA suite host", async () => {
const runLiveTransportQaSuiteCommand = vi.fn(async () => {});
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
runLiveTransportQaSuiteCommand,
});
const module = await import("./qa-runtime.js");
const options = { providerMode: "mock-openai" };
const selectScenarioIds = vi.fn(() => ["channel-canary"]);
await module.runLiveTransportQaSuiteCommand({
channelId: "buzz",
defaultProviderMode: "mock-openai",
options,
selectScenarioIds,
});
expect(runLiveTransportQaSuiteCommand).toHaveBeenCalledWith({
channelId: "buzz",
defaultProviderMode: "mock-openai",
options,
selectScenarioIds,
});
});
it("registers shared live transport QA CLI options", async () => {
const module = await import("./qa-runtime.js");
const run = vi.fn(async () => {});
@@ -126,6 +150,7 @@ describe("plugin-sdk qa-runtime", () => {
module
.createLiveTransportQaCliRegistration({
commandName: "telegram",
credentialFileHelp: "Private JSON credential file",
credentialOptions: {
sourceDescription: "Credential source for Telegram QA",
roleDescription: "Credential role for Telegram QA",
@@ -172,6 +197,8 @@ describe("plugin-sdk qa-runtime", () => {
"--fail-fast",
"--sut-account",
"sut-2",
"--credential-file",
"/secure/telegram-qa.json",
"--credential-source",
"convex",
"--credential-role",
@@ -191,6 +218,7 @@ describe("plugin-sdk qa-runtime", () => {
scenarioIds: ["alpha", "beta"],
listScenarios: true,
sutAccountId: "sut-2",
credentialFile: "/secure/telegram-qa.json",
credentialSource: "convex",
credentialRole: "maintainer",
});
+29
View File
@@ -24,6 +24,7 @@ type QaRuntimeSurface = {
},
) => string;
startQaLiveLaneGateway: (...args: unknown[]) => Promise<unknown>;
runLiveTransportQaSuiteCommand: (params: LiveTransportQaSuiteCommandOptions) => Promise<unknown>;
};
function isMissingQaRuntimeError(error: unknown) {
@@ -71,10 +72,31 @@ export type LiveTransportQaCommandOptions = {
scenarioIds?: string[];
listScenarios?: boolean;
sutAccountId?: string;
credentialFile?: string;
credentialSource?: string;
credentialRole?: string;
};
export type LiveTransportQaSuiteCommandOptions = {
channelId: string;
credentialMode?: "env-only" | "shared-lease";
defaultProviderMode: string;
envCredentialReason?: string;
laneLabel?: string;
options: LiveTransportQaCommandOptions;
selectScenarioIds: (params: {
profile?: string;
primaryModel: string;
providerMode: string;
scenarioIds?: readonly string[];
}) => string[];
};
/** Run a plugin-owned transport adapter through QA Lab's shared suite host. */
export async function runLiveTransportQaSuiteCommand(params: LiveTransportQaSuiteCommandOptions) {
return await loadQaRuntimeModule().runLiveTransportQaSuiteCommand(params);
}
type LiveTransportQaCommanderOptions = {
repoRoot?: string;
outputDir?: string;
@@ -88,6 +110,7 @@ type LiveTransportQaCommanderOptions = {
failFast?: boolean;
profile?: string;
sutAccount?: string;
credentialFile?: string;
credentialSource?: string;
credentialRole?: string;
};
@@ -104,6 +127,7 @@ export type LiveTransportQaCredentialCliOptions = {
/** Declarative command metadata and runner used to install a live-transport QA CLI. */
export type LiveTransportQaCliRegistrationOptions = {
commandName: string;
credentialFileHelp?: string;
credentialOptions?: LiveTransportQaCredentialCliOptions;
defaultProviderMode: string;
description: string;
@@ -149,6 +173,7 @@ function mapLiveTransportQaCommanderOptions(
scenarioIds: opts.scenario,
listScenarios: opts.listScenarios,
sutAccountId: opts.sutAccount,
credentialFile: opts.credentialFile,
credentialSource: opts.credentialSource,
credentialRole: opts.credentialRole,
};
@@ -177,6 +202,10 @@ function registerLiveTransportQaCli(
command.option("--sut-account <id>", params.sutAccountHelp, "sut");
if (params.credentialFileHelp) {
command.option("--credential-file <path>", params.credentialFileHelp);
}
if (params.listScenariosHelp) {
command.option("--list-scenarios", params.listScenariosHelp, false);
}
@@ -2,7 +2,106 @@
import { describe, expect, it } from "vitest";
import { normalizeCredentialPayloadForKind } from "../qa/convex-credential-broker/convex/payload_validation.js";
const BUZZ_DRIVER_PRIVATE_KEY = "01".repeat(32);
const BUZZ_SUT_PRIVATE_KEY = "02".repeat(32);
const BUZZ_DRIVER_NSEC = "nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw";
describe("QA Convex credential payload validation", () => {
it("normalizes Buzz credential payloads", () => {
expect(
normalizeCredentialPayloadForKind("buzz", {
relayUrl: " wss://relay.qa.example ",
roomId: " 123E4567-E89B-42D3-A456-426614174000 ",
driverPrivateKey: ` ${BUZZ_DRIVER_PRIVATE_KEY} `,
sutPrivateKey: ` ${BUZZ_SUT_PRIVATE_KEY} `,
driverAuthTag: ' ["auth","driver","conditions","signature"] ',
ignored: true,
}),
).toEqual({
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: BUZZ_DRIVER_PRIVATE_KEY,
sutPrivateKey: BUZZ_SUT_PRIVATE_KEY,
driverAuthTag: '["auth","driver","conditions","signature"]',
});
});
it.each(["ws://localhost:8080", "ws://127.0.0.1:8080", "ws://[::1]:8080"])(
"allows plaintext loopback Buzz relay URL %s",
(relayUrl) => {
expect(
normalizeCredentialPayloadForKind("buzz", {
relayUrl,
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: BUZZ_DRIVER_PRIVATE_KEY,
sutPrivateKey: BUZZ_SUT_PRIVATE_KEY,
}),
).toMatchObject({ relayUrl });
},
);
it("rejects plaintext remote Buzz relay URLs", () => {
expect(() =>
normalizeCredentialPayloadForKind("buzz", {
relayUrl: "ws://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: BUZZ_DRIVER_PRIVATE_KEY,
sutPrivateKey: BUZZ_SUT_PRIVATE_KEY,
}),
).toThrow(/wss:\/\//u);
});
it("rejects malformed Buzz credential payloads without echoing values", () => {
const privateKey = BUZZ_DRIVER_PRIVATE_KEY;
const invalidRelay = "https://relay.qa.example/private-path";
expect(() =>
normalizeCredentialPayloadForKind("buzz", {
relayUrl: invalidRelay,
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: privateKey,
sutPrivateKey: privateKey,
}),
).toThrow(/wss:\/\//u);
try {
normalizeCredentialPayloadForKind("buzz", {
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: privateKey,
sutPrivateKey: privateKey,
});
} catch (error) {
expect(String(error)).not.toContain(privateKey);
}
});
it("rejects runtime-invalid Buzz secrets and equivalent key encodings", () => {
expect(() =>
normalizeCredentialPayloadForKind("buzz", {
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: BUZZ_DRIVER_PRIVATE_KEY,
sutPrivateKey: BUZZ_DRIVER_NSEC,
}),
).toThrow(/distinct driver and SUT identities/u);
expect(() =>
normalizeCredentialPayloadForKind("buzz", {
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: "not-a-private-key",
sutPrivateKey: BUZZ_SUT_PRIVATE_KEY,
}),
).toThrow(/nsec or 64-character hex private key/u);
expect(() =>
normalizeCredentialPayloadForKind("buzz", {
relayUrl: "wss://relay.qa.example",
roomId: "123e4567-e89b-42d3-a456-426614174000",
driverPrivateKey: BUZZ_DRIVER_PRIVATE_KEY,
sutPrivateKey: BUZZ_SUT_PRIVATE_KEY,
driverAuthTag: "not-an-auth-tag",
}),
).toThrow(/auth tag JSON array/u);
});
it("normalizes Discord credential payloads", () => {
expect(
normalizeCredentialPayloadForKind("discord", {
@@ -394,6 +394,7 @@ function runReleaseChecksSummary(params: {
QA_LAB_PARITY_LANE_RELEASE_CHECKS_RESULT: "skipped",
QA_LAB_PARITY_REPORT_RELEASE_CHECKS_RESULT: "skipped",
QA_LAB_RUNTIME_PARITY_RELEASE_CHECKS_RESULT: "skipped",
QA_LIVE_BUZZ_RELEASE_CHECKS_RESULT: "skipped",
QA_LIVE_DISCORD_RELEASE_CHECKS_RESULT: "skipped",
QA_LIVE_RELEASE_CHECKS_RESULT: "skipped",
QA_LIVE_SLACK_RELEASE_CHECKS_RESULT: "skipped",
@@ -2590,7 +2591,7 @@ describe("package artifact reuse", () => {
fail_fast: "${{ fromJSON(needs.resolve_target.outputs.fail_fast) }}",
run_matrix: true,
});
for (const lane of ["mock_parity", "telegram", "discord", "whatsapp", "slack"]) {
for (const lane of ["mock_parity", "buzz", "telegram", "discord", "whatsapp", "slack"]) {
expect(releaseJob.with?.[`run_${lane}`]).toBeUndefined();
}
expect(workflowJob(QA_LIVE_TRANSPORTS_WORKFLOW, "run_mock_parity").if).toBe(
@@ -2645,6 +2646,30 @@ describe("package artifact reuse", () => {
);
});
it("routes release Buzz through the QA Lab selector", () => {
const releaseJob = workflowJob(RELEASE_CHECKS_WORKFLOW, "qa_live_buzz_release_checks");
expect(releaseJob.uses).toBe("./.github/workflows/qa-live-transports-convex.yml");
expect(releaseJob.secrets).toBeUndefined();
expect(releaseJob.permissions).toEqual({ contents: "read", "pull-requests": "read" });
expect(releaseJob.if).toContain('contains(fromJSON(\'["all","qa","qa-live"]\')');
expect(releaseJob.if).toContain("needs.resolve_target.outputs.qa_live_buzz_enabled == 'true'");
expect(releaseJob.with).toMatchObject({
buzz_scenario: "channel-canary,channel-mention-gating",
expected_sha: "${{ needs.resolve_target.outputs.revision }}",
run_buzz: true,
});
expect(workflowJob(QA_LIVE_TRANSPORTS_WORKFLOW, "run_live_buzz").if).toBe("inputs.run_buzz");
expect(
workflowStep(
workflowJob(QA_LIVE_TRANSPORTS_WORKFLOW, "run_live_buzz"),
"Upload Buzz QA artifacts",
).with?.name,
).toBe(
"${{ inputs.expected_sha != '' && format('release-qa-live-buzz-{0}-{1}', inputs.expected_sha, github.run_attempt) || format('qa-live-buzz-{0}-{1}', github.run_id, github.run_attempt) }}",
);
});
it("runs live transport lanes nightly while release checks stay gated", () => {
const releaseWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8");
const qaWorkflow = readFileSync(QA_LIVE_TRANSPORTS_WORKFLOW, "utf8");
@@ -2674,6 +2699,7 @@ describe("package artifact reuse", () => {
"always() && steps.run_lane.outputs.output_dir != ''",
],
["run_live_matrix", "Upload Matrix QA artifacts", "always()"],
["run_live_buzz", "Upload Buzz QA artifacts", "always()"],
["run_live_telegram", "Upload Telegram QA artifacts", "always()"],
["run_live_discord", "Upload Discord QA artifacts", "always()"],
["run_live_whatsapp", "Upload WhatsApp QA artifacts", "always()"],
@@ -3431,6 +3457,7 @@ describe("package artifact reuse", () => {
const verifyStep = workflowStep(summary, "Verify release check results");
expect(verifyStep.env).toMatchObject({
QA_LIVE_BUZZ_RELEASE_CHECKS_RESULT: "${{ needs.qa_live_buzz_release_checks.result }}",
QA_LIVE_RELEASE_CHECKS_RESULT: "${{ needs.qa_live_release_checks.result }}",
RELEASE_CHECK_RUN_ATTEMPT: "${{ github.run_attempt }}",
RELEASE_CHECK_RUN_ID: "${{ github.run_id }}",
@@ -3454,6 +3481,7 @@ describe("package artifact reuse", () => {
"::warning::${name} ended with ${result}; Tideclaw alpha treats non-package-safety release-check lanes as advisory.",
"::error::${name} ended with ${result}",
'"qa_live_release_checks=${QA_LIVE_RELEASE_CHECKS_RESULT}"',
'"qa_live_buzz_release_checks=${QA_LIVE_BUZZ_RELEASE_CHECKS_RESULT}"',
]);
expect(verifyStep.run).not.toContain("qa_live_matrix_release_checks");
expect(verifyStep.run).not.toContain(