Skip to content

feat(qa-lab): add Convex credential broker and admin CLI#65596

Merged
joshavant merged 6 commits intomainfrom
feat/qa-lab-convex-credentials
Apr 13, 2026
Merged

feat(qa-lab): add Convex credential broker and admin CLI#65596
joshavant merged 6 commits intomainfrom
feat/qa-lab-convex-credentials

Conversation

@joshavant
Copy link
Copy Markdown
Contributor

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: QA Telegram live runs required each maintainer/CI lane to provide local credentials, with no shared lease-safe pool.
  • Why it matters: this blocks reliable concurrent smoke/E2E usage and increases setup friction for maintainers and CI.
  • What changed: added Convex-backed credential leasing for Telegram QA lane, a standalone Convex broker scaffold, and maintainer CLI admin commands to add/list/remove pooled credentials.
  • What did NOT change (scope boundary): Matrix remains env/disposable-only for now; no app-level payload encryption layer was added.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #
  • Related #
  • This PR fixes a bug or regression

Root Cause (if applicable)

For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write N/A. If the cause is unclear, write Unknown.

  • Root cause: N/A
  • Missing detection / guardrail: N/A
  • Contributing context (if known): N/A

Regression Test Plan (if applicable)

For bug fixes or regressions, name the smallest reliable test coverage that should catch this. Otherwise write N/A.

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: extensions/qa-lab/src/cli.test.ts, extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts, extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts, extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts, extensions/qa-lab/src/live-transports/matrix/cli.runtime.test.ts
  • Scenario the test should lock in: CLI routing + Convex admin client behavior + lease lifecycle + Telegram credential-source selection behavior.
  • Why this is the smallest reliable guardrail: these are the direct seams touched by this feature set and they isolate broker contract behavior from unrelated runtime surfaces.
  • Existing test that already covers this (if any): listed above.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

List user-visible changes (including defaults/config).
If none, write None.

  • New openclaw qa credentials command group:
    • add --kind <kind> --payload-file <path> [--note] [--json]
    • list [--kind] [--status] [--limit] [--show-secrets] [--json]
    • remove --credential-id <id> [--json]
  • openclaw qa telegram now supports pooled credentials via --credential-source convex.
  • New role selection for pooled access: --credential-role maintainer|ci.
  • New docs for pooled credential setup and broker endpoints.

Diagram (if applicable)

For UI changes or non-trivial logic flows, include a small ASCII diagram reviewers can scan quickly. Otherwise write N/A.

Before:
[qa telegram run] -> [read env tokens only] -> [single operator-managed creds]

After:
[qa telegram run --credential-source convex]
  -> [acquire lease from Convex pool]
  -> [run scenario + heartbeat]
  -> [release lease]

Security Impact (required)

  • New permissions/capabilities? (Yes/No) Yes
  • Secrets/tokens handling changed? (Yes/No) Yes
  • New/changed network calls? (Yes/No) Yes
  • Command/tool execution surface changed? (Yes/No) Yes
  • Data access scope changed? (Yes/No) Yes
  • If any Yes, explain risk + mitigation:
    • Adds authenticated remote credential leasing/admin endpoints.
    • Mitigations: role-scoped secrets (maintainer vs ci), maintainer-only admin endpoints, soft-delete guard for active leases, lease TTL + heartbeat expiry, minimal event logging with retention cleanup, and default redaction on list responses unless explicitly requested.

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 24 + pnpm (repo local)
  • Model/provider: N/A
  • Integration/channel (if any): QA lab Telegram/Matrix runtime seams
  • Relevant config (redacted): Convex env + QA CLI env defaults

Steps

  1. pnpm test extensions/qa-lab/src/cli.test.ts extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts extensions/qa-lab/src/live-transports/matrix/cli.runtime.test.ts
  2. Confirm all tests pass.
  3. Inspect docs and broker README for endpoint/CLI contract consistency.

Expected

  • Targeted test suite passes.
  • CLI and broker docs align with implemented endpoint and command surfaces.

Actual

  • Pass: 5 files, 35 tests.
  • Docs and broker README both document acquire/heartbeat/release and admin add/remove/list surfaces.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: targeted QA-lab test run above; branch push; docs/README endpoint contract alignment.
  • Edge cases checked: admin list redaction docs, remove flow active-lease guard behavior covered by runtime/tests.
  • What you did not verify: full pnpm check, full pnpm test, and broad multi-channel/manual E2E rerun in this pass.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.

Compatibility / Migration

  • Backward compatible? (Yes/No) Yes
  • Config/env changes? (Yes/No) Yes
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk: Convex outage causes pooled credential acquisition failure.
    • Mitigation: fail-fast behavior is explicit and observable; env credential-source remains available.
  • Risk: secret misconfiguration blocks admin or lease operations.
    • Mitigation: role-specific secret checks and clear API error codes.
  • Risk: pool exhaustion during contention.
    • Mitigation: round-robin/least-recently-leased selection plus client retry behavior.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Apr 12, 2026

🔒 Aisle Security Analysis

We found 8 potential security issue(s) in this PR:

# Severity Title
1 🟠 High SSRF and bearer token exfiltration via unvalidated OPENCLAW_QA_CONVEX_SITE_URL in credential leasing client
2 🟠 High SSRF and maintainer secret leakage via --site-url / OPENCLAW_QA_CONVEX_SITE_URL in qa-credentials admin client
3 🟡 Medium Bypassable loopback check allows insecure HTTP to non-loopback hosts
4 🟡 Medium Information disclosure via reflected internal error messages in credential broker HTTP API
5 🟡 Medium QA credential CLI can output raw secret payloads to stdout/JSON
6 🟡 Medium Lease role not bound in heartbeat/release allows cross-role lease manipulation with leaked lease token
7 🟡 Medium Bypassable loopback check allows insecure HTTP to non-loopback hosts (admin client)
8 🔵 Low Authentication error responses disclose broker deployment configuration and token role matches
1. 🟠 SSRF and bearer token exfiltration via unvalidated OPENCLAW_QA_CONVEX_SITE_URL in credential leasing client
Property Value
Severity High
CWE CWE-918
Location extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts:134-277

Description

The Convex credential leasing client builds request URLs from OPENCLAW_QA_CONVEX_SITE_URL and then performs authenticated fetch requests to those URLs.

  • Input: OPENCLAW_QA_CONVEX_SITE_URL environment variable (or other upstream config) is parsed and only constrained by scheme (https://, or http://localhost with opt-in).
  • Sink: postConvexBroker() sends Authorization: Bearer <secret> to params.url using raw fetch.
  • Impact: If an attacker can influence OPENCLAW_QA_CONVEX_SITE_URL in the execution environment (e.g., compromised CI job config, poisoned shell environment, or malicious wrapper script), the client will send the Convex broker bearer token to an attacker-controlled host over HTTPS, and can be used to target internal services (SSRF) reachable from that runner.

Vulnerable code:

const response = await params.fetchImpl(params.url, {
  method: "POST",
  headers: {
    authorization: `Bearer ${params.authToken}`,
    "content-type": "application/json",
  },
  body: JSON.stringify(params.body),
});

Recommendation

Prevent attacker-controlled outbound destinations for authenticated broker calls.

Options:

  1. Allowlist the broker hostname(s) (recommended): require the hostname to match expected Convex deployment domains (e.g. *.convex.site, *.convex.cloud) and reject IP literals and private network ranges.

  2. Use the existing SSRF-guarded fetch if available in this runtime (e.g., fetchWithSsrFGuard) and configure it to block private/loopback/link-local and metadata IP ranges.

Example (hostname allowlist):

function assertAllowedConvexHost(url: URL) {
  const host = url.hostname.toLowerCase();
  const allowed = host.endsWith(".convex.site") || host.endsWith(".convex.cloud");
  if (!allowed) throw new Error("OPENCLAW_QA_CONVEX_SITE_URL must point to a Convex domain");
}

function normalizeConvexSiteUrl(raw: string, env: NodeJS.ProcessEnv): string {
  const url = new URL(raw);
  if (url.protocol !== "https:") throw new Error("...");
  assertAllowedConvexHost(url);
  return url.toString().replace(/\/$/, "");
}

Additionally, consider not sending the bearer token until after URL validation is complete (fail closed).

2. 🟠 SSRF and maintainer secret leakage via --site-url / OPENCLAW_QA_CONVEX_SITE_URL in qa-credentials admin client
Property Value
Severity High
CWE CWE-918
Location extensions/qa-lab/src/qa-credentials-admin.runtime.ts:206-281

Description

The QA credentials admin client allows overriding the Convex broker base URL via --site-url (CLI) or OPENCLAW_QA_CONVEX_SITE_URL (env). It then performs authenticated POST requests with the maintainer bearer token to the constructed URL.

  • Input: options.siteUrl (CLI --site-url) or env.OPENCLAW_QA_CONVEX_SITE_URL in resolveAdminConfig().
  • Sink: postJson() sends Authorization: Bearer <OPENCLAW_QA_CONVEX_SECRET_MAINTAINER> to params.url using raw fetch.
  • Impact: If an attacker can influence --site-url/env, the tool will send the maintainer secret to an attacker-controlled host over HTTPS. This is both SSRF (arbitrary outbound requests from the operator machine/CI runner) and credential exfiltration.

Vulnerable code:

response = await params.fetchImpl(params.url, {
  method: "POST",
  headers: {
    authorization: `Bearer ${params.authToken}`,
    "content-type": "application/json",
  },
  body: JSON.stringify(params.body),
});

Recommendation

Treat the Convex broker base URL as a sensitive, non-user-controllable configuration.

  • Do not accept arbitrary --site-url unless it is strictly validated/allowlisted.
  • Add an allowlist of expected Convex domains and reject IP literals and private network targets.
  • Alternatively, use an SSRF-protected fetch implementation (e.g. fetchWithSsrFGuard) that blocks requests to private/loopback/link-local/metadata ranges.

Example allowlist enforcement:

function assertAllowedBrokerHost(url: URL) {
  const host = url.hostname.toLowerCase();
  if (!(host.endsWith(".convex.site") || host.endsWith(".convex.cloud"))) {
    throw new QaCredentialAdminError({
      code: "INVALID_SITE_URL",
      message: "--site-url must point to a Convex domain",
    });
  }
}

function normalizeConvexSiteUrl(raw: string, env: NodeJS.ProcessEnv): string {
  const url = new URL(raw);
  if (url.protocol !== "https:") throw ...;
  assertAllowedBrokerHost(url);
  return url.toString().replace(/\/$/, "");
}

Also consider warning users that --site-url can leak secrets and should only be used for local loopback development with explicit opt-in.

3. 🟡 Bypassable loopback check allows insecure HTTP to non-loopback hosts
Property Value
Severity Medium
CWE CWE-295
Location extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts:130-132

Description

OPENCLAW_QA_ALLOW_INSECURE_HTTP=1 is intended to permit http:// only for local loopback development, but the loopback check is based on a naive string prefix match (hostname.startsWith("127.")).

Because URL.hostname returns the hostname as a string (not necessarily an IP literal), attacker-controlled or misconfigured values such as http://127.0.0.1.nip.io will pass the check even though they resolve to a non-loopback remote host, enabling cleartext HTTP requests (credential acquisition/heartbeat/release) to be sent off-machine.

Vulnerable code:

function isLoopbackHostname(hostname: string) {
  return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
}

Impact:

  • If the insecure HTTP opt-in is enabled (accidentally in CI or shared environments), credentials and lease tokens can be transmitted over plaintext HTTP to an attacker-controlled endpoint.
  • This undermines the protection intended by enforcing https:// for broker/admin communications.

Recommendation

Make the loopback decision based on IP literals only, not arbitrary hostnames, and avoid prefix matching.

Example fix:

import { isIP } from "node:net";

function isLoopbackHost(url: URL) {
  const host = url.hostname;
  if (host === "localhost") return true;
  const ipVersion = isIP(host);
  if (ipVersion === 4) return host.startsWith("127.");
  if (ipVersion === 6) return host === "::1";
  return false; // any other hostname must use https
}

Optionally, if you truly want to allow custom local dev hostnames, resolve DNS and verify the resolved address is loopback (with care for DNS rebinding).

4. 🟡 Information disclosure via reflected internal error messages in credential broker HTTP API
Property Value
Severity Medium
CWE CWE-209
Location qa/convex-credential-broker/convex/http.ts:286-294

Description

The Convex credential broker HTTP layer returns raw exception messages to the client for unexpected errors.

  • normalizeError maps any non-BrokerHttpError Error to a 500 response that includes error.message.
  • Internal exceptions from Convex/runtime/database can include sensitive details (and in some cases values derived from request bodies), which may leak implementation details or secrets.
  • This is especially risky for a service that handles secrets (credential payloads) because thrown errors elsewhere in the call chain may accidentally embed secret-bearing objects in their message.

Vulnerable code:

if (error instanceof Error) {
  return {
    httpStatus: 500,
    payload: {
      status: "error",
      code: "INTERNAL_ERROR",
      message: error.message || "Internal credential broker error.",
    },
  };
}

Recommendation

Do not reflect raw internal exception messages to clients. Return a generic error message for unhandled exceptions, and log the detailed error server-side (with care to avoid logging credential payloads).

Example:

function normalizeError(error: unknown) {
  if (error instanceof BrokerHttpError) {
    return {
      httpStatus: error.httpStatus,
      payload: { status: "error", code: error.code, message: error.message },
    };
  }// Server-side logging only
  console.error("Broker unhandled error", error);

  return {
    httpStatus: 500,
    payload: {
      status: "error",
      code: "INTERNAL_ERROR",
      message: "Internal credential broker error.",
    },
  };
}

Optionally add a request correlation id and return that to the client for debugging without leaking details.

5. 🟡 QA credential CLI can output raw secret payloads to stdout/JSON
Property Value
Severity Medium
CWE CWE-532
Location extensions/qa-lab/src/cli.runtime.ts:619-641

Description

The QA credentials administration CLI includes an option to return/print credential payload objects (e.g., Telegram bot tokens). When --show-secrets is used, payloads are printed verbatim to stdout; and when --json is used, the command returns the full credential records (including payload when present) in machine-readable output.

This is risky because:

  • Secrets can be captured in terminal scrollback, shell history, CI logs, build artifacts, or crash reports
  • JSON output is commonly redirected to files or piped to other tools, increasing accidental retention/exfiltration risk

Vulnerable code:

if (opts.json) {
  process.stdout.write(
    `${JSON.stringify({ status: "ok", action: "list", count: result.credentials.length, credentials: result.credentials }, null, 2)}\n`,
  );
  return;
}
...
if (opts.showSecrets && result.credentials.length > 0) {
  process.stdout.write("\nPayloads:\n");
  for (const credential of result.credentials) {
    process.stdout.write(`${credential.credentialId}: ${JSON.stringify(credential.payload ?? null)}\n`);
  }
}

Although --show-secrets is an explicit opt-in, there is no additional safeguard (warning/confirmation, output redaction, or forcing stderr) to prevent inadvertent secret exposure.

Recommendation

Reduce the chance of accidental secret exposure:

  • Keep payloads redacted by default (including in --json output), even when listing
  • Require an additional explicit acknowledgement for secrets, e.g. --show-secrets --yes-i-know-this-prints-secrets
  • Print a loud warning to stderr before emitting secrets
  • Consider emitting secrets only to a separate file with restrictive permissions, or to stderr, not stdout

Example (redact payloads in JSON unless explicitly acknowledged):

const allowSecrets = opts.showSecrets && opts.iKnowThisPrintsSecrets;

const credentials = allowSecrets
  ? result.credentials
  : result.credentials.map(({ payload, ...rest }) => ({ ...rest, payload: undefined }));

if (opts.json) {
  process.stdout.write(JSON.stringify({ status: "ok", action: "list", count: credentials.length, credentials }, null, 2) + "\n");
  return;
}

if (allowSecrets) {
  process.stderr.write("WARNING: printing credential payloads (secrets) to output\n");// ...print payloads...
}
6. 🟡 Lease role not bound in heartbeat/release allows cross-role lease manipulation with leaked lease token
Property Value
Severity Medium
CWE CWE-285
Location qa/convex-credential-broker/convex/credentials.ts:360-365

Description

The credential broker stores an actorRole on each lease (credential_sets.lease.actorRole), but the heartbeatLease and releaseLease mutations do not verify that the stored lease role matches the caller-provided actorRole.

Impact:

  • Authorization for heartbeat/release is effectively only ownerId + leaseToken (plus kind/id checks).
  • If a lease token is leaked (e.g., via client logs/telemetry), a caller authenticated with the other broker secret (CI vs maintainer) can still heartbeat/extend or release a lease acquired under a different role, defeating separation between roles.
  • releaseLease also records the event using args.actorRole, allowing audit log inconsistency with the lease's originally recorded role.

Vulnerable code:

if (row.lease.ownerId !== args.ownerId || row.lease.leaseToken !== args.leaseToken) {
  return brokerError("LEASE_NOT_OWNER", "Credential lease owner/token mismatch.");
}// Missing: row.lease.actorRole === args.actorRole

Although the HTTP layer enforces tokenRole === actorRole, these mutations are internalMutations and can also be invoked by other internal callers; additionally, the missing binding means cross-role use is possible whenever lease tokens are exposed.

Recommendation

Bind the lease to the role by verifying actorRole against the stored lease role in both heartbeatLease and releaseLease.

Example fix:

if (row.lease.ownerId !== args.ownerId || row.lease.leaseToken !== args.leaseToken) {
  return brokerError("LEASE_NOT_OWNER", "Credential lease owner/token mismatch.");
}
if (row.lease.actorRole !== args.actorRole) {
  return brokerError("LEASE_ROLE_MISMATCH", "Credential lease role mismatch.");
}

Additionally, consider:

  • Deriving actorRole from the bearer token at the boundary and not accepting it from the request body (avoid spoofing in future internal call sites).
  • Recording the release event role from row.lease.actorRole (trusted persisted value) instead of args.actorRole.
7. 🟡 Bypassable loopback check allows insecure HTTP to non-loopback hosts (admin client)
Property Value
Severity Medium
CWE CWE-295
Location extensions/qa-lab/src/qa-credentials-admin.runtime.ts:129-131

Description

OPENCLAW_QA_ALLOW_INSECURE_HTTP=1 is intended to permit http:// only for loopback development, but the check relies on hostname.startsWith("127.").

Hostnames that merely begin with 127. (e.g. 127.0.0.1.nip.io) will be treated as loopback even though they can resolve to non-loopback addresses, enabling cleartext HTTP requests for admin operations (add/remove/list) to be sent to a remote attacker-controlled host if the opt-in is set.

Vulnerable code:

function isLoopbackHostname(hostname: string) {
  return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
}

Recommendation

Restrict loopback allowance to IP literals (and exact localhost), not arbitrary hostnames.

Example:

import { isIP } from "node:net";

function isLoopbackHost(url: URL) {
  const host = url.hostname;
  if (host === "localhost") return true;
  const v = isIP(host);
  if (v === 4) return host.startsWith("127.");
  if (v === 6) return host === "::1";
  return false;
}

Then use isLoopbackHost(parsed) when deciding whether http: is permitted.

8. 🔵 Authentication error responses disclose broker deployment configuration and token role matches
Property Value
Severity Low
CWE CWE-209
Location qa/convex-credential-broker/convex/http.ts:42-97

Description

The credential broker HTTP auth helpers return distinguishable HTTP status codes/messages for different authentication failure modes. This allows an unauthenticated remote caller to infer details about deployment configuration and (if a secret is already compromised) whether a presented token matches the CI secret vs. being completely invalid.

Impacts:

  • /qa-credentials/v1/admin/* reveals whether the maintainer secret is configured (500 SERVER_MISCONFIGURED), which is deployment configuration information.
  • The admin auth path returns 403 AUTH_ROLE_MISMATCH when a valid CI secret is supplied, and 401 AUTH_INVALID for other tokens, allowing a caller to distinguish “token is valid CI secret” from “token is invalid”.

Vulnerable code (examples):

if (!maintainerSecret) {
  throw new BrokerHttpError(
    500,
    "SERVER_MISCONFIGURED",
    "Admin endpoints require OPENCLAW_QA_CONVEX_SECRET_MAINTAINER on this deployment.",
  );
}
...
if (ciSecret && token === ciSecret) {
  throw new BrokerHttpError(403, "AUTH_ROLE_MISMATCH", "Admin endpoints require maintainer credentials.");
}

Recommendation

Return a uniform error shape/status for authentication/authorization failures to reduce information disclosure.

Suggested approach:

  • For admin endpoints, always return 401 (or always 403) for invalid/insufficient credentials, and avoid indicating whether the deployment is misconfigured or which role a valid token belongs to.
  • Log detailed reasons server-side only.

Example hardening:

function assertMaintainerAdminAuth(token: string | null) {
  const maintainerSecret = process.env.OPENCLAW_QA_CONVEX_SECRET_MAINTAINER?.trim();
  const ciSecret = process.env.OPENCLAW_QA_CONVEX_SECRET_CI?.trim();// Log config problems internally, but do not reveal to caller
  if (!maintainerSecret) {
    console.error("Missing maintainer secret");
    throw new BrokerHttpError(401, "AUTH_INVALID", "Invalid credentials.");
  }

  if (!token || token !== maintainerSecret) {// do not distinguish CI secret vs invalid token
    throw new BrokerHttpError(401, "AUTH_INVALID", "Invalid credentials.");
  }
}

Analyzed PR: #65596 at commit 55f8295

Last updated on: 2026-04-13T03:06:02Z

@openclaw-barnacle openclaw-barnacle bot added docs Improvements or additions to documentation extensions: qa-lab size: XL maintainer Maintainer-authored PR labels Apr 12, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 12, 2026

Greptile Summary

Adds a Convex-backed credential lease broker for the QA Telegram lane, a standalone Convex scaffold (qa/convex-credential-broker/), and maintainer CLI admin commands (openclaw qa credentials add|list|remove). All remaining inline findings are P2 style notes.

Confidence Score: 5/5

Safe to merge; all findings are P2 style suggestions with no blocking correctness or security issues.

Lease lifecycle mutations are correctly serialized by Convex's OCC, preventing TOCTOU races. Role-scoped auth, idempotent release semantics, and leaseToken redaction from list responses are all correct. The three findings are dead code cleanup, a theoretical timing-attack surface on an internal tool, and a missing .gitignore entry — none affect correctness or security in practice.

No files require special attention for correctness. qa/convex-credential-broker/convex/http.ts has the timing-safe comparison note worth a quick look before production hardening.

Security Review

  • Bearer token comparison in qa/convex-credential-broker/convex/http.ts uses JavaScript string === rather than a timing-safe primitive (crypto.subtle.timingSafeEqual). Risk is low for an internal QA tool, but worth correcting before wider deployment.
  • leaseToken is correctly excluded from all admin list/summary responses — only returned at acquisition time.
  • Admin endpoints require OPENCLAW_QA_CONVEX_SECRET_MAINTAINER; shared and CI tokens are explicitly rejected with a 403, preventing privilege escalation through the shared secret.
  • Credential payloads are redacted by default from list responses (includePayload must be explicitly set to true), limiting accidental secret exposure.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts
Line: 353-358

Comment:
**Redundant broker-error check after `postConvexBroker`**

`postConvexBroker` already parses the response body for `{ status: "error" }` and throws a `QaCredentialBrokerError` before returning. If the call returns without throwing, the payload is guaranteed to not be a broker error response, so the second `toBrokerError` check here can never match and is dead code.

```suggestion
      const acquired = convexAcquireSuccessSchema.parse(payload);
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: qa/convex-credential-broker/convex/http.ts
Line: 62-71

Comment:
**Timing-unsafe bearer-token comparison**

All three `token === secret` comparisons use JavaScript's native string equality, which can leak secret length via timing. `crypto.timingSafeEqual` (available in the Convex V8 environment via the Web Crypto API) is the conventional defense. For an internal QA tool the risk is low, but the pattern is worth correcting before broader deployment.

where `timingSafeEqual` wraps `crypto.subtle.timingSafeEqual` on equal-length buffers (or returns `false` on length mismatch without leaking which side is shorter).

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: qa/convex-credential-broker/.gitignore
Line: 1-4

Comment:
**`node_modules` not excluded in broker-local `.gitignore`**

This package is a standalone Convex project (not a pnpm workspace member), so running `npm install` or `pnpm install` here will produce a local `node_modules` directory. The root `.gitignore` likely covers this globally, but being explicit in the broker's own `.gitignore` makes the standalone nature clearer and prevents accidental commits if the root glob ever changes.

```suggestion
.convex
convex/_generated
node_modules

.env.local
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "QA Lab: add Convex credential admin CLI" | Re-trigger Greptile

Comment on lines +353 to +358
const brokerError = toBrokerError({
payload,
fallback: `Convex credential acquire failed for kind "${opts.kind}".`,
});
if (brokerError) {
throw brokerError;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant broker-error check after postConvexBroker

postConvexBroker already parses the response body for { status: "error" } and throws a QaCredentialBrokerError before returning. If the call returns without throwing, the payload is guaranteed to not be a broker error response, so the second toBrokerError check here can never match and is dead code.

Suggested change
const brokerError = toBrokerError({
payload,
fallback: `Convex credential acquire failed for kind "${opts.kind}".`,
});
if (brokerError) {
throw brokerError;
const acquired = convexAcquireSuccessSchema.parse(payload);
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts
Line: 353-358

Comment:
**Redundant broker-error check after `postConvexBroker`**

`postConvexBroker` already parses the response body for `{ status: "error" }` and throws a `QaCredentialBrokerError` before returning. If the call returns without throwing, the payload is guaranteed to not be a broker error response, so the second `toBrokerError` check here can never match and is dead code.

```suggestion
      const acquired = convexAcquireSuccessSchema.parse(payload);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +62 to +71
if (maintainerSecret && token === maintainerSecret) {
return "maintainer";
}
if (ciSecret && token === ciSecret) {
return "ci";
}
if (sharedSecret && token === sharedSecret) {
return "shared";
}
throw new BrokerHttpError(401, "AUTH_INVALID", "Credential broker shared secret is invalid.");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Timing-unsafe bearer-token comparison

All three token === secret comparisons use JavaScript's native string equality, which can leak secret length via timing. crypto.timingSafeEqual (available in the Convex V8 environment via the Web Crypto API) is the conventional defense. For an internal QA tool the risk is low, but the pattern is worth correcting before broader deployment.

where timingSafeEqual wraps crypto.subtle.timingSafeEqual on equal-length buffers (or returns false on length mismatch without leaking which side is shorter).

Prompt To Fix With AI
This is a comment left during a code review.
Path: qa/convex-credential-broker/convex/http.ts
Line: 62-71

Comment:
**Timing-unsafe bearer-token comparison**

All three `token === secret` comparisons use JavaScript's native string equality, which can leak secret length via timing. `crypto.timingSafeEqual` (available in the Convex V8 environment via the Web Crypto API) is the conventional defense. For an internal QA tool the risk is low, but the pattern is worth correcting before broader deployment.

where `timingSafeEqual` wraps `crypto.subtle.timingSafeEqual` on equal-length buffers (or returns `false` on length mismatch without leaking which side is shorter).

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +1 to +4
.convex
convex/_generated

.env.local
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 node_modules not excluded in broker-local .gitignore

This package is a standalone Convex project (not a pnpm workspace member), so running npm install or pnpm install here will produce a local node_modules directory. The root .gitignore likely covers this globally, but being explicit in the broker's own .gitignore makes the standalone nature clearer and prevents accidental commits if the root glob ever changes.

Suggested change
.convex
convex/_generated
.env.local
.convex
convex/_generated
node_modules
.env.local
Prompt To Fix With AI
This is a comment left during a code review.
Path: qa/convex-credential-broker/.gitignore
Line: 1-4

Comment:
**`node_modules` not excluded in broker-local `.gitignore`**

This package is a standalone Convex project (not a pnpm workspace member), so running `npm install` or `pnpm install` here will produce a local `node_modules` directory. The root `.gitignore` likely covers this globally, but being explicit in the broker's own `.gitignore` makes the standalone nature clearer and prevents accidental commits if the root glob ever changes.

```suggestion
.convex
convex/_generated
node_modules

.env.local
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4df3e25c90

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +360 to +362
const acquired = convexAcquireSuccessSchema.parse(payload);
const parsedPayload = opts.parsePayload(acquired.payload);
const leaseTtlMs = acquired.leaseTtlMs ?? config.leaseTtlMs;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Release acquired lease when payload validation fails

After a successful acquire response, the code parses acquired.payload before returning the lease object; if parsePayload throws (for example, a malformed Telegram credential row), execution jumps to the catch path and never calls the broker release endpoint. That leaves the credential locked until TTL expiry, and repeated attempts can exhaust the pool even though no run actually started. Add a best-effort release when validation fails after credentialId/leaseToken are available.

Useful? React with 👍 / 👎.

@joshavant joshavant force-pushed the feat/qa-lab-convex-credentials branch from 4df3e25 to 0edf663 Compare April 13, 2026 00:49
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0edf663cad

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if (!trimmed) {
return DEFAULT_ENDPOINT_PREFIX;
}
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forbid // endpoint prefixes before URL join

The endpoint-prefix normalization currently accepts values like //attacker.example, and joinConvexEndpoint later passes that into new URL(...); scheme-relative paths override the Convex host. In that case, acquire/heartbeat/release calls are redirected off-deployment while still sending the Authorization: Bearer ... secret, so a typo or poisoned env var can exfiltrate broker credentials. Reject endpoint prefixes that start with // (and ideally any absolute URL form) before building request URLs.

Useful? React with 👍 / 👎.


function normalizeCredentialId(raw: string) {
// Convex Ids are opaque strings. We only enforce non-empty shape at HTTP boundary.
return raw;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate credential IDs at the HTTP boundary

This helper returns any non-empty string as a credential id, then handlers cast it to Id<"credential_sets"> for runMutation. Malformed IDs (for example a typo from CLI) can fail Convex arg validation and fall into the generic catch path, which is returned as 500 INTERNAL_ERROR instead of a client 400 INVALID_BODY. That makes user input mistakes look like server failures on heartbeat/release/admin remove; add explicit shape validation here and throw BrokerHttpError(400, ...) for invalid ids.

Useful? React with 👍 / 👎.

@joshavant joshavant self-assigned this Apr 13, 2026
@blacksmith-sh
Copy link
Copy Markdown
Contributor

blacksmith-sh bot commented Apr 13, 2026

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
gateway-watch-regression invalid local run: dirty watched source tree would force a reb
uild inside the watch window/
gateway-watch-regression invalid local run: dirty watched source tree would force a reb
uild inside the watch window
View Logs

Fix in Cursor

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95ecbcdf80

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}

function isLoopbackHostname(hostname: string) {
return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forbid non-IP 127. hostnames for insecure HTTP opt-in*

With OPENCLAW_QA_ALLOW_INSECURE_HTTP=1, this check accepts any hostname that starts with 127., including public DNS names like 127.attacker.example. That breaks the intended “loopback-only” guard and can send Convex broker Bearer secrets to a non-local host over plaintext HTTP if OPENCLAW_QA_CONVEX_SITE_URL is mistyped or poisoned. The check should validate true loopback literals (localhost, 127.0.0.0/8 IPs, and IPv6 loopback) instead of a string prefix.

Useful? React with 👍 / 👎.

@joshavant joshavant force-pushed the feat/qa-lab-convex-credentials branch from 95ecbcd to 55f8295 Compare April 13, 2026 03:03
@joshavant joshavant merged commit 3d07dfb into main Apr 13, 2026
@joshavant joshavant deleted the feat/qa-lab-convex-credentials branch April 13, 2026 03:03
@joshavant
Copy link
Copy Markdown
Contributor Author

Landed via temp rebase onto main.

  • Gate: pnpm lint && pnpm build && pnpm test
  • Land commit: 55f8295
  • Merge commit: 3d07dfb

Thanks @joshavant!

@joshavant
Copy link
Copy Markdown
Contributor Author

Follow-up gate note: the requested full gate command was executed, but two lanes currently fail on latest main outside this PR scope:

  • lint: existing redundant-type-constituents errors
  • test: existing (codex) and failures

openclaw@2026.4.12-beta.1 build /Users/joshavant/Developer/openclaw-5
node scripts/build-all.mjs

openclaw@2026.4.12-beta.1 canvas:a2ui:bundle /Users/joshavant/Developer/openclaw-5
node scripts/bundle-a2ui.mjs

/a2ui.bundle.js chunk │ size: 503.60 kB

✔ rolldown v1.0.0-rc.12 Finished in 39.04 ms

openclaw@2026.4.12-beta.1 build:plugin-sdk:dts /Users/joshavant/Developer/openclaw-5
tsc -p tsconfig.plugin-sdk.dts.json

OK: All 4 required plugin-sdk exports verified.
[copy-hook-metadata] Copied 4 hook metadata files.
[copy-export-html-templates] Copied 5 export-html assets. passed.

@joshavant
Copy link
Copy Markdown
Contributor Author

Correction (safe quoting):

The requested full gate command was executed, but two lanes currently fail on latest main outside this PR scope:

  • lint: existing extensions/codex/* redundant-type-constituents errors
  • test: existing test/extension-package-tsc-boundary.test.ts (codex) and extensions/acpx/src/runtime.test.ts failures

pnpm build passed.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

const scenarios = findScenario(params.scenarioIds);

P1 Badge Ensure lease release runs for preflight failures

The lease is acquired before preflight validation (normalizeQaProviderMode, findScenario) but the try/finally that stops heartbeat and releases the lease starts later, so any error in this setup window exits without releasing the Convex lease. A simple bad scenario id (unknown Telegram QA scenario) can therefore leave credentials locked until TTL expiry and gradually exhaust the shared pool.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}

function isLoopbackHostname(hostname: string) {
return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject non-literal 127. hosts for insecure admin HTTP*

The loopback check treats any hostname beginning with 127. as local, so values like http://127.attacker.example pass when OPENCLAW_QA_ALLOW_INSECURE_HTTP=1. In that configuration, admin commands can POST the maintainer bearer secret to an external host over plaintext HTTP if the site URL is mistyped or poisoned. Restrict this to true loopback literals (localhost, IPv4 127/8 addresses, and IPv6 loopback literal).

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation extensions: qa-lab maintainer Maintainer-authored PR size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant