openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
390 lines (389 loc) • 18.6 kB
JavaScript
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 };