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

* QA Lab: add Convex credential source for Telegram lane

* QA Lab: scaffold Convex credential broker

* QA Lab: add Convex credential admin CLI

* QA Lab: harden Convex credential security paths

* QA Broker: validate Telegram payloads on admin add

* fix: note QA Convex credential broker in changelog (#65596) (thanks @joshavant)
This commit is contained in:
Josh Avant
2026-04-12 22:03:42 -05:00
committed by GitHub
parent 5da237c887
commit 3d07dfbb65
25 changed files with 3678 additions and 125 deletions

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
buildQaAgenticParityComparison,
renderQaAgenticParityMarkdownReport,
@@ -15,6 +16,13 @@ import { startQaLabServer } from "./lab-server.js";
import { runQaManualLane } from "./manual-lane.runtime.js";
import { startQaMockOpenAiServer } from "./mock-openai-server.js";
import { runQaMultipass } from "./multipass.runtime.js";
import {
addQaCredentialSet,
listQaCredentialSets,
QaCredentialAdminError,
removeQaCredentialSet,
type QaCredentialRecord,
} from "./qa-credentials-admin.runtime.js";
import { normalizeQaThinkingLevel, type QaThinkingLevel } from "./qa-gateway-config.js";
import { normalizeQaTransportId } from "./qa-transport-registry.js";
import {
@@ -117,6 +125,46 @@ function parseQaCliBackendAuthMode(value: string | undefined): QaCliBackendAuthM
throw new Error("--cli-auth-mode must be one of auto, api-key, subscription");
}
function parseQaCredentialListStatus(value: string | undefined) {
if (value === undefined) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (normalized === "active" || normalized === "disabled" || normalized === "all") {
return normalized;
}
throw new Error('--status must be one of "active", "disabled", or "all".');
}
function normalizeQaCredentialAdminError(error: unknown) {
if (error instanceof QaCredentialAdminError) {
return {
code: error.code,
message: error.message,
};
}
return {
code: "UNEXPECTED_ERROR",
message: formatErrorMessage(error),
};
}
function writeQaCredentialCommandErrorJson(action: string, error: unknown) {
const normalized = normalizeQaCredentialAdminError(error);
process.stdout.write(
`${JSON.stringify(
{
status: "error",
action,
code: normalized.code,
message: normalized.message,
},
null,
2,
)}\n`,
);
}
function parseQaModelSpecs(label: string, entries: readonly string[] | undefined) {
const models: string[] = [];
const optionsByModel: Record<string, QaCharacterModelOptions> = {};
@@ -198,6 +246,55 @@ async function runInterruptibleServer(label: string, server: InterruptibleServer
await new Promise(() => undefined);
}
async function readQaCredentialPayloadFile(filePath: string) {
const text = await fs.readFile(filePath, "utf8");
let payload: unknown;
try {
payload = JSON.parse(text) as unknown;
} catch (error) {
throw new Error(`Payload file must contain valid JSON: ${formatErrorMessage(error)}`, {
cause: error,
});
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("Payload file JSON must be an object.");
}
return payload as Record<string, unknown>;
}
function formatQaCredentialLeaseState(credential: QaCredentialRecord) {
if (!credential.lease) {
return "no";
}
return `yes(${credential.lease.actorRole}:${credential.lease.ownerId})`;
}
function printQaCredentialListTable(credentials: QaCredentialRecord[]) {
if (credentials.length === 0) {
process.stdout.write("No credentials matched.\n");
return;
}
const rows = credentials.map((credential) => ({
credentialId: credential.credentialId,
kind: credential.kind,
status: credential.status,
leased: formatQaCredentialLeaseState(credential),
note: credential.note ?? "",
}));
const idWidth = Math.max("credentialId".length, ...rows.map((row) => row.credentialId.length));
const kindWidth = Math.max("kind".length, ...rows.map((row) => row.kind.length));
const statusWidth = Math.max("status".length, ...rows.map((row) => row.status.length));
const leaseWidth = Math.max("leased".length, ...rows.map((row) => row.leased.length));
process.stdout.write(
`${"credentialId".padEnd(idWidth)} ${"kind".padEnd(kindWidth)} ${"status".padEnd(statusWidth)} ${"leased".padEnd(leaseWidth)} note\n`,
);
for (const row of rows) {
process.stdout.write(
`${row.credentialId.padEnd(idWidth)} ${row.kind.padEnd(kindWidth)} ${row.status.padEnd(statusWidth)} ${row.leased.padEnd(leaseWidth)} ${row.note}\n`,
);
}
}
export async function runQaLabSelfCheckCommand(opts: { repoRoot?: string; output?: string }) {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const server = await startQaLabServer({
@@ -411,6 +508,148 @@ export async function runQaManualLaneCommand(opts: {
process.stdout.write("\n");
}
export async function runQaCredentialsAddCommand(opts: {
actorId?: string;
endpointPrefix?: string;
json?: boolean;
kind: string;
note?: string;
payloadFile: string;
repoRoot?: string;
siteUrl?: string;
}) {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
try {
const payloadPath = path.resolve(repoRoot, opts.payloadFile);
const payload = await readQaCredentialPayloadFile(payloadPath);
const result = await addQaCredentialSet({
kind: opts.kind,
payload,
note: opts.note,
actorId: opts.actorId,
siteUrl: opts.siteUrl,
endpointPrefix: opts.endpointPrefix,
});
if (opts.json) {
process.stdout.write(
`${JSON.stringify({ status: "ok", action: "add", credential: result.credential }, null, 2)}\n`,
);
return;
}
process.stdout.write(`QA credential added: ${result.credential.credentialId}\n`);
process.stdout.write(`Kind: ${result.credential.kind}\n`);
process.stdout.write(`Status: ${result.credential.status}\n`);
if (result.credential.note) {
process.stdout.write(`Note: ${result.credential.note}\n`);
}
} catch (error) {
if (opts.json) {
writeQaCredentialCommandErrorJson("add", error);
process.exitCode = 1;
return;
}
throw error;
}
}
export async function runQaCredentialsRemoveCommand(opts: {
actorId?: string;
credentialId: string;
endpointPrefix?: string;
json?: boolean;
siteUrl?: string;
}) {
try {
const result = await removeQaCredentialSet({
credentialId: opts.credentialId,
actorId: opts.actorId,
siteUrl: opts.siteUrl,
endpointPrefix: opts.endpointPrefix,
});
if (opts.json) {
process.stdout.write(
`${JSON.stringify(
{
status: "ok",
action: "remove",
changed: result.changed,
credential: result.credential,
},
null,
2,
)}\n`,
);
return;
}
process.stdout.write(
result.changed
? `QA credential removed (disabled): ${result.credential.credentialId}\n`
: `QA credential already disabled: ${result.credential.credentialId}\n`,
);
} catch (error) {
if (opts.json) {
writeQaCredentialCommandErrorJson("remove", error);
process.exitCode = 1;
return;
}
throw error;
}
}
export async function runQaCredentialsListCommand(opts: {
actorId?: string;
endpointPrefix?: string;
json?: boolean;
kind?: string;
limit?: number;
showSecrets?: boolean;
siteUrl?: string;
status?: string;
}) {
try {
const result = await listQaCredentialSets({
actorId: opts.actorId,
siteUrl: opts.siteUrl,
endpointPrefix: opts.endpointPrefix,
kind: opts.kind?.trim(),
status: parseQaCredentialListStatus(opts.status),
includePayload: opts.showSecrets,
limit: parseQaPositiveIntegerOption("--limit", opts.limit),
});
if (opts.json) {
process.stdout.write(
`${JSON.stringify(
{
status: "ok",
action: "list",
count: result.credentials.length,
credentials: result.credentials,
},
null,
2,
)}\n`,
);
return;
}
printQaCredentialListTable(result.credentials);
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`,
);
}
}
} catch (error) {
if (opts.json) {
writeQaCredentialCommandErrorJson("list", error);
process.exitCode = 1;
return;
}
throw error;
}
}
export async function runQaLabUiCommand(opts: {
repoRoot?: string;
host?: string;

View File

@@ -1,7 +1,16 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { runQaMatrixCommand, runQaTelegramCommand } = vi.hoisted(() => ({
const {
runQaCredentialsAddCommand,
runQaCredentialsListCommand,
runQaCredentialsRemoveCommand,
runQaMatrixCommand,
runQaTelegramCommand,
} = vi.hoisted(() => ({
runQaCredentialsAddCommand: vi.fn(),
runQaCredentialsListCommand: vi.fn(),
runQaCredentialsRemoveCommand: vi.fn(),
runQaMatrixCommand: vi.fn(),
runQaTelegramCommand: vi.fn(),
}));
@@ -14,6 +23,12 @@ vi.mock("./live-transports/telegram/cli.runtime.js", () => ({
runQaTelegramCommand,
}));
vi.mock("./cli.runtime.js", () => ({
runQaCredentialsAddCommand,
runQaCredentialsListCommand,
runQaCredentialsRemoveCommand,
}));
import { registerQaLabCli } from "./cli.js";
describe("qa cli registration", () => {
@@ -22,6 +37,9 @@ describe("qa cli registration", () => {
beforeEach(() => {
program = new Command();
registerQaLabCli(program);
runQaCredentialsAddCommand.mockReset();
runQaCredentialsListCommand.mockReset();
runQaCredentialsRemoveCommand.mockReset();
runQaMatrixCommand.mockReset();
runQaTelegramCommand.mockReset();
});
@@ -34,7 +52,7 @@ describe("qa cli registration", () => {
const qa = program.commands.find((command) => command.name() === "qa");
expect(qa).toBeDefined();
expect(qa?.commands.map((command) => command.name())).toEqual(
expect.arrayContaining(["matrix", "telegram"]),
expect.arrayContaining(["matrix", "telegram", "credentials"]),
);
});
@@ -72,6 +90,8 @@ describe("qa cli registration", () => {
fastMode: true,
scenarioIds: ["matrix-thread-follow-up", "matrix-thread-isolation"],
sutAccountId: "sut-live",
credentialSource: undefined,
credentialRole: undefined,
});
});
@@ -87,6 +107,92 @@ describe("qa cli registration", () => {
fastMode: false,
scenarioIds: [],
sutAccountId: "sut",
credentialSource: undefined,
credentialRole: undefined,
});
});
it("routes credential add flags into the qa runtime command", async () => {
await program.parseAsync([
"node",
"openclaw",
"qa",
"credentials",
"add",
"--kind",
"telegram",
"--payload-file",
"qa/payload.json",
"--repo-root",
"/tmp/openclaw-repo",
"--note",
"shared lane",
"--site-url",
"https://first-schnauzer-821.convex.site",
"--endpoint-prefix",
"/qa-credentials/v1",
"--actor-id",
"maintainer-local",
"--json",
]);
expect(runQaCredentialsAddCommand).toHaveBeenCalledWith({
kind: "telegram",
payloadFile: "qa/payload.json",
repoRoot: "/tmp/openclaw-repo",
note: "shared lane",
siteUrl: "https://first-schnauzer-821.convex.site",
endpointPrefix: "/qa-credentials/v1",
actorId: "maintainer-local",
json: true,
});
});
it("routes credential remove flags into the qa runtime command", async () => {
await program.parseAsync([
"node",
"openclaw",
"qa",
"credentials",
"remove",
"--credential-id",
"j57b8k419ba7bcsfw99rg05c9184p8br",
"--site-url",
"https://first-schnauzer-821.convex.site",
"--actor-id",
"maintainer-local",
"--json",
]);
expect(runQaCredentialsRemoveCommand).toHaveBeenCalledWith({
credentialId: "j57b8k419ba7bcsfw99rg05c9184p8br",
siteUrl: "https://first-schnauzer-821.convex.site",
actorId: "maintainer-local",
endpointPrefix: undefined,
json: true,
});
});
it("routes credential list defaults into the qa runtime command", async () => {
await program.parseAsync([
"node",
"openclaw",
"qa",
"credentials",
"list",
"--kind",
"telegram",
]);
expect(runQaCredentialsListCommand).toHaveBeenCalledWith({
kind: "telegram",
status: "all",
limit: undefined,
showSecrets: false,
siteUrl: undefined,
endpointPrefix: undefined,
actorId: undefined,
json: false,
});
});
});

View File

@@ -83,6 +83,45 @@ async function runQaManualLane(opts: {
await runtime.runQaManualLaneCommand(opts);
}
async function runQaCredentialsAdd(opts: {
actorId?: string;
endpointPrefix?: string;
json?: boolean;
kind: string;
note?: string;
payloadFile: string;
repoRoot?: string;
siteUrl?: string;
}) {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaCredentialsAddCommand(opts);
}
async function runQaCredentialsRemove(opts: {
actorId?: string;
credentialId: string;
endpointPrefix?: string;
json?: boolean;
siteUrl?: string;
}) {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaCredentialsRemoveCommand(opts);
}
async function runQaCredentialsList(opts: {
actorId?: string;
endpointPrefix?: string;
json?: boolean;
kind?: string;
limit?: number;
showSecrets?: boolean;
siteUrl?: string;
status?: string;
}) {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaCredentialsListCommand(opts);
}
async function runQaUi(opts: {
repoRoot?: string;
host?: string;
@@ -347,6 +386,82 @@ export function registerQaLabCli(program: Command) {
},
);
const credentials = qa
.command("credentials")
.description("Manage pooled Convex live credentials used by QA lanes");
credentials
.command("add")
.description("Add one credential payload to the shared pool")
.requiredOption("--kind <kind>", "Credential kind (for Telegram v1, use telegram)")
.requiredOption("--payload-file <path>", "JSON object file containing the credential payload")
.option("--repo-root <path>", "Repository root for resolving relative payload-file paths")
.option("--note <text>", "Optional note stored with this credential row")
.option("--site-url <url>", "Override OPENCLAW_QA_CONVEX_SITE_URL")
.option("--endpoint-prefix <path>", "Override OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX")
.option("--actor-id <id>", "Optional admin actor id to include in broker audit events")
.option("--json", "Emit machine-readable JSON output", false)
.action(
async (opts: {
kind: string;
payloadFile: string;
repoRoot?: string;
note?: string;
siteUrl?: string;
endpointPrefix?: string;
actorId?: string;
json?: boolean;
}) => {
await runQaCredentialsAdd(opts);
},
);
credentials
.command("remove")
.description("Remove one credential from active use by disabling it")
.requiredOption("--credential-id <id>", "Credential row id from the Convex pool")
.option("--site-url <url>", "Override OPENCLAW_QA_CONVEX_SITE_URL")
.option("--endpoint-prefix <path>", "Override OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX")
.option("--actor-id <id>", "Optional admin actor id to include in broker audit events")
.option("--json", "Emit machine-readable JSON output", false)
.action(
async (opts: {
credentialId: string;
siteUrl?: string;
endpointPrefix?: string;
actorId?: string;
json?: boolean;
}) => {
await runQaCredentialsRemove(opts);
},
);
credentials
.command("list")
.description("List credential rows in the shared Convex pool")
.option("--kind <kind>", "Filter by credential kind")
.option("--status <status>", 'Filter by row status: "active", "disabled", or "all"', "all")
.option("--limit <count>", "Max rows to return", (value: string) => Number(value))
.option("--show-secrets", "Include credential payload JSON in output", false)
.option("--site-url <url>", "Override OPENCLAW_QA_CONVEX_SITE_URL")
.option("--endpoint-prefix <path>", "Override OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX")
.option("--actor-id <id>", "Optional admin actor id to include in broker audit events")
.option("--json", "Emit machine-readable JSON output", false)
.action(
async (opts: {
kind?: string;
status?: string;
limit?: number;
showSecrets?: boolean;
siteUrl?: string;
endpointPrefix?: string;
actorId?: string;
json?: boolean;
}) => {
await runQaCredentialsList(opts);
},
);
qa.command("ui")
.description("Start the private QA debugger UI and local QA bus")
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
const runMatrixQaLive = vi.hoisted(() => vi.fn());
vi.mock("./matrix-live.runtime.js", () => ({
runMatrixQaLive,
}));
import { runQaMatrixCommand } from "./cli.runtime.js";
describe("matrix qa cli runtime", () => {
it("rejects non-env credential sources for the disposable Matrix lane", async () => {
await expect(
runQaMatrixCommand({
credentialSource: "convex",
}),
).rejects.toThrow("Matrix QA currently supports only --credential-source env");
});
it("passes through default env credential source options", async () => {
runMatrixQaLive.mockResolvedValue({
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
});
await runQaMatrixCommand({
repoRoot: "/tmp/openclaw",
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
});
expect(runMatrixQaLive).toHaveBeenCalledWith(
expect.objectContaining({
repoRoot: "/tmp/openclaw",
outputDir: "/tmp/openclaw/.artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
}),
);
});
});

View File

@@ -6,7 +6,15 @@ import {
import { runMatrixQaLive } from "./matrix-live.runtime.js";
export async function runQaMatrixCommand(opts: LiveTransportQaCommandOptions) {
const result = await runMatrixQaLive(resolveLiveTransportQaRunOptions(opts));
const runOptions = resolveLiveTransportQaRunOptions(opts);
const credentialSource = runOptions.credentialSource?.toLowerCase();
if (credentialSource && credentialSource !== "env") {
throw new Error(
"Matrix QA currently supports only --credential-source env (disposable local harness).",
);
}
const result = await runMatrixQaLive(runOptions);
printLiveTransportQaArtifacts("Matrix QA", {
report: result.reportPath,
summary: result.summaryPath,

View File

@@ -0,0 +1,272 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
acquireQaCredentialLease,
startQaCredentialLeaseHeartbeat,
} from "./credential-lease.runtime.js";
function jsonResponse(payload: unknown, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
describe("credential lease runtime", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("uses env credentials by default", async () => {
const lease = await acquireQaCredentialLease({
kind: "telegram",
resolveEnvPayload: () => ({ groupId: "-100123", driverToken: "driver", sutToken: "sut" }),
parsePayload: () => {
throw new Error("should not parse convex payload in env mode");
},
env: {},
});
expect(lease.source).toBe("env");
expect(lease.payload).toEqual({
groupId: "-100123",
driverToken: "driver",
sutToken: "sut",
});
});
it("acquires, heartbeats, and releases convex credentials", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
status: "ok",
credentialId: "cred-1",
leaseToken: "lease-1",
payload: { groupId: "-100123", driverToken: "driver", sutToken: "sut" },
leaseTtlMs: 1_200_000,
heartbeatIntervalMs: 30_000,
}),
)
.mockResolvedValueOnce(jsonResponse({ status: "ok" }))
.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
const lease = await acquireQaCredentialLease({
kind: "telegram",
source: "convex",
role: "maintainer",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maintainer-secret",
},
fetchImpl,
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
});
expect(lease.source).toBe("convex");
expect(lease.credentialId).toBe("cred-1");
expect(lease.payload.groupId).toBe("-100123");
await lease.heartbeat();
await lease.release();
expect(fetchImpl).toHaveBeenCalledTimes(3);
const firstCall = fetchImpl.mock.calls[0];
expect(firstCall?.[0]).toContain("/qa-credentials/v1/acquire");
const firstInit = firstCall?.[1];
const headers = firstInit?.headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer maintainer-secret");
});
it("retries convex acquire while the pool is exhausted", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
status: "error",
code: "POOL_EXHAUSTED",
message: "wait",
}),
)
.mockResolvedValueOnce(
jsonResponse({
status: "error",
code: "POOL_EXHAUSTED",
message: "wait",
}),
)
.mockResolvedValueOnce(
jsonResponse({
status: "ok",
credentialId: "cred-2",
leaseToken: "lease-2",
payload: { groupId: "-100456", driverToken: "driver-2", sutToken: "sut-2" },
}),
);
const sleeps: number[] = [];
let nowMs = 0;
const lease = await acquireQaCredentialLease({
kind: "telegram",
source: "convex",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maintainer-secret",
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "90000",
},
fetchImpl,
randomImpl: () => 0,
timeImpl: () => nowMs,
sleepImpl: async (ms) => {
sleeps.push(ms);
nowMs += ms;
},
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
});
expect(lease.credentialId).toBe("cred-2");
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(sleeps.length).toBe(2);
expect(sleeps[0]).toBeGreaterThanOrEqual(100);
expect(sleeps[1]).toBeGreaterThan(sleeps[0] ?? 0);
});
it("rejects non-https convex site URLs unless local insecure opt-in is enabled", async () => {
await expect(
acquireQaCredentialLease({
kind: "telegram",
source: "convex",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "http://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maintainer-secret",
},
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
}),
).rejects.toThrow("must use https://");
});
it("allows loopback http URLs when OPENCLAW_QA_ALLOW_INSECURE_HTTP is enabled", async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValueOnce(
jsonResponse({
status: "ok",
credentialId: "cred-local",
leaseToken: "lease-local",
payload: { groupId: "-100123", driverToken: "driver", sutToken: "sut" },
}),
);
await acquireQaCredentialLease({
kind: "telegram",
source: "convex",
role: "maintainer",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "http://127.0.0.1:3210",
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maintainer-secret",
OPENCLAW_QA_ALLOW_INSECURE_HTTP: "1",
},
fetchImpl,
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
});
const firstCall = fetchImpl.mock.calls[0];
expect(firstCall?.[0]).toBe("http://127.0.0.1:3210/qa-credentials/v1/acquire");
});
it("rejects unsafe endpoint prefix overrides", async () => {
await expect(
acquireQaCredentialLease({
kind: "telegram",
source: "convex",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maintainer-secret",
OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX: "//evil.example",
},
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
}),
).rejects.toThrow("OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX must be an absolute path");
});
it("releases acquired lease when payload parsing fails", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
status: "ok",
credentialId: "cred-parse-fail",
leaseToken: "lease-parse-fail",
payload: { broken: true },
}),
)
.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
await expect(
acquireQaCredentialLease({
kind: "telegram",
source: "convex",
role: "maintainer",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maintainer-secret",
},
fetchImpl,
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: () => {
throw new Error("bad payload shape");
},
}),
).rejects.toThrow("bad payload shape");
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl.mock.calls[1]?.[0]).toBe(
"https://qa-cred.example.convex.site/qa-credentials/v1/release",
);
});
it("fails convex mode when auth secret is missing", async () => {
await expect(
acquireQaCredentialLease({
kind: "telegram",
source: "convex",
role: "maintainer",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
},
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
}),
).rejects.toThrow("OPENCLAW_QA_CONVEX_SECRET_MAINTAINER");
});
it("captures heartbeat failures for fail-fast checks", async () => {
vi.useFakeTimers();
const heartbeat = startQaCredentialLeaseHeartbeat(
{
source: "convex",
kind: "telegram",
heartbeatIntervalMs: 50,
heartbeat: async () => {
throw new Error("heartbeat-down");
},
},
{ intervalMs: 50 },
);
await vi.advanceTimersByTimeAsync(55);
expect(heartbeat.getFailure()).toBeInstanceOf(Error);
expect(() => heartbeat.throwIfFailed()).toThrow("heartbeat-down");
await heartbeat.stop();
});
});

View File

@@ -0,0 +1,576 @@
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { z } from "zod";
const DEFAULT_ACQUIRE_TIMEOUT_MS = 90_000;
const DEFAULT_ENDPOINT_PREFIX = "/qa-credentials/v1";
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
const DEFAULT_HTTP_TIMEOUT_MS = 15_000;
const DEFAULT_LEASE_TTL_MS = 20 * 60 * 1_000;
const ALLOW_INSECURE_HTTP_ENV_KEY = "OPENCLAW_QA_ALLOW_INSECURE_HTTP";
const RETRY_BACKOFF_MS = [500, 1_000, 2_000, 4_000, 5_000] as const;
const RETRYABLE_ACQUIRE_CODES = new Set(["POOL_EXHAUSTED", "NO_CREDENTIAL_AVAILABLE"]);
const convexAcquireSuccessSchema = z.object({
status: z.literal("ok"),
credentialId: z.string().min(1),
leaseToken: z.string().min(1),
payload: z.unknown(),
leaseTtlMs: z.number().int().positive().optional(),
heartbeatIntervalMs: z.number().int().positive().optional(),
});
const convexErrorSchema = z.object({
status: z.literal("error"),
code: z.string().min(1),
message: z.string().optional(),
retryAfterMs: z.number().int().positive().optional(),
});
const convexOkSchema = z.object({
status: z.literal("ok"),
});
type ConvexCredentialBrokerConfig = {
acquireTimeoutMs: number;
acquireUrl: string;
authToken: string;
heartbeatIntervalMs: number;
heartbeatUrl: string;
httpTimeoutMs: number;
leaseTtlMs: number;
ownerId: string;
releaseUrl: string;
role: QaCredentialRole;
};
export type QaCredentialLeaseHeartbeat = {
getFailure(): Error | null;
stop(): Promise<void>;
throwIfFailed(): void;
};
export type QaCredentialRole = "ci" | "maintainer";
export type QaCredentialLeaseSource = "convex" | "env";
export type QaCredentialLease<TPayload> = {
credentialId?: string;
heartbeat(): Promise<void>;
heartbeatIntervalMs: number;
kind: string;
leaseToken?: string;
leaseTtlMs: number;
ownerId?: string;
payload: TPayload;
release(): Promise<void>;
role?: QaCredentialRole;
source: QaCredentialLeaseSource;
};
export type AcquireQaCredentialLeaseOptions<TPayload> = {
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
kind: string;
ownerId?: string;
parsePayload: (payload: unknown) => TPayload;
randomImpl?: () => number;
resolveEnvPayload: () => TPayload;
role?: string;
sleepImpl?: (ms: number) => Promise<unknown>;
source?: string;
timeImpl?: () => number;
};
class QaCredentialBrokerError extends Error {
code: string;
retryAfterMs?: number;
constructor(params: { code: string; message: string; retryAfterMs?: number }) {
super(params.message);
this.name = "QaCredentialBrokerError";
this.code = params.code;
this.retryAfterMs = params.retryAfterMs;
}
}
function parsePositiveIntegerEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
const raw = env[key]?.trim();
if (!raw) {
return fallback;
}
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
throw new Error(`${key} must be a positive integer.`);
}
return value;
}
function normalizeQaCredentialSource(value: string | undefined): QaCredentialLeaseSource {
const normalized = value?.trim().toLowerCase() || "env";
if (normalized === "env" || normalized === "convex") {
return normalized;
}
throw new Error(`Credential source must be one of env or convex, got "${value}".`);
}
function normalizeQaCredentialRole(value: string | undefined): QaCredentialRole {
const normalized = value?.trim().toLowerCase() || "maintainer";
if (normalized === "maintainer" || normalized === "ci") {
return normalized;
}
throw new Error(`Credential role must be one of maintainer or ci, got "${value}".`);
}
function isTruthyOptIn(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function isLoopbackHostname(hostname: string) {
return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
}
function normalizeConvexSiteUrl(raw: string, env: NodeJS.ProcessEnv): string {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error(`OPENCLAW_QA_CONVEX_SITE_URL must be a valid URL, got "${raw || "<empty>"}".`);
}
if (url.protocol === "https:") {
const text = url.toString();
return text.endsWith("/") ? text.slice(0, -1) : text;
}
if (url.protocol !== "http:") {
throw new Error("OPENCLAW_QA_CONVEX_SITE_URL must use https://.");
}
const allowInsecureHttp = isTruthyOptIn(env[ALLOW_INSECURE_HTTP_ENV_KEY]);
if (!allowInsecureHttp || !isLoopbackHostname(url.hostname)) {
throw new Error(
`OPENCLAW_QA_CONVEX_SITE_URL must use https://. http:// is only allowed for loopback hosts when ${ALLOW_INSECURE_HTTP_ENV_KEY}=1.`,
);
}
const text = url.toString();
return text.endsWith("/") ? text.slice(0, -1) : text;
}
function normalizeEndpointPrefix(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) {
return DEFAULT_ENDPOINT_PREFIX;
}
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
const normalized = prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
if (!normalized.startsWith("/") || normalized.startsWith("//")) {
throw new Error(
"OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX must be an absolute path like /qa-credentials/v1.",
);
}
if (normalized.includes("\\") || normalized.split("/").some((segment) => segment === "..")) {
throw new Error(
"OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX must not contain backslashes or .. path segments.",
);
}
return normalized;
}
function resolveConvexAuthToken(env: NodeJS.ProcessEnv, role: QaCredentialRole): string {
const roleToken =
role === "ci"
? env.OPENCLAW_QA_CONVEX_SECRET_CI?.trim()
: env.OPENCLAW_QA_CONVEX_SECRET_MAINTAINER?.trim();
const token = roleToken;
if (token) {
return token;
}
if (role === "ci") {
throw new Error("Missing OPENCLAW_QA_CONVEX_SECRET_CI for CI credential access.");
}
throw new Error("Missing OPENCLAW_QA_CONVEX_SECRET_MAINTAINER for maintainer credential access.");
}
function joinConvexEndpoint(baseUrl: string, prefix: string, suffix: string): string {
const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`;
const url = new URL(baseUrl);
url.pathname = `${prefix}${normalizedSuffix}`.replace(/\/{2,}/gu, "/");
url.search = "";
url.hash = "";
return url.toString();
}
function resolveConvexCredentialBrokerConfig(params: {
env: NodeJS.ProcessEnv;
ownerId?: string;
role: QaCredentialRole;
}): ConvexCredentialBrokerConfig {
const siteUrl = params.env.OPENCLAW_QA_CONVEX_SITE_URL?.trim();
if (!siteUrl) {
throw new Error("Missing OPENCLAW_QA_CONVEX_SITE_URL for --credential-source convex.");
}
const baseUrl = normalizeConvexSiteUrl(siteUrl, params.env);
const endpointPrefix = normalizeEndpointPrefix(params.env.OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX);
const ownerId =
params.ownerId?.trim() ||
params.env.OPENCLAW_QA_CREDENTIAL_OWNER_ID?.trim() ||
`qa-lab-${params.role}-${process.pid}-${randomUUID().slice(0, 8)}`;
return {
role: params.role,
ownerId,
authToken: resolveConvexAuthToken(params.env, params.role),
leaseTtlMs: parsePositiveIntegerEnv(
params.env,
"OPENCLAW_QA_CREDENTIAL_LEASE_TTL_MS",
DEFAULT_LEASE_TTL_MS,
),
heartbeatIntervalMs: parsePositiveIntegerEnv(
params.env,
"OPENCLAW_QA_CREDENTIAL_HEARTBEAT_INTERVAL_MS",
DEFAULT_HEARTBEAT_INTERVAL_MS,
),
acquireTimeoutMs: parsePositiveIntegerEnv(
params.env,
"OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS",
DEFAULT_ACQUIRE_TIMEOUT_MS,
),
httpTimeoutMs: parsePositiveIntegerEnv(
params.env,
"OPENCLAW_QA_CREDENTIAL_HTTP_TIMEOUT_MS",
DEFAULT_HTTP_TIMEOUT_MS,
),
acquireUrl: joinConvexEndpoint(baseUrl, endpointPrefix, "acquire"),
heartbeatUrl: joinConvexEndpoint(baseUrl, endpointPrefix, "heartbeat"),
releaseUrl: joinConvexEndpoint(baseUrl, endpointPrefix, "release"),
};
}
function toBrokerError(params: {
payload: unknown;
fallback: string;
}): QaCredentialBrokerError | null {
const parsed = convexErrorSchema.safeParse(params.payload);
if (!parsed.success) {
return null;
}
return new QaCredentialBrokerError({
code: parsed.data.code,
message: parsed.data.message?.trim() || params.fallback,
retryAfterMs: parsed.data.retryAfterMs,
});
}
async function postConvexBroker(params: {
authToken: string;
body: Record<string, unknown>;
fetchImpl: typeof fetch;
timeoutMs: number;
url: string;
}): Promise<unknown> {
const response = await params.fetchImpl(params.url, {
method: "POST",
headers: {
authorization: `Bearer ${params.authToken}`,
"content-type": "application/json",
},
body: JSON.stringify(params.body),
signal: AbortSignal.timeout(params.timeoutMs),
});
const text = await response.text();
const payload: unknown = (() => {
if (!text.trim()) {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
})();
const brokerError = toBrokerError({
payload,
fallback: `Convex credential broker request failed (${response.status}).`,
});
if (brokerError) {
throw brokerError;
}
if (!response.ok) {
throw new Error(
`Convex credential broker request to ${params.url} failed with HTTP ${response.status}.`,
);
}
return payload;
}
function computeAcquireBackoffMs(params: {
attempt: number;
randomImpl: () => number;
retryAfterMs?: number;
}): number {
if (params.retryAfterMs && params.retryAfterMs > 0) {
return params.retryAfterMs;
}
const base = RETRY_BACKOFF_MS[Math.min(RETRY_BACKOFF_MS.length - 1, params.attempt - 1)];
const jitter = 0.75 + params.randomImpl() * 0.5;
return Math.max(100, Math.round(base * jitter));
}
function assertConvexOk(payload: unknown, actionLabel: string) {
if (payload === undefined) {
return;
}
if (convexOkSchema.safeParse(payload).success) {
return;
}
const brokerError = toBrokerError({
payload,
fallback: `Convex credential ${actionLabel} failed.`,
});
if (brokerError) {
throw brokerError;
}
throw new Error(`Convex credential ${actionLabel} failed with an invalid response payload.`);
}
export async function acquireQaCredentialLease<TPayload>(
opts: AcquireQaCredentialLeaseOptions<TPayload>,
): Promise<QaCredentialLease<TPayload>> {
const env = opts.env ?? process.env;
const source = normalizeQaCredentialSource(opts.source ?? env.OPENCLAW_QA_CREDENTIAL_SOURCE);
if (source === "env") {
return {
source: "env",
kind: opts.kind,
payload: opts.resolveEnvPayload(),
heartbeatIntervalMs: 0,
leaseTtlMs: 0,
async heartbeat() {},
async release() {},
};
}
const role = normalizeQaCredentialRole(opts.role ?? env.OPENCLAW_QA_CREDENTIAL_ROLE);
const config = resolveConvexCredentialBrokerConfig({
env,
role,
ownerId: opts.ownerId,
});
const fetchImpl = opts.fetchImpl ?? fetch;
const sleepImpl =
opts.sleepImpl ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const timeImpl = opts.timeImpl ?? (() => Date.now());
const randomImpl = opts.randomImpl ?? (() => Math.random());
const startedAt = timeImpl();
let attempt = 0;
while (true) {
attempt += 1;
try {
const payload = await postConvexBroker({
fetchImpl,
timeoutMs: config.httpTimeoutMs,
authToken: config.authToken,
url: config.acquireUrl,
body: {
kind: opts.kind,
ownerId: config.ownerId,
actorRole: config.role,
leaseTtlMs: config.leaseTtlMs,
heartbeatIntervalMs: config.heartbeatIntervalMs,
},
});
const acquired = convexAcquireSuccessSchema.parse(payload);
const releaseLease = async () => {
const releasePayload = await postConvexBroker({
fetchImpl,
timeoutMs: config.httpTimeoutMs,
authToken: config.authToken,
url: config.releaseUrl,
body: {
kind: opts.kind,
ownerId: config.ownerId,
credentialId: acquired.credentialId,
leaseToken: acquired.leaseToken,
actorRole: config.role,
},
});
assertConvexOk(releasePayload, "release");
};
let parsedPayload: TPayload;
try {
parsedPayload = opts.parsePayload(acquired.payload);
} catch (error) {
try {
await releaseLease();
} catch (releaseError) {
throw new Error(
`Convex credential payload validation failed for kind "${opts.kind}" and cleanup release failed: ${formatErrorMessage(error)}; release failed: ${formatErrorMessage(releaseError)}`,
{ cause: releaseError },
);
}
throw new Error(
`Convex credential payload validation failed for kind "${opts.kind}": ${formatErrorMessage(error)}`,
{ cause: error },
);
}
const leaseTtlMs = acquired.leaseTtlMs ?? config.leaseTtlMs;
const heartbeatIntervalMs = acquired.heartbeatIntervalMs ?? config.heartbeatIntervalMs;
return {
source: "convex",
kind: opts.kind,
role,
ownerId: config.ownerId,
credentialId: acquired.credentialId,
leaseToken: acquired.leaseToken,
leaseTtlMs,
heartbeatIntervalMs,
payload: parsedPayload,
async heartbeat() {
const heartbeatPayload = await postConvexBroker({
fetchImpl,
timeoutMs: config.httpTimeoutMs,
authToken: config.authToken,
url: config.heartbeatUrl,
body: {
kind: opts.kind,
ownerId: config.ownerId,
credentialId: acquired.credentialId,
leaseToken: acquired.leaseToken,
actorRole: config.role,
leaseTtlMs,
},
});
assertConvexOk(heartbeatPayload, "heartbeat");
},
async release() {
await releaseLease();
},
};
} catch (error) {
if (error instanceof QaCredentialBrokerError && RETRYABLE_ACQUIRE_CODES.has(error.code)) {
const elapsed = timeImpl() - startedAt;
if (elapsed >= config.acquireTimeoutMs) {
throw new Error(
`Convex credential pool exhausted for kind "${opts.kind}" after ${config.acquireTimeoutMs}ms.`,
{ cause: error },
);
}
const delayMs = Math.min(
computeAcquireBackoffMs({
attempt,
retryAfterMs: error.retryAfterMs,
randomImpl,
}),
Math.max(0, config.acquireTimeoutMs - elapsed),
);
if (delayMs > 0) {
await sleepImpl(delayMs);
}
continue;
}
if (error instanceof z.ZodError) {
throw new Error(
`Convex credential acquire response did not match the expected payload for kind "${opts.kind}": ${error.message}`,
{ cause: error },
);
}
throw new Error(
`Convex credential acquire failed for kind "${opts.kind}": ${formatErrorMessage(error)}`,
{ cause: error },
);
}
}
}
export function startQaCredentialLeaseHeartbeat(
lease: Pick<QaCredentialLease<unknown>, "heartbeat" | "heartbeatIntervalMs" | "kind" | "source">,
opts?: {
intervalMs?: number;
setTimeoutImpl?: typeof setTimeout;
clearTimeoutImpl?: typeof clearTimeout;
},
): QaCredentialLeaseHeartbeat {
if (lease.source !== "convex") {
return {
getFailure: () => null,
async stop() {},
throwIfFailed() {},
};
}
const intervalMs = opts?.intervalMs ?? lease.heartbeatIntervalMs;
if (!Number.isFinite(intervalMs) || intervalMs < 1) {
return {
getFailure: () => null,
async stop() {},
throwIfFailed() {},
};
}
const setTimeoutImpl = opts?.setTimeoutImpl ?? setTimeout;
const clearTimeoutImpl = opts?.clearTimeoutImpl ?? clearTimeout;
let failure: Error | null = null;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let inFlight: Promise<void> | null = null;
const schedule = () => {
if (stopped || failure) {
return;
}
timer = setTimeoutImpl(() => {
timer = null;
if (stopped || failure) {
return;
}
inFlight = (async () => {
try {
await lease.heartbeat();
} catch (error) {
failure = new Error(
`Credential lease heartbeat failed for kind "${lease.kind}": ${formatErrorMessage(error)}`,
);
return;
} finally {
inFlight = null;
}
schedule();
})();
}, intervalMs);
};
schedule();
return {
getFailure() {
return failure;
},
throwIfFailed() {
if (failure) {
throw failure;
}
},
async stop() {
stopped = true;
if (timer) {
clearTimeoutImpl(timer);
timer = null;
}
if (inFlight) {
await inFlight.catch(() => {});
}
},
};
}
export const __testing = {
DEFAULT_ACQUIRE_TIMEOUT_MS,
DEFAULT_ENDPOINT_PREFIX,
DEFAULT_HEARTBEAT_INTERVAL_MS,
DEFAULT_LEASE_TTL_MS,
computeAcquireBackoffMs,
normalizeQaCredentialRole,
normalizeQaCredentialSource,
parsePositiveIntegerEnv,
resolveConvexCredentialBrokerConfig,
};

View File

@@ -25,6 +25,8 @@ export function resolveLiveTransportQaRunOptions(
fastMode: opts.fastMode,
scenarioIds: opts.scenarioIds,
sutAccountId: opts.sutAccountId,
credentialSource: opts.credentialSource?.trim(),
credentialRole: opts.credentialRole?.trim(),
};
}

View File

@@ -11,6 +11,8 @@ export type LiveTransportQaCommandOptions = {
fastMode?: boolean;
scenarioIds?: string[];
sutAccountId?: string;
credentialSource?: string;
credentialRole?: string;
};
type LiveTransportQaCommanderOptions = {
@@ -22,6 +24,8 @@ type LiveTransportQaCommanderOptions = {
scenario?: string[];
fast?: boolean;
sutAccount?: string;
credentialSource?: string;
credentialRole?: string;
};
export type LiveTransportQaCliRegistration = {
@@ -49,6 +53,8 @@ export function mapLiveTransportQaCommanderOptions(
fastMode: opts.fast,
scenarioIds: opts.scenario,
sutAccountId: opts.sutAccount,
credentialSource: opts.credentialSource,
credentialRole: opts.credentialRole,
};
}
@@ -76,6 +82,14 @@ export function registerLiveTransportQaCli(params: {
.option("--scenario <id>", params.scenarioHelp, collectString, [])
.option("--fast", "Enable provider fast mode where supported", false)
.option("--sut-account <id>", params.sutAccountHelp, "sut")
.option(
"--credential-source <source>",
"Credential source for live lanes: env or convex (default: env)",
)
.option(
"--credential-role <role>",
"Credential role for convex auth: maintainer or ci (default: maintainer)",
)
.action(async (opts: LiveTransportQaCommanderOptions) => {
await params.run(mapLiveTransportQaCommanderOptions(opts));
});

View File

@@ -66,6 +66,30 @@ describe("telegram live qa runtime", () => {
).toThrow("OPENCLAW_QA_TELEGRAM_GROUP_ID must be a numeric Telegram chat id.");
});
it("parses Telegram pooled credential payloads", () => {
expect(
__testing.parseTelegramQaCredentialPayload({
groupId: "-100123",
driverToken: "driver",
sutToken: "sut",
}),
).toEqual({
groupId: "-100123",
driverToken: "driver",
sutToken: "sut",
});
});
it("rejects Telegram pooled credential payloads with non-numeric group ids", () => {
expect(() =>
__testing.parseTelegramQaCredentialPayload({
groupId: "qa-group",
driverToken: "driver",
sutToken: "sut",
}),
).toThrow("Telegram credential payload groupId must be a numeric Telegram chat id.");
});
it("injects a temporary Telegram account into the QA gateway config", () => {
const baseCfg: OpenClawConfig = {
plugins: {

View File

@@ -4,12 +4,18 @@ import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { z } from "zod";
import { startQaGatewayChild } from "../../gateway-child.js";
import {
defaultQaModelForMode,
normalizeQaProviderMode,
type QaProviderModeInput,
} from "../../run-config.js";
import {
acquireQaCredentialLease,
startQaCredentialLeaseHeartbeat,
type QaCredentialRole,
} from "../shared/credential-lease.runtime.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { appendLiveLaneIssue, buildLiveLaneArtifactsError } from "../shared/live-lane-helpers.js";
import {
@@ -89,6 +95,13 @@ export type TelegramQaRunResult = {
};
type TelegramQaSummary = {
credentials: {
credentialId?: string;
kind: string;
ownerId?: string;
role?: QaCredentialRole;
source: "convex" | "env";
};
groupId: string;
startedAt: string;
finishedAt: string;
@@ -266,6 +279,12 @@ const TELEGRAM_QA_ENV_KEYS = [
"OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN",
] as const;
const telegramQaCredentialPayloadSchema = z.object({
groupId: z.string().trim().min(1),
driverToken: z.string().trim().min(1),
sutToken: z.string().trim().min(1),
});
function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof TELEGRAM_QA_ENV_KEYS)[number]) {
const value = env[key]?.trim();
if (!value) {
@@ -288,6 +307,18 @@ export function resolveTelegramQaRuntimeEnv(
};
}
function parseTelegramQaCredentialPayload(payload: unknown): TelegramQaRuntimeEnv {
const parsed = telegramQaCredentialPayloadSchema.parse(payload);
if (!/^-?\d+$/u.test(parsed.groupId)) {
throw new Error("Telegram credential payload groupId must be a numeric Telegram chat id.");
}
return {
groupId: parsed.groupId,
driverToken: parsed.driverToken,
sutToken: parsed.sutToken,
};
}
function flattenInlineButtons(replyMarkup?: TelegramReplyMarkup) {
return (replyMarkup?.inline_keyboard ?? [])
.flat()
@@ -528,6 +559,7 @@ async function waitForTelegramChannelRunning(
function renderTelegramQaMarkdown(params: {
cleanupIssues: string[];
credentialSource: "convex" | "env";
groupId: string;
startedAt: string;
finishedAt: string;
@@ -536,6 +568,7 @@ function renderTelegramQaMarkdown(params: {
const lines = [
"# Telegram QA Report",
"",
`- Credential source: \`${params.credentialSource}\``,
`- Group: \`${params.groupId}\``,
`- Started: ${params.startedAt}`,
`- Finished: ${params.finishedAt}`,
@@ -803,6 +836,8 @@ export async function runTelegramQaLive(params: {
fastMode?: boolean;
scenarioIds?: string[];
sutAccountId?: string;
credentialSource?: string;
credentialRole?: string;
}): Promise<TelegramQaRunResult> {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
@@ -810,7 +845,19 @@ export async function runTelegramQaLive(params: {
path.join(repoRoot, ".artifacts", "qa-e2e", `telegram-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const runtimeEnv = resolveTelegramQaRuntimeEnv();
const credentialLease = await acquireQaCredentialLease({
kind: "telegram",
source: params.credentialSource,
role: params.credentialRole,
resolveEnvPayload: () => resolveTelegramQaRuntimeEnv(),
parsePayload: parseTelegramQaCredentialPayload,
});
const leaseHeartbeat = startQaCredentialLeaseHeartbeat(credentialLease);
const assertLeaseHealthy = () => {
leaseHeartbeat.throwIfFailed();
};
const runtimeEnv = credentialLease.payload;
const providerMode = normalizeQaProviderMode(params.providerMode ?? "live-frontier");
const primaryModel = params.primaryModel?.trim() || defaultQaModelForMode(providerMode);
const alternateModel = params.alternateModel?.trim() || defaultQaModelForMode(providerMode, true);
@@ -819,145 +866,163 @@ export async function runTelegramQaLive(params: {
const observedMessages: TelegramObservedMessage[] = [];
const includeObservedMessageContent = process.env.OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT === "1";
const startedAt = new Date().toISOString();
const driverIdentity = await getBotIdentity(runtimeEnv.driverToken);
const sutIdentity = await getBotIdentity(runtimeEnv.sutToken);
const sutUsername = sutIdentity.username?.trim();
const uniqueIds = new Set([driverIdentity.id, sutIdentity.id]);
if (uniqueIds.size !== 2) {
throw new Error("Telegram QA requires two distinct bots for driver and SUT.");
}
if (!sutUsername) {
throw new Error("Telegram QA requires the SUT bot to have a Telegram username.");
}
await Promise.all([
flushTelegramUpdates(runtimeEnv.driverToken),
flushTelegramUpdates(runtimeEnv.sutToken),
]);
const gatewayHarness = await startQaLiveLaneGateway({
repoRoot,
transport: {
requiredPluginIds: [],
createGatewayConfig: () => ({}),
},
transportBaseUrl: "http://127.0.0.1:0",
providerMode,
primaryModel,
alternateModel,
fastMode: params.fastMode,
controlUiEnabled: false,
mutateConfig: (cfg) =>
buildTelegramQaConfig(cfg, {
groupId: runtimeEnv.groupId,
sutToken: runtimeEnv.sutToken,
driverBotId: driverIdentity.id,
sutAccountId,
}),
});
const scenarioResults: TelegramQaScenarioResult[] = [];
const cleanupIssues: string[] = [];
let canaryFailure: string | null = null;
try {
await waitForTelegramChannelRunning(gatewayHarness.gateway, sutAccountId);
try {
await runCanary({
driverToken: runtimeEnv.driverToken,
groupId: runtimeEnv.groupId,
sutUsername,
sutBotId: sutIdentity.id,
observedMessages,
});
} catch (error) {
canaryFailure = canaryFailureMessage({
error,
groupId: runtimeEnv.groupId,
driverBotId: driverIdentity.id,
driverUsername: driverIdentity.username,
sutBotId: sutIdentity.id,
sutUsername,
});
scenarioResults.push({
id: "telegram-canary",
title: "Telegram canary",
status: "fail",
details: canaryFailure,
});
const driverIdentity = await getBotIdentity(runtimeEnv.driverToken);
const sutIdentity = await getBotIdentity(runtimeEnv.sutToken);
const sutUsername = sutIdentity.username?.trim();
const uniqueIds = new Set([driverIdentity.id, sutIdentity.id]);
if (uniqueIds.size !== 2) {
throw new Error("Telegram QA requires two distinct bots for driver and SUT.");
}
if (!canaryFailure) {
let driverOffset = await flushTelegramUpdates(runtimeEnv.driverToken);
for (const scenario of scenarios) {
const scenarioRun = scenario.buildRun(sutUsername);
try {
const sent = await sendGroupMessage(
runtimeEnv.driverToken,
runtimeEnv.groupId,
scenarioRun.input,
);
const matched = await waitForObservedMessage({
token: runtimeEnv.driverToken,
initialOffset: driverOffset,
timeoutMs: scenario.timeoutMs,
observedMessages,
predicate: (message) =>
matchesTelegramScenarioReply({
groupId: runtimeEnv.groupId,
matchText: scenarioRun.matchText,
message,
sentMessageId: sent.message_id,
sutBotId: sutIdentity.id,
}),
});
driverOffset = matched.nextOffset;
if (!scenarioRun.expectReply) {
throw new Error(`unexpected reply message ${matched.message.messageId} matched`);
}
assertTelegramScenarioReply({
expectedTextIncludes: scenarioRun.expectedTextIncludes,
message: matched.message,
});
scenarioResults.push({
id: scenario.id,
title: scenario.title,
status: "pass",
details: `reply message ${matched.message.messageId} matched`,
});
} catch (error) {
if (!scenarioRun.expectReply) {
const details = formatErrorMessage(error);
if (
details === `timed out after ${scenario.timeoutMs}ms waiting for Telegram message`
) {
scenarioResults.push({
id: scenario.id,
title: scenario.title,
status: "pass",
details: "no reply",
});
continue;
if (!sutUsername) {
throw new Error("Telegram QA requires the SUT bot to have a Telegram username.");
}
await Promise.all([
flushTelegramUpdates(runtimeEnv.driverToken),
flushTelegramUpdates(runtimeEnv.sutToken),
]);
const gatewayHarness = await startQaLiveLaneGateway({
repoRoot,
transport: {
requiredPluginIds: [],
createGatewayConfig: () => ({}),
},
transportBaseUrl: "http://127.0.0.1:0",
providerMode,
primaryModel,
alternateModel,
fastMode: params.fastMode,
controlUiEnabled: false,
mutateConfig: (cfg) =>
buildTelegramQaConfig(cfg, {
groupId: runtimeEnv.groupId,
sutToken: runtimeEnv.sutToken,
driverBotId: driverIdentity.id,
sutAccountId,
}),
});
try {
await waitForTelegramChannelRunning(gatewayHarness.gateway, sutAccountId);
assertLeaseHealthy();
try {
await runCanary({
driverToken: runtimeEnv.driverToken,
groupId: runtimeEnv.groupId,
sutUsername,
sutBotId: sutIdentity.id,
observedMessages,
});
} catch (error) {
canaryFailure = canaryFailureMessage({
error,
groupId: runtimeEnv.groupId,
driverBotId: driverIdentity.id,
driverUsername: driverIdentity.username,
sutBotId: sutIdentity.id,
sutUsername,
});
scenarioResults.push({
id: "telegram-canary",
title: "Telegram canary",
status: "fail",
details: canaryFailure,
});
}
assertLeaseHealthy();
if (!canaryFailure) {
let driverOffset = await flushTelegramUpdates(runtimeEnv.driverToken);
for (const scenario of scenarios) {
assertLeaseHealthy();
const scenarioRun = scenario.buildRun(sutUsername);
try {
const sent = await sendGroupMessage(
runtimeEnv.driverToken,
runtimeEnv.groupId,
scenarioRun.input,
);
const matched = await waitForObservedMessage({
token: runtimeEnv.driverToken,
initialOffset: driverOffset,
timeoutMs: scenario.timeoutMs,
observedMessages,
predicate: (message) =>
matchesTelegramScenarioReply({
groupId: runtimeEnv.groupId,
matchText: scenarioRun.matchText,
message,
sentMessageId: sent.message_id,
sutBotId: sutIdentity.id,
}),
});
driverOffset = matched.nextOffset;
if (!scenarioRun.expectReply) {
throw new Error(`unexpected reply message ${matched.message.messageId} matched`);
}
assertTelegramScenarioReply({
expectedTextIncludes: scenarioRun.expectedTextIncludes,
message: matched.message,
});
scenarioResults.push({
id: scenario.id,
title: scenario.title,
status: "pass",
details: `reply message ${matched.message.messageId} matched`,
});
} catch (error) {
if (!scenarioRun.expectReply) {
const details = formatErrorMessage(error);
if (
details === `timed out after ${scenario.timeoutMs}ms waiting for Telegram message`
) {
scenarioResults.push({
id: scenario.id,
title: scenario.title,
status: "pass",
details: "no reply",
});
continue;
}
}
scenarioResults.push({
id: scenario.id,
title: scenario.title,
status: "fail",
details: formatErrorMessage(error),
});
}
scenarioResults.push({
id: scenario.id,
title: scenario.title,
status: "fail",
details: formatErrorMessage(error),
});
assertLeaseHealthy();
}
}
} finally {
try {
await gatewayHarness.stop();
} catch (error) {
appendLiveLaneIssue(cleanupIssues, "live gateway cleanup", error);
}
}
} finally {
await leaseHeartbeat.stop();
try {
await gatewayHarness.stop();
await credentialLease.release();
} catch (error) {
appendLiveLaneIssue(cleanupIssues, "live gateway cleanup", error);
appendLiveLaneIssue(cleanupIssues, "credential lease release", error);
}
}
const finishedAt = new Date().toISOString();
const summary: TelegramQaSummary = {
credentials: {
source: credentialLease.source,
kind: credentialLease.kind,
role: credentialLease.role,
ownerId: credentialLease.ownerId,
credentialId: credentialLease.credentialId,
},
groupId: runtimeEnv.groupId,
startedAt,
finishedAt,
@@ -976,6 +1041,7 @@ export async function runTelegramQaLive(params: {
reportPath,
`${renderTelegramQaMarkdown({
cleanupIssues,
credentialSource: credentialLease.source,
groupId: runtimeEnv.groupId,
startedAt,
finishedAt,
@@ -1043,5 +1109,6 @@ export const __testing = {
findScenario,
matchesTelegramScenarioReply,
normalizeTelegramObservedMessage,
parseTelegramQaCredentialPayload,
resolveTelegramQaRuntimeEnv,
};

View File

@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from "vitest";
import {
addQaCredentialSet,
listQaCredentialSets,
QaCredentialAdminError,
removeQaCredentialSet,
} from "./qa-credentials-admin.runtime.js";
function jsonResponse(payload: unknown, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: {
"content-type": "application/json",
},
});
}
describe("qa credential admin runtime", () => {
it("adds a credential set through the admin endpoint", async () => {
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
jsonResponse({
status: "ok",
credential: {
credentialId: "cred-1",
kind: "telegram",
status: "active",
createdAtMs: 100,
updatedAtMs: 100,
lastLeasedAtMs: 0,
note: "qa",
},
}),
);
const result = await addQaCredentialSet({
kind: "telegram",
payload: {
groupId: "-100123",
driverToken: "driver",
sutToken: "sut",
},
note: "qa",
actorId: "maintainer-local",
siteUrl: "https://first-schnauzer-821.convex.site",
env: {
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maint-secret",
},
fetchImpl,
});
expect(result.credential.credentialId).toBe("cred-1");
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe("https://first-schnauzer-821.convex.site/qa-credentials/v1/admin/add");
const headers = init?.headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer maint-secret");
const bodyText = init?.body;
expect(typeof bodyText).toBe("string");
const body = JSON.parse(bodyText as string) as Record<string, unknown>;
expect(body.kind).toBe("telegram");
expect(body.actorId).toBe("maintainer-local");
expect(body.payload).toEqual({
groupId: "-100123",
driverToken: "driver",
sutToken: "sut",
});
});
it("rejects admin commands when maintainer secret is missing", async () => {
await expect(
listQaCredentialSets({
siteUrl: "https://first-schnauzer-821.convex.site",
env: {},
fetchImpl: vi.fn(),
}),
).rejects.toMatchObject({
name: "QaCredentialAdminError",
code: "MISSING_MAINTAINER_SECRET",
} satisfies Partial<QaCredentialAdminError>);
});
it("rejects non-https admin site URLs unless local insecure opt-in is enabled", async () => {
await expect(
listQaCredentialSets({
siteUrl: "http://qa-cred.example.convex.site",
env: {
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maint-secret",
},
fetchImpl: vi.fn(),
}),
).rejects.toMatchObject({
name: "QaCredentialAdminError",
code: "INVALID_SITE_URL",
} satisfies Partial<QaCredentialAdminError>);
});
it("allows loopback http admin site URLs when OPENCLAW_QA_ALLOW_INSECURE_HTTP is enabled", async () => {
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
jsonResponse({
status: "ok",
count: 0,
credentials: [],
}),
);
await listQaCredentialSets({
siteUrl: "http://127.0.0.1:3210",
env: {
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maint-secret",
OPENCLAW_QA_ALLOW_INSECURE_HTTP: "1",
},
fetchImpl,
});
expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://127.0.0.1:3210/qa-credentials/v1/admin/list");
});
it("rejects unsafe endpoint-prefix overrides", async () => {
await expect(
listQaCredentialSets({
siteUrl: "https://first-schnauzer-821.convex.site",
endpointPrefix: "//evil.example",
env: {
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maint-secret",
},
fetchImpl: vi.fn(),
}),
).rejects.toMatchObject({
name: "QaCredentialAdminError",
code: "INVALID_ARGUMENT",
} satisfies Partial<QaCredentialAdminError>);
});
it("surfaces broker error codes for remove", async () => {
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
jsonResponse(
{
status: "error",
code: "LEASE_ACTIVE",
message: "Credential is currently leased and cannot be disabled.",
},
200,
),
);
await expect(
removeQaCredentialSet({
credentialId: "cred-1",
siteUrl: "https://first-schnauzer-821.convex.site",
env: {
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maint-secret",
},
fetchImpl,
}),
).rejects.toMatchObject({
name: "QaCredentialAdminError",
code: "LEASE_ACTIVE",
} satisfies Partial<QaCredentialAdminError>);
});
it("lists credentials and forwards includePayload/status filters", async () => {
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
jsonResponse({
status: "ok",
count: 1,
credentials: [
{
credentialId: "cred-2",
kind: "telegram",
status: "active",
createdAtMs: 100,
updatedAtMs: 100,
lastLeasedAtMs: 50,
payload: {
groupId: "-100123",
driverToken: "driver",
sutToken: "sut",
},
},
],
}),
);
const result = await listQaCredentialSets({
kind: "telegram",
status: "active",
includePayload: true,
limit: 5,
siteUrl: "https://first-schnauzer-821.convex.site",
env: {
OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maint-secret",
},
fetchImpl,
});
expect(result.credentials).toHaveLength(1);
const [, init] = fetchImpl.mock.calls[0] ?? [];
const bodyText = init?.body;
expect(typeof bodyText).toBe("string");
const body = JSON.parse(bodyText as string) as Record<string, unknown>;
expect(body).toEqual({
kind: "telegram",
status: "active",
includePayload: true,
limit: 5,
});
});
});

View File

@@ -0,0 +1,407 @@
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { z } from "zod";
const DEFAULT_ENDPOINT_PREFIX = "/qa-credentials/v1";
const DEFAULT_HTTP_TIMEOUT_MS = 15_000;
const ALLOW_INSECURE_HTTP_ENV_KEY = "OPENCLAW_QA_ALLOW_INSECURE_HTTP";
const actorRoleSchema = z.union([z.literal("ci"), z.literal("maintainer")]);
const credentialStatusSchema = z.union([z.literal("active"), z.literal("disabled")]);
const listStatusSchema = z.union([z.literal("active"), z.literal("disabled"), z.literal("all")]);
const brokerErrorSchema = z.object({
status: z.literal("error"),
code: z.string().min(1),
message: z.string().min(1),
});
const credentialLeaseSchema = z.object({
ownerId: z.string().min(1),
actorRole: actorRoleSchema,
acquiredAtMs: z.number().int(),
heartbeatAtMs: z.number().int(),
expiresAtMs: z.number().int(),
});
const credentialRecordSchema = z.object({
credentialId: z.string().min(1),
kind: z.string().min(1),
status: credentialStatusSchema,
createdAtMs: z.number().int(),
updatedAtMs: z.number().int(),
lastLeasedAtMs: z.number().int(),
note: z.string().optional(),
lease: credentialLeaseSchema.optional(),
payload: z.unknown().optional(),
});
const addCredentialResponseSchema = z.object({
status: z.literal("ok"),
credential: credentialRecordSchema,
});
const removeCredentialResponseSchema = z.object({
status: z.literal("ok"),
changed: z.boolean(),
credential: credentialRecordSchema,
});
const listCredentialsResponseSchema = z.object({
status: z.literal("ok"),
credentials: z.array(credentialRecordSchema),
count: z.number().int().nonnegative().optional(),
});
export type QaCredentialAdminListStatus = z.infer<typeof listStatusSchema>;
export type QaCredentialRecord = z.infer<typeof credentialRecordSchema>;
export type QaCredentialListResponse = z.infer<typeof listCredentialsResponseSchema>;
export class QaCredentialAdminError extends Error {
code: string;
httpStatus?: number;
constructor(params: { code: string; message: string; httpStatus?: number }) {
super(params.message);
this.name = "QaCredentialAdminError";
this.code = params.code;
this.httpStatus = params.httpStatus;
}
}
type AdminConfig = {
actorId: string;
authToken: string;
addUrl: string;
endpointPrefix: string;
httpTimeoutMs: number;
listUrl: string;
removeUrl: string;
siteUrl: string;
};
type AdminBaseOptions = {
actorId?: string;
endpointPrefix?: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
siteUrl?: string;
};
type AddQaCredentialSetOptions = AdminBaseOptions & {
kind: string;
note?: string;
payload: Record<string, unknown>;
status?: z.infer<typeof credentialStatusSchema>;
};
type RemoveQaCredentialSetOptions = AdminBaseOptions & {
credentialId: string;
};
type ListQaCredentialSetsOptions = AdminBaseOptions & {
includePayload?: boolean;
kind?: string;
limit?: number;
status?: string;
};
function parsePositiveIntegerEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
const raw = env[key]?.trim();
if (!raw) {
return fallback;
}
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
throw new QaCredentialAdminError({
code: "INVALID_ENV",
message: `${key} must be a positive integer.`,
});
}
return value;
}
function isTruthyOptIn(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function isLoopbackHostname(hostname: string) {
return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
}
function normalizeConvexSiteUrl(raw: string, env: NodeJS.ProcessEnv): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new QaCredentialAdminError({
code: "INVALID_SITE_URL",
message: `OPENCLAW_QA_CONVEX_SITE_URL must be a valid URL, got "${raw || "<empty>"}".`,
});
}
if (parsed.protocol === "https:") {
const text = parsed.toString();
return text.endsWith("/") ? text.slice(0, -1) : text;
}
if (parsed.protocol !== "http:") {
throw new QaCredentialAdminError({
code: "INVALID_SITE_URL",
message: "OPENCLAW_QA_CONVEX_SITE_URL must use https://.",
});
}
const allowInsecureHttp = isTruthyOptIn(env[ALLOW_INSECURE_HTTP_ENV_KEY]);
if (!allowInsecureHttp || !isLoopbackHostname(parsed.hostname)) {
throw new QaCredentialAdminError({
code: "INVALID_SITE_URL",
message: `OPENCLAW_QA_CONVEX_SITE_URL must use https://. http:// is only allowed for loopback hosts when ${ALLOW_INSECURE_HTTP_ENV_KEY}=1.`,
});
}
const text = parsed.toString();
return text.endsWith("/") ? text.slice(0, -1) : text;
}
function normalizeEndpointPrefix(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) {
return DEFAULT_ENDPOINT_PREFIX;
}
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
const normalized = prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
if (!normalized.startsWith("/") || normalized.startsWith("//")) {
throw new QaCredentialAdminError({
code: "INVALID_ARGUMENT",
message: '--endpoint-prefix must be an absolute path like "/qa-credentials/v1" (not //host).',
});
}
if (normalized.includes("\\") || normalized.split("/").some((segment) => segment === "..")) {
throw new QaCredentialAdminError({
code: "INVALID_ARGUMENT",
message: '--endpoint-prefix must not contain backslashes or ".." path segments.',
});
}
return normalized;
}
function joinEndpoint(baseUrl: string, prefix: string, suffix: string): string {
const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`;
const url = new URL(baseUrl);
url.pathname = `${prefix}${normalizedSuffix}`.replace(/\/{2,}/gu, "/");
url.search = "";
url.hash = "";
return url.toString();
}
function resolveAdminAuthToken(env: NodeJS.ProcessEnv): string {
const token = env.OPENCLAW_QA_CONVEX_SECRET_MAINTAINER?.trim();
if (token) {
return token;
}
throw new QaCredentialAdminError({
code: "MISSING_MAINTAINER_SECRET",
message: "Missing OPENCLAW_QA_CONVEX_SECRET_MAINTAINER for qa credential admin commands.",
});
}
function resolveAdminConfig(options: AdminBaseOptions): AdminConfig {
const env = options.env ?? process.env;
const siteUrl = options.siteUrl?.trim() || env.OPENCLAW_QA_CONVEX_SITE_URL?.trim();
if (!siteUrl) {
throw new QaCredentialAdminError({
code: "MISSING_SITE_URL",
message: "Missing OPENCLAW_QA_CONVEX_SITE_URL for qa credential admin commands.",
});
}
const normalizedSiteUrl = normalizeConvexSiteUrl(siteUrl, env);
const endpointPrefix = normalizeEndpointPrefix(
options.endpointPrefix?.trim() || env.OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX,
);
const actorId =
options.actorId?.trim() ||
env.OPENCLAW_QA_CREDENTIAL_OWNER_ID?.trim() ||
`qa-lab-admin-${process.pid}-${randomUUID().slice(0, 8)}`;
return {
actorId,
authToken: resolveAdminAuthToken(env),
siteUrl: normalizedSiteUrl,
endpointPrefix,
httpTimeoutMs: parsePositiveIntegerEnv(
env,
"OPENCLAW_QA_CREDENTIAL_HTTP_TIMEOUT_MS",
DEFAULT_HTTP_TIMEOUT_MS,
),
addUrl: joinEndpoint(normalizedSiteUrl, endpointPrefix, "admin/add"),
removeUrl: joinEndpoint(normalizedSiteUrl, endpointPrefix, "admin/remove"),
listUrl: joinEndpoint(normalizedSiteUrl, endpointPrefix, "admin/list"),
};
}
function parseJsonResponsePayload(text: string) {
if (!text.trim()) {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function toBrokerError(payload: unknown, httpStatus: number) {
const parsed = brokerErrorSchema.safeParse(payload);
if (!parsed.success) {
return null;
}
return new QaCredentialAdminError({
code: parsed.data.code,
message: parsed.data.message,
httpStatus,
});
}
async function postJson<T>(params: {
authToken: string;
body: Record<string, unknown>;
fetchImpl: typeof fetch;
httpTimeoutMs: number;
responseSchema: z.ZodType<T>;
url: string;
}) {
let response: Response;
try {
response = await params.fetchImpl(params.url, {
method: "POST",
headers: {
authorization: `Bearer ${params.authToken}`,
"content-type": "application/json",
},
body: JSON.stringify(params.body),
signal: AbortSignal.timeout(params.httpTimeoutMs),
});
} catch (error) {
throw new QaCredentialAdminError({
code: "BROKER_REQUEST_FAILED",
message: `Convex credential admin request failed: ${formatErrorMessage(error)}`,
});
}
const text = await response.text();
const payload = parseJsonResponsePayload(text);
const brokerError = toBrokerError(payload, response.status);
if (brokerError) {
throw brokerError;
}
if (!response.ok) {
throw new QaCredentialAdminError({
code: "BROKER_HTTP_ERROR",
message: `Convex credential admin request failed with HTTP ${response.status}.`,
httpStatus: response.status,
});
}
const parsed = params.responseSchema.safeParse(payload);
if (!parsed.success) {
throw new QaCredentialAdminError({
code: "INVALID_RESPONSE",
message: `Convex credential admin response did not match expected shape: ${parsed.error.message}`,
httpStatus: response.status,
});
}
return parsed.data;
}
function normalizeStatus(value: string | undefined): QaCredentialAdminListStatus | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
const parsed = listStatusSchema.safeParse(normalized);
if (!parsed.success) {
throw new QaCredentialAdminError({
code: "INVALID_ARGUMENT",
message: '--status must be one of "active", "disabled", or "all".',
});
}
return parsed.data;
}
function normalizeLimit(value: number | undefined) {
if (value === undefined) {
return undefined;
}
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
throw new QaCredentialAdminError({
code: "INVALID_ARGUMENT",
message: "--limit must be a positive integer.",
});
}
return value;
}
export async function addQaCredentialSet(options: AddQaCredentialSetOptions) {
const config = resolveAdminConfig(options);
const fetchImpl = options.fetchImpl ?? fetch;
return await postJson({
fetchImpl,
authToken: config.authToken,
httpTimeoutMs: config.httpTimeoutMs,
url: config.addUrl,
responseSchema: addCredentialResponseSchema,
body: {
kind: options.kind,
payload: options.payload,
...(options.note ? { note: options.note } : {}),
...(options.status ? { status: options.status } : {}),
actorId: config.actorId,
},
});
}
export async function removeQaCredentialSet(options: RemoveQaCredentialSetOptions) {
const config = resolveAdminConfig(options);
const fetchImpl = options.fetchImpl ?? fetch;
return await postJson({
fetchImpl,
authToken: config.authToken,
httpTimeoutMs: config.httpTimeoutMs,
url: config.removeUrl,
responseSchema: removeCredentialResponseSchema,
body: {
credentialId: options.credentialId,
actorId: config.actorId,
},
});
}
export async function listQaCredentialSets(options: ListQaCredentialSetsOptions) {
const config = resolveAdminConfig(options);
const fetchImpl = options.fetchImpl ?? fetch;
const status = normalizeStatus(options.status);
const limit = normalizeLimit(options.limit);
return await postJson({
fetchImpl,
authToken: config.authToken,
httpTimeoutMs: config.httpTimeoutMs,
url: config.listUrl,
responseSchema: listCredentialsResponseSchema,
body: {
...(options.kind ? { kind: options.kind } : {}),
...(status ? { status } : {}),
...(options.includePayload === true ? { includePayload: true } : {}),
...(limit !== undefined ? { limit } : {}),
},
});
}
export const __testing = {
DEFAULT_ENDPOINT_PREFIX,
DEFAULT_HTTP_TIMEOUT_MS,
normalizeConvexSiteUrl,
normalizeEndpointPrefix,
normalizeStatus,
parsePositiveIntegerEnv,
resolveAdminConfig,
};