From 8aaea142093372508dcda5da8b1211d4c2e5c61e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 20:48:00 +0100 Subject: [PATCH] refactor: share matrix runtime helpers --- extensions/matrix/src/matrix/client/config.ts | 180 +++--------------- .../matrix/src/matrix/client/env-auth.ts | 2 +- .../src/matrix/sdk/timeout-abort-signal.ts | 31 +++ .../src/matrix/sdk/transport-runtime-api.ts | 33 +--- extensions/matrix/src/runtime-api.ts | 33 +--- 5 files changed, 56 insertions(+), 223 deletions(-) create mode 100644 extensions/matrix/src/matrix/sdk/timeout-abort-signal.ts diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index b2160eb1b40..550e6df3486 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -22,16 +22,24 @@ import { resolveMatrixConfigFieldPath } from "../config-paths.js"; import type { MatrixStoredCredentials } from "../credentials-read.js"; import { DEFAULT_ACCOUNT_ID, - assertHttpUrlTargetsPrivateNetwork, - isPrivateOrLoopbackHost, isPrivateNetworkOptInEnabled, - type LookupFn, normalizeAccountId, normalizeOptionalAccountId, ssrfPolicyFromDangerouslyAllowPrivateNetwork, } from "./config-runtime-api.js"; +import { + hasReadyMatrixEnvAuth, + resolveGlobalMatrixEnvConfig, + resolveMatrixEnvAuthReadiness, + resolveScopedMatrixEnvConfig, + type MatrixEnvConfig, +} from "./env-auth.js"; import { repairCurrentTokenStorageMetaDeviceId } from "./storage.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; +import { + resolveValidatedMatrixHomeserverUrl, + validateMatrixHomeserverUrl, +} from "./url-validation.js"; type MatrixAuthClientDeps = { MatrixClient: typeof import("../sdk.js").MatrixClient; @@ -234,15 +242,6 @@ function clean( ); } -type MatrixEnvConfig = { - homeserver: string; - userId: string; - accessToken?: string; - password?: string; - deviceId?: string; - deviceName?: string; -}; - type MatrixConfigStringField = | "homeserver" | "userId" @@ -425,9 +424,6 @@ function clampMatrixInitialSyncLimit(value: unknown): number | undefined { return typeof value === "number" ? Math.max(0, Math.floor(value)) : undefined; } -const MATRIX_HTTP_HOMESERVER_ERROR = - "Matrix homeserver must use https:// unless it targets a private or loopback host"; - function buildMatrixNetworkFields(params: { allowPrivateNetwork: boolean | undefined; proxy?: string; @@ -450,74 +446,18 @@ function buildMatrixNetworkFields(params: { }; } -export function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig { - return { - homeserver: clean(env.MATRIX_HOMESERVER, "MATRIX_HOMESERVER"), - userId: clean(env.MATRIX_USER_ID, "MATRIX_USER_ID"), - accessToken: clean(env.MATRIX_ACCESS_TOKEN, "MATRIX_ACCESS_TOKEN") || undefined, - password: clean(env.MATRIX_PASSWORD, "MATRIX_PASSWORD") || undefined, - deviceId: clean(env.MATRIX_DEVICE_ID, "MATRIX_DEVICE_ID") || undefined, - deviceName: clean(env.MATRIX_DEVICE_NAME, "MATRIX_DEVICE_NAME") || undefined, - }; -} - export { getMatrixScopedEnvVarNames } from "../../env-vars.js"; - -export function resolveMatrixEnvAuthReadiness( - accountId: string, - env: NodeJS.ProcessEnv = process.env, -): { - ready: boolean; - homeserver?: string; - userId?: string; - sourceHint: string; - missingMessage: string; -} { - const normalizedAccountId = normalizeAccountId(accountId); - const scoped = resolveScopedMatrixEnvConfig(normalizedAccountId, env); - const scopedReady = hasReadyMatrixEnvAuth(scoped); - if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) { - const keys = getMatrixScopedEnvVarNames(normalizedAccountId); - return { - ready: scopedReady, - homeserver: scoped.homeserver || undefined, - userId: scoped.userId || undefined, - sourceHint: `${keys.homeserver} (+ auth vars)`, - missingMessage: `Set per-account env vars for "${normalizedAccountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`, - }; - } - - const defaultScoped = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env); - const global = resolveGlobalMatrixEnvConfig(env); - const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScoped); - const globalReady = hasReadyMatrixEnvAuth(global); - const defaultKeys = getMatrixScopedEnvVarNames(DEFAULT_ACCOUNT_ID); - return { - ready: defaultScopedReady || globalReady, - homeserver: defaultScoped.homeserver || global.homeserver || undefined, - userId: defaultScoped.userId || global.userId || undefined, - sourceHint: "MATRIX_* or MATRIX_DEFAULT_*", - missingMessage: - `Set Matrix env vars for the default account ` + - `(for example MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN, MATRIX_USER_ID + MATRIX_PASSWORD, ` + - `or ${defaultKeys.homeserver} + ${defaultKeys.accessToken}).`, - }; -} - -export function resolveScopedMatrixEnvConfig( - accountId: string, - env: NodeJS.ProcessEnv = process.env, -): MatrixEnvConfig { - const keys = getMatrixScopedEnvVarNames(accountId); - return { - homeserver: clean(env[keys.homeserver], keys.homeserver), - userId: clean(env[keys.userId], keys.userId), - accessToken: clean(env[keys.accessToken], keys.accessToken) || undefined, - password: clean(env[keys.password], keys.password) || undefined, - deviceId: clean(env[keys.deviceId], keys.deviceId) || undefined, - deviceName: clean(env[keys.deviceName], keys.deviceName) || undefined, - }; -} +export { + hasReadyMatrixEnvAuth, + resolveGlobalMatrixEnvConfig, + resolveMatrixEnvAuthReadiness, + resolveScopedMatrixEnvConfig, + type MatrixEnvConfig, +} from "./env-auth.js"; +export { + resolveValidatedMatrixHomeserverUrl, + validateMatrixHomeserverUrl, +} from "./url-validation.js"; function hasScopedMatrixEnvConfig(accountId: string, env: NodeJS.ProcessEnv): boolean { const scoped = resolveScopedMatrixEnvConfig(accountId, env); @@ -531,82 +471,6 @@ function hasScopedMatrixEnvConfig(accountId: string, env: NodeJS.ProcessEnv): bo ); } -export function hasReadyMatrixEnvAuth(config: { - homeserver?: string; - userId?: string; - accessToken?: string; - password?: string; -}): boolean { - const homeserver = clean(config.homeserver, "matrix.env.homeserver"); - const userId = clean(config.userId, "matrix.env.userId"); - const accessToken = clean(config.accessToken, "matrix.env.accessToken"); - const password = clean(config.password, "matrix.env.password"); - return Boolean(homeserver && (accessToken || (userId && password))); -} - -export function validateMatrixHomeserverUrl( - homeserver: string, - opts?: { allowPrivateNetwork?: boolean }, -): string { - const trimmed = clean(homeserver, "matrix.homeserver"); - if (!trimmed) { - throw new Error("Matrix homeserver is required (matrix.homeserver)"); - } - - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - throw new Error("Matrix homeserver must be a valid http(s) URL"); - } - - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - throw new Error("Matrix homeserver must use http:// or https://"); - } - if (!parsed.hostname) { - throw new Error("Matrix homeserver must include a hostname"); - } - if (parsed.username || parsed.password) { - throw new Error("Matrix homeserver URL must not include embedded credentials"); - } - if (parsed.search || parsed.hash) { - throw new Error("Matrix homeserver URL must not include query strings or fragments"); - } - if ( - parsed.protocol === "http:" && - opts?.allowPrivateNetwork !== true && - !isPrivateOrLoopbackHost(parsed.hostname) - ) { - throw new Error(MATRIX_HTTP_HOMESERVER_ERROR); - } - - return trimmed; -} - -export async function resolveValidatedMatrixHomeserverUrl( - homeserver: string, - opts?: { - dangerouslyAllowPrivateNetwork?: boolean; - allowPrivateNetwork?: boolean; - lookupFn?: LookupFn; - }, -): Promise { - const allowPrivateNetwork = - typeof opts?.dangerouslyAllowPrivateNetwork === "boolean" - ? opts.dangerouslyAllowPrivateNetwork - : opts?.allowPrivateNetwork; - const normalized = validateMatrixHomeserverUrl(homeserver, { - allowPrivateNetwork, - }); - await assertHttpUrlTargetsPrivateNetwork(normalized, { - dangerouslyAllowPrivateNetwork: opts?.dangerouslyAllowPrivateNetwork, - allowPrivateNetwork, - lookupFn: opts?.lookupFn, - errorMessage: MATRIX_HTTP_HOMESERVER_ERROR, - }); - return normalized; -} - export function resolveMatrixConfigForAccount( cfg: CoreConfig, accountId: string, diff --git a/extensions/matrix/src/matrix/client/env-auth.ts b/extensions/matrix/src/matrix/client/env-auth.ts index 862b57a345f..b54b51ce75b 100644 --- a/extensions/matrix/src/matrix/client/env-auth.ts +++ b/extensions/matrix/src/matrix/client/env-auth.ts @@ -1,7 +1,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixScopedEnvVarNames } from "../../env-vars.js"; -type MatrixEnvConfig = { +export type MatrixEnvConfig = { homeserver: string; userId: string; accessToken?: string; diff --git a/extensions/matrix/src/matrix/sdk/timeout-abort-signal.ts b/extensions/matrix/src/matrix/sdk/timeout-abort-signal.ts new file mode 100644 index 00000000000..082dd8b9c50 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/timeout-abort-signal.ts @@ -0,0 +1,31 @@ +export function buildTimeoutAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { + signal?: AbortSignal; + cleanup: () => void; +} { + const { timeoutMs, signal } = params; + if (!timeoutMs && !signal) { + return { signal: undefined, cleanup: () => {} }; + } + if (!timeoutMs) { + return { signal, cleanup: () => {} }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); + const onAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", onAbort); + }, + }; +} diff --git a/extensions/matrix/src/matrix/sdk/transport-runtime-api.ts b/extensions/matrix/src/matrix/sdk/transport-runtime-api.ts index a9d38625ac8..6783848ae07 100644 --- a/extensions/matrix/src/matrix/sdk/transport-runtime-api.ts +++ b/extensions/matrix/src/matrix/sdk/transport-runtime-api.ts @@ -9,6 +9,7 @@ import { type PinnedDispatcherPolicy, type SsrFPolicy, } from "openclaw/plugin-sdk/ssrf-dispatcher"; +export { buildTimeoutAbortSignal } from "./timeout-abort-signal.js"; export { closeDispatcher, @@ -19,35 +20,3 @@ export { type PinnedDispatcherPolicy, type SsrFPolicy, }; - -export function buildTimeoutAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { - signal?: AbortSignal; - cleanup: () => void; -} { - const { timeoutMs, signal } = params; - if (!timeoutMs && !signal) { - return { signal: undefined, cleanup: () => {} }; - } - if (!timeoutMs) { - return { signal, cleanup: () => {} }; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); - const onAbort = () => controller.abort(); - if (signal) { - if (signal.aborted) { - controller.abort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - } - } - - return { - signal: controller.signal, - cleanup: () => { - clearTimeout(timeoutId); - signal?.removeEventListener("abort", onAbort); - }, - }; -} diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index fb2ff5b01f5..3113179bac3 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -102,6 +102,7 @@ export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, } from "openclaw/plugin-sdk/channel-policy"; +export { buildTimeoutAbortSignal } from "./matrix/sdk/timeout-abort-signal.js"; export { formatZonedTimestamp, type PluginRuntime, @@ -110,35 +111,3 @@ export { export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; // resolveMatrixAccountStringValues already comes from plugin-sdk/matrix. // Re-exporting auth-precedence here makes Jiti try to define the same export twice. - -export function buildTimeoutAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { - signal?: AbortSignal; - cleanup: () => void; -} { - const { timeoutMs, signal } = params; - if (!timeoutMs && !signal) { - return { signal: undefined, cleanup: () => {} }; - } - if (!timeoutMs) { - return { signal, cleanup: () => {} }; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); - const onAbort = () => controller.abort(); - if (signal) { - if (signal.aborted) { - controller.abort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - } - } - - return { - signal: controller.signal, - cleanup: () => { - clearTimeout(timeoutId); - signal?.removeEventListener("abort", onAbort); - }, - }; -}