diff --git a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts index a7bc31aaccc..fefe0eccc99 100644 --- a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts +++ b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts @@ -1,13 +1,20 @@ import { randomUUID } from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { z } from "zod"; +import { + isQaCredentialTruthyOptIn, + joinQaCredentialEndpoint, + normalizeQaCredentialConvexSiteUrl, + normalizeQaCredentialEndpointPrefix, + parseQaCredentialPositiveIntegerEnv, + QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX, +} from "../../qa-credentials-common.runtime.js"; const DEFAULT_ACQUIRE_TIMEOUT_MS = 90_000; -const DEFAULT_ENDPOINT_PREFIX = "/qa-credentials/v1"; +const DEFAULT_ENDPOINT_PREFIX = QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX; 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"]); @@ -95,15 +102,7 @@ class QaCredentialBrokerError extends Error { } 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; + return parseQaCredentialPositiveIntegerEnv({ env, key, fallback }); } function normalizeQaCredentialSource(value: string | undefined): QaCredentialLeaseSource { @@ -118,7 +117,7 @@ function normalizeQaCredentialRole( value: string | undefined, env: NodeJS.ProcessEnv = process.env, ): QaCredentialRole { - const defaultRole = isTruthyOptIn(env.CI) ? "ci" : "maintainer"; + const defaultRole = isQaCredentialTruthyOptIn(env.CI) ? "ci" : "maintainer"; const normalized = value?.trim().toLowerCase() || defaultRole; if (normalized === "maintainer" || normalized === "ci") { return normalized; @@ -126,57 +125,19 @@ function normalizeQaCredentialRole( 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 || ""}".`); - } - 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; + return normalizeQaCredentialConvexSiteUrl({ raw, env }); } 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( + return normalizeQaCredentialEndpointPrefix({ + value, + fallback: DEFAULT_ENDPOINT_PREFIX, + invalidAbsoluteMessage: "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( + invalidSegmentsMessage: "OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX must not contain backslashes or .. path segments.", - ); - } - return normalized; + }); } function resolveConvexAuthToken(env: NodeJS.ProcessEnv, role: QaCredentialRole): string { @@ -194,15 +155,6 @@ function resolveConvexAuthToken(env: NodeJS.ProcessEnv, role: QaCredentialRole): 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; @@ -242,9 +194,9 @@ function resolveConvexCredentialBrokerConfig(params: { "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"), + acquireUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "acquire"), + heartbeatUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "heartbeat"), + releaseUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "release"), }; } diff --git a/extensions/qa-lab/src/qa-credentials-admin.runtime.ts b/extensions/qa-lab/src/qa-credentials-admin.runtime.ts index 372e068c1e0..db095eaad5d 100644 --- a/extensions/qa-lab/src/qa-credentials-admin.runtime.ts +++ b/extensions/qa-lab/src/qa-credentials-admin.runtime.ts @@ -1,10 +1,16 @@ import { randomUUID } from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { z } from "zod"; +import { + joinQaCredentialEndpoint, + normalizeQaCredentialConvexSiteUrl, + normalizeQaCredentialEndpointPrefix, + parseQaCredentialPositiveIntegerEnv, + QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX, +} from "./qa-credentials-common.runtime.js"; -const DEFAULT_ENDPOINT_PREFIX = "/qa-credentials/v1"; +const DEFAULT_ENDPOINT_PREFIX = QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX; 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")]); @@ -107,89 +113,43 @@ type ListQaCredentialSetsOptions = AdminBaseOptions & { }; 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."); + return parseQaCredentialPositiveIntegerEnv({ + env, + key, + fallback, + toError: (message) => + new QaCredentialAdminError({ + code: "INVALID_ENV", + message, + }), + }); } 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 || ""}".`, - }); - } - 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; + return normalizeQaCredentialConvexSiteUrl({ + raw, + env, + toError: (message) => + new QaCredentialAdminError({ + code: "INVALID_SITE_URL", + message, + }), + }); } 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(); + return normalizeQaCredentialEndpointPrefix({ + value, + fallback: DEFAULT_ENDPOINT_PREFIX, + invalidAbsoluteMessage: + '--endpoint-prefix must be an absolute path like "/qa-credentials/v1" (not //host).', + invalidSegmentsMessage: '--endpoint-prefix must not contain backslashes or ".." path segments.', + toError: (message) => + new QaCredentialAdminError({ + code: "INVALID_ARGUMENT", + message, + }), + }); } function resolveAdminAuthToken(env: NodeJS.ProcessEnv): string { @@ -231,9 +191,9 @@ function resolveAdminConfig(options: AdminBaseOptions): AdminConfig { "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"), + addUrl: joinQaCredentialEndpoint(normalizedSiteUrl, endpointPrefix, "admin/add"), + removeUrl: joinQaCredentialEndpoint(normalizedSiteUrl, endpointPrefix, "admin/remove"), + listUrl: joinQaCredentialEndpoint(normalizedSiteUrl, endpointPrefix, "admin/list"), }; } diff --git a/extensions/qa-lab/src/qa-credentials-common.runtime.ts b/extensions/qa-lab/src/qa-credentials-common.runtime.ts new file mode 100644 index 00000000000..aef86b68a48 --- /dev/null +++ b/extensions/qa-lab/src/qa-credentials-common.runtime.ts @@ -0,0 +1,99 @@ +export const QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX = "/qa-credentials/v1"; +export const QA_CREDENTIALS_ALLOW_INSECURE_HTTP_ENV_KEY = "OPENCLAW_QA_ALLOW_INSECURE_HTTP"; + +type ErrorFactory = (message: string) => Error; + +function makeError(message: string) { + return new Error(message); +} + +export function parseQaCredentialPositiveIntegerEnv(params: { + env: NodeJS.ProcessEnv; + fallback: number; + key: string; + toError?: ErrorFactory; +}): number { + const raw = params.env[params.key]?.trim(); + if (!raw) { + return params.fallback; + } + const value = Number(raw); + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) { + throw (params.toError ?? makeError)(`${params.key} must be a positive integer.`); + } + return value; +} + +export function isQaCredentialTruthyOptIn(value: string | undefined) { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + +function isQaCredentialLoopbackHostname(hostname: string) { + return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127."); +} + +export function normalizeQaCredentialConvexSiteUrl(params: { + env: NodeJS.ProcessEnv; + raw: string; + toError?: ErrorFactory; +}): string { + const toError = params.toError ?? makeError; + let url: URL; + try { + url = new URL(params.raw); + } catch { + throw toError( + `OPENCLAW_QA_CONVEX_SITE_URL must be a valid URL, got "${params.raw || ""}".`, + ); + } + if (url.protocol === "https:") { + const text = url.toString(); + return text.endsWith("/") ? text.slice(0, -1) : text; + } + if (url.protocol !== "http:") { + throw toError("OPENCLAW_QA_CONVEX_SITE_URL must use https://."); + } + const allowInsecureHttp = isQaCredentialTruthyOptIn( + params.env[QA_CREDENTIALS_ALLOW_INSECURE_HTTP_ENV_KEY], + ); + if (!allowInsecureHttp || !isQaCredentialLoopbackHostname(url.hostname)) { + throw toError( + `OPENCLAW_QA_CONVEX_SITE_URL must use https://. http:// is only allowed for loopback hosts when ${QA_CREDENTIALS_ALLOW_INSECURE_HTTP_ENV_KEY}=1.`, + ); + } + const text = url.toString(); + return text.endsWith("/") ? text.slice(0, -1) : text; +} + +export function normalizeQaCredentialEndpointPrefix(params: { + fallback?: string; + invalidAbsoluteMessage: string; + invalidSegmentsMessage: string; + toError?: ErrorFactory; + value: string | undefined; +}): string { + const trimmed = params.value?.trim(); + if (!trimmed) { + return params.fallback ?? QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX; + } + const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + const normalized = prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed; + const toError = params.toError ?? makeError; + if (!normalized.startsWith("/") || normalized.startsWith("//")) { + throw toError(params.invalidAbsoluteMessage); + } + if (normalized.includes("\\") || normalized.split("/").some((segment) => segment === "..")) { + throw toError(params.invalidSegmentsMessage); + } + return normalized; +} + +export function joinQaCredentialEndpoint(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(); +}