mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:20:43 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -25,6 +25,8 @@ export function resolveLiveTransportQaRunOptions(
|
||||
fastMode: opts.fastMode,
|
||||
scenarioIds: opts.scenarioIds,
|
||||
sutAccountId: opts.sutAccountId,
|
||||
credentialSource: opts.credentialSource?.trim(),
|
||||
credentialRole: opts.credentialRole?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
207
extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts
Normal file
207
extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
407
extensions/qa-lab/src/qa-credentials-admin.runtime.ts
Normal file
407
extensions/qa-lab/src/qa-credentials-admin.runtime.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user