UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

390 lines (389 loc) 18.6 kB
import { c as isRecord } from "./record-coerce-DItp3I4t.js"; import { m as readNonBlankString, o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js"; import { i as getOrCreatePromise, n as createLazyPromise } from "./lazy-promise-DGqyc4Y4.js"; import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js"; import { o as getRuntimeConfigSnapshot } from "./runtime-snapshot-BaQikjTR.js"; import { a as READ_SCOPE } from "./operator-scopes-Dw7Gu2cA.js"; import { n as runExec } from "./exec-BIE-3oLG.js"; import { h as getActivePluginSessionExtensionRegistry } from "./runtime-BL4wZfTq.js"; import { r as authorizeOperatorScopesForRequiredScope } from "./method-scopes-K6J_UQGL.js"; import { S as normalizeGitHubLogin, b as resolveCachedGitHubIdentity, f as syncGitHubIdentity, v as classifyTailscaleLogin } from "./user-profiles-4AB7AmiH.js"; import "./control-ui-contract-zYW4RcpK.js"; import { i as controlUiPluginAssetPrefix, s as resolvePluginRoutePathContext, t as findMatchingPluginHttpRoutes } from "./route-match-BkI3BCZw.js"; import { a as discardResponse, f as readBoundedResponse, h as withOptionalGitHubAuth, i as GITHUB_REQUEST_TIMEOUT_MS, l as githubApiCredentialCacheScope, n as ControlUiGitHubError, o as fetchGitHubApi, p as readGitHubJsonResponse, r as GITHUB_API_ORIGIN, s as fetchGitHubJson, u as githubApiToken } from "./control-ui-github-api-CCpVJnCA.js"; import fs from "node:fs/promises"; import os from "node:os"; //#region src/gateway/control-ui-plugin-frame-contract.ts /** Lifetime shared by server-minted plugin-tab grants and parent-side renewal. */ const CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS = 3e5; /** Reserved query key for the sandbox cookie capability probe. */ const CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY = "__openclaw_plugin_frame_auth_probe"; /** Exact parent origin that may receive the successful probe message. */ const CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY = "__openclaw_plugin_frame_auth_origin"; /** Message emitted only by a successful sandbox cookie capability probe. */ const CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE = "openclaw-plugin-frame-auth-probe"; /** Extracts the same-origin route pathname from a tab descriptor URL. */ function resolveControlUiPluginTabPathname(path) { try { const baseUrl = new URL("http://openclaw.invalid"); const tabUrl = new URL(path, baseUrl); return tabUrl.origin === baseUrl.origin ? tabUrl.pathname : void 0; } catch { return; } } //#endregion //#region src/gateway/control-ui-plugin-policy.ts const CUSTOM_PLUGIN_UI_DISABLED_MESSAGE = "Custom plugin UI is disabled. Enable Custom plugin UI in Settings > Labs, restart the Gateway, and reload this page."; function isControlUiPluginAllowed(plugin) { return plugin.origin === "bundled" || getRuntimeConfigSnapshot()?.gateway?.controlUi?.experimental?.customPlugins === true; } //#endregion //#region src/gateway/control-ui-plugin-tabs.ts const CORE_CONTROL_UI_WIDGET_KINDS = [{ pluginId: "session", kind: "session:progress", label: "Session progress" }]; function findControlUiTabGatewayRoute(registry, tab) { if (!tab.path) return; const routePath = resolveControlUiPluginTabPathname(tab.path); if (!routePath) return; const route = findMatchingPluginHttpRoutes(registry, resolvePluginRoutePathContext(routePath)).find((candidate) => candidate.auth === "gateway"); if (!route) return; return route.pluginId === tab.pluginId ? route : null; } /** Pure projection of tab descriptors visible to the presented scopes. */ function projectControlUiPluginTabs(entries, scopes) { const tabs = []; for (const entry of entries) { const descriptor = entry.descriptor; if (descriptor.surface !== "tab") continue; if (!(descriptor.requiredScopes ?? []).every((scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed)) continue; tabs.push({ pluginId: entry.pluginId, id: descriptor.id, label: descriptor.label, description: descriptor.description, icon: descriptor.icon, path: descriptor.path, placement: descriptor.placement, group: descriptor.group, order: descriptor.order }); } return tabs.toSorted((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.label.localeCompare(right.label) || left.id.localeCompare(right.id)); } /** Lists active plugins' tab descriptors visible to the presented scopes. */ function listControlUiPluginTabs(scopes, opts = {}) { const registry = getActivePluginSessionExtensionRegistry(); return projectControlUiPluginTabs(registry?.controlUiDescriptors ?? [], scopes).flatMap((tab) => { const route = registry ? findControlUiTabGatewayRoute(registry, tab) : void 0; if (route === null) return []; return route && opts.requireGatewayAuthGrant !== false ? [{ ...tab, requiresGatewayAuth: true }] : [tab]; }); } /** Lists active plugins' trusted widget kinds visible to the presented scopes. */ function listControlUiPluginWidgetKinds(scopes) { const registry = getActivePluginSessionExtensionRegistry(); const entries = registry?.controlUiDescriptors ?? []; const disabled = new Set(registry?.plugins.filter((plugin) => plugin.controlUi && !isControlUiPluginAllowed(plugin)).map((plugin) => plugin.id)); const coreEntries = authorizeOperatorScopesForRequiredScope("operator.read", scopes).allowed ? CORE_CONTROL_UI_WIDGET_KINDS : []; const pluginEntries = entries.flatMap((entry) => { const descriptor = entry.descriptor; if (descriptor.surface !== "widget" || disabled.has(entry.pluginId)) return []; return (descriptor.requiredScopes ?? []).every((scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed) ? [{ pluginId: entry.pluginId, kind: `${entry.pluginId}:${descriptor.id}`, label: descriptor.label }] : []; }); return [...coreEntries, ...pluginEntries].toSorted((left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind)); } /** Grants read access to active native assets and visible same-plugin Gateway tabs. */ function listControlUiPluginTabAuthGrants(callerScopes) { const registry = getActivePluginSessionExtensionRegistry(); if (!registry || !authorizeOperatorScopesForRequiredScope("operator.read", callerScopes).allowed) return []; const grants = /* @__PURE__ */ new Map(); const basePath = getRuntimeConfigSnapshot()?.gateway?.controlUi?.basePath; for (const plugin of registry.plugins) { if (!plugin.enabled || plugin.status !== "loaded" || !plugin.controlUi || !isControlUiPluginAllowed(plugin)) continue; const assetPath = controlUiPluginAssetPrefix(plugin.id, basePath); grants.set(`${plugin.id}\n${assetPath}`, { pluginId: plugin.id, path: assetPath, match: "prefix", scopes: [READ_SCOPE] }); } for (const tab of projectControlUiPluginTabs(registry.controlUiDescriptors ?? [], callerScopes)) { if (!tab.path) continue; const route = findControlUiTabGatewayRoute(registry, tab); if (!route) continue; const key = `${tab.pluginId}\n${route.path}`; const existing = grants.get(key); if (existing) { if (existing.match === "exact" && route.match === "prefix") grants.set(key, { ...existing, match: "prefix" }); continue; } grants.set(key, { pluginId: tab.pluginId, path: route.path, match: route.match, scopes: [READ_SCOPE] }); } return [...grants.values()]; } //#endregion //#region src/infra/host-account-name.ts let cachedName; async function readAccountCommand(command, args) { try { const { stdout } = await runExec(command, args, { timeoutMs: 1e3, maxBuffer: 16384, logOutput: false }); return stdout; } catch { return null; } } async function readHostAccountName() { if (process.platform !== "darwin" && process.platform !== "linux") return null; const { username } = os.userInfo(); const normalizeName = (value) => { const name = value?.trim(); return name && name.toLowerCase() !== username.toLowerCase() ? truncateUtf16Safe(name, 256) : null; }; if (process.platform === "darwin") { const fullName = normalizeName(await readAccountCommand("/usr/bin/id", ["-F"])); if (fullName) return fullName; return normalizeName((await readAccountCommand("/usr/bin/dscl", [ ".", "-read", `/Users/${username}`, "RealName" ]))?.replace(/^RealName:\s*/u, "")); } const fields = (await readAccountCommand("getent", ["passwd", username]) ?? await fs.readFile("/etc/passwd", "utf8")).split("\n").find((line) => line.split(":", 1)[0] === username)?.split(":"); return normalizeName(fields?.[4]?.split(",", 1)[0]); } /** Best-effort human name for the gateway host account; never seeds a login name. */ function resolveHostAccountName() { cachedName ??= readHostAccountName().catch(() => null); return cachedName; } //#endregion //#region src/gateway/gateway-owner-profile.ts /** Owner attribution never supplies a verified login or changes operator authority. */ function shouldUseGatewayOwnerProfile(params) { return params.role === "operator" && !params.authenticatedUserId && (!params.rolesConfigured || params.authMethod === "token" || params.authMethod === "password"); } //#endregion //#region src/gateway/github-user-identity.ts const CLOUDFLARE_ACCESS_USER_HEADER = "cf-access-authenticated-user-email"; const CLOUDFLARE_ACCESS_ASSERTION_HEADER = "cf-access-jwt-assertion"; const CLOUDFLARE_ACCESS_HOST_SUFFIX = ".cloudflareaccess.com"; const CLOUDFLARE_ACCESS_IDENTITY_PATH = "/cdn-cgi/access/get-identity"; const ACCESS_ASSERTION_MAX_BYTES = 16384; const ACCESS_IDENTITY_MAX_BYTES = 65536; const JWT_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/u; const GITHUB_IDENTITY_CACHE_MS = 9e5; const GITHUB_IDENTITY_CACHE_LIMIT = 200; const GITHUB_ETAG_MAX_LENGTH = 1024; const identityMetadataCaches = /* @__PURE__ */ new WeakMap(); function headerValue(value) { return Array.isArray(value) ? value[0] : value; } function cloudflareAccessIssuer(assertion) { if (Buffer.byteLength(assertion, "utf8") > ACCESS_ASSERTION_MAX_BYTES) throw new Error("Cloudflare Access assertion is invalid"); const segments = assertion.split("."); if (segments.length !== 3 || segments.some((segment) => !JWT_SEGMENT_PATTERN.test(segment))) throw new Error("Cloudflare Access assertion is invalid"); let payload; try { payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf8")); } catch { throw new Error("Cloudflare Access assertion is invalid"); } if (!isRecord(payload) || typeof payload.iss !== "string") throw new Error("Cloudflare Access assertion issuer is invalid"); let issuer; try { issuer = new URL(payload.iss); } catch { throw new Error("Cloudflare Access assertion issuer is invalid"); } if (issuer.protocol !== "https:" || issuer.username || issuer.password || issuer.port || issuer.pathname !== "/" || issuer.search || issuer.hash || !issuer.hostname.endsWith(CLOUDFLARE_ACCESS_HOST_SUFFIX)) throw new Error("Cloudflare Access assertion issuer is invalid"); return issuer; } async function resolveCloudflareAccessIdentity(assertion, authenticatedPrincipal) { const issuer = cloudflareAccessIssuer(assertion); let payload; try { const response = await fetch(`${issuer.origin}${CLOUDFLARE_ACCESS_IDENTITY_PATH}`, { headers: { Cookie: `CF_Authorization=${assertion}` }, redirect: "manual", signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) }); if (!response.ok) { await response.body?.cancel().catch(() => {}); throw new Error("identity response was not successful"); } const body = await readBoundedResponse(response, ACCESS_IDENTITY_MAX_BYTES); payload = JSON.parse(body.toString("utf8")); } catch { throw new Error("Cloudflare Access identity lookup failed"); } if (!isRecord(payload)) throw new Error("Cloudflare Access identity response is invalid"); const email = typeof payload.email === "string" ? payload.email.trim() : ""; if (!email || email.toLowerCase() !== authenticatedPrincipal.trim().toLowerCase()) throw new Error("Cloudflare Access identity principal did not match"); if (!isRecord(payload.idp) || payload.idp.type !== "github") throw new Error("Cloudflare Access identity is not GitHub-backed"); if (typeof payload.id !== "number" || !Number.isSafeInteger(payload.id) || payload.id <= 0) throw new Error("Cloudflare Access GitHub account id is invalid"); const initialDisplayName = typeof payload.name === "string" && payload.name.trim() ? payload.name : void 0; return { accountId: payload.id, ...initialDisplayName ? { initialDisplayName } : {} }; } async function resolveGitHubUserIdentityByLogin(username) { const requestedLogin = normalizeGitHubLogin(username); if (!requestedLogin) throw new TypeError("GitHub username is invalid"); const token = githubApiToken(); let payload; try { payload = await fetchGitHubJson(`${GITHUB_API_ORIGIN}/users/${encodeURIComponent(requestedLogin)}`, fetch, token); } catch (error) { if (error instanceof ControlUiGitHubError) throw error; throw new ControlUiGitHubError(502, "GitHub request failed"); } if (!isRecord(payload)) throw new ControlUiGitHubError(502, "GitHub response was not an object"); const accountId = payload.id; if (!Number.isSafeInteger(accountId) || typeof accountId !== "number" || accountId <= 0) throw new ControlUiGitHubError(502, "GitHub response omitted a valid account id"); return parseGitHubUserIdentity(accountId, payload); } function parseGitHubUserIdentity(accountId, payload) { if (!isRecord(payload) || payload.id !== accountId) throw new ControlUiGitHubError(502, "GitHub account id did not match"); const login = typeof payload.login === "string" ? normalizeGitHubLogin(payload.login) : void 0; if (!login) throw new ControlUiGitHubError(502, "GitHub response omitted a valid login"); return { accountId, login, name: readNonBlankString(payload.name) }; } function resolveGitHubUserIdentityById(accountId, token, fetchImpl) { const cache = identityMetadataCaches.get(fetchImpl) ?? { values: /* @__PURE__ */ new Map(), pending: /* @__PURE__ */ new Map() }; identityMetadataCaches.set(fetchImpl, cache); const key = `${accountId}:${githubApiCredentialCacheScope(token)}`; const cached = cache.values.get(key); if (cached && cached.expiresAt > Date.now()) return Promise.resolve({ identity: cached.identity, refreshed: false }); const promise = getOrCreatePromise(cache.pending, key, async () => { try { const response = await fetchGitHubApi(`${GITHUB_API_ORIGIN}/user/${accountId}`, fetchImpl, token, void 0, void 0, cached?.etag); let identity; if (response.status === 304 && cached?.etag) { await discardResponse(response); identity = cached.identity; } else identity = parseGitHubUserIdentity(accountId, await readGitHubJsonResponse(response)); const rawEtag = response.headers.get("etag") ?? (response.status === 304 ? cached?.etag : void 0); const etag = rawEtag && rawEtag.length <= GITHUB_ETAG_MAX_LENGTH ? rawEtag : void 0; if (cache.pending.get(key) === promise) { cache.values.set(key, { identity, etag, expiresAt: Date.now() + GITHUB_IDENTITY_CACHE_MS }); pruneMapToMaxSize(cache.values, GITHUB_IDENTITY_CACHE_LIMIT); } return identity; } catch (error) { if (cache.pending.get(key) === promise && error instanceof ControlUiGitHubError && !error.retryable) cache.values.delete(key); throw error; } }, { evictOnSettled: true }); pruneMapToMaxSize(cache.pending, GITHUB_IDENTITY_CACHE_LIMIT); return promise.then((identity) => ({ identity, refreshed: true })); } function cloudflareAccessAssertion(params) { const trustedProxy = params.authConfig?.trustedProxy; if (!params.authResult.ok || params.authResult.method !== "trusted-proxy" || params.authConfig?.mode !== "trusted-proxy" || normalizeLowercaseStringOrEmpty(trustedProxy?.userHeader) !== CLOUDFLARE_ACCESS_USER_HEADER || !trustedProxy?.requiredHeaders?.some((header) => normalizeLowercaseStringOrEmpty(header) === CLOUDFLARE_ACCESS_ASSERTION_HEADER)) return; const principal = params.authResult.user?.trim(); const assertion = headerValue(params.requestHeaders?.[CLOUDFLARE_ACCESS_ASSERTION_HEADER])?.trim(); return principal && assertion ? { assertion, principal } : void 0; } function createAuthenticatedGitHubIdentitySync(params) { const tailscaleLogin = params.authResult.tailscaleIdentity ? classifyTailscaleLogin(params.authResult.tailscaleIdentity.login) : void 0; if (tailscaleLogin?.kind === "provider" && tailscaleLogin.provider === "github") return createLazyPromise(async () => { const identity = await resolveGitHubUserIdentityByLogin(tailscaleLogin.subject); const profile = syncGitHubIdentity({ identity, authenticationAlias: { kind: "github-login", login: tailscaleLogin.subject }, initialDisplayName: params.authResult.tailscaleIdentity?.name }); return { profileId: profile.id, updatedAt: profile.updatedAt }; }); const access = cloudflareAccessAssertion(params); if (!access) return; return createLazyPromise(async () => { const accessIdentity = await resolveCloudflareAccessIdentity(access.assertion, access.principal); const identityBinding = { accountId: accessIdentity.accountId, email: access.principal }; const token = githubApiToken(); let lookup; try { lookup = await withOptionalGitHubAuth(token, (requestToken) => resolveGitHubUserIdentityById(accessIdentity.accountId, requestToken, fetch)); } catch (error) { if (error instanceof ControlUiGitHubError && error.retryable) { const cached = resolveCachedGitHubIdentity(identityBinding); if (cached) return cached; } throw error instanceof ControlUiGitHubError ? error : new ControlUiGitHubError(502, "GitHub request failed"); } if (!lookup.refreshed) { const cached = resolveCachedGitHubIdentity(identityBinding); if (cached) return cached; } const profile = syncGitHubIdentity({ identity: lookup.identity, authenticationAlias: { kind: "email", email: access.principal }, initialDisplayName: accessIdentity.initialDisplayName }); return { profileId: profile.id, updatedAt: profile.updatedAt }; }); } //#endregion export { listControlUiPluginTabs as a, isControlUiPluginAllowed as c, CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY as d, CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY as f, listControlUiPluginTabAuthGrants as i, CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS as l, shouldUseGatewayOwnerProfile as n, listControlUiPluginWidgetKinds as o, resolveHostAccountName as r, CUSTOM_PLUGIN_UI_DISABLED_MESSAGE as s, createAuthenticatedGitHubIdentitySync as t, CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE as u };