mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
refactor: share qa credential helpers
This commit is contained in:
@@ -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 || "<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;
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || "<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;
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
99
extensions/qa-lab/src/qa-credentials-common.runtime.ts
Normal file
99
extensions/qa-lab/src/qa-credentials-common.runtime.ts
Normal file
@@ -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 || "<empty>"}".`,
|
||||
);
|
||||
}
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user