UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

192 lines (191 loc) 9.57 kB
import path from "node:path"; import crypto from "node:crypto"; //#region src/fleet/cell-profile.ts const DEFAULT_FLEET_IMAGE = "ghcr.io/openclaw/openclaw:latest"; const FLEET_BASE_PORT = 19100; const FLEET_GATEWAY_PORT = 18789; const FLEET_CONTAINER_HOME = "/home/node"; const FLEET_CONTAINER_STATE_DIR = "/home/node/.openclaw"; const FLEET_CONTAINER_AUTH_SECRET_DIR = "/home/node/.config/openclaw"; const FLEET_TENANT_LABEL = "openclaw.fleet.tenant"; const FLEET_OWNER_LABEL = "openclaw.fleet.owner"; const FLEET_ATTEMPT_LABEL = "openclaw.fleet.attempt"; const FLEET_ENV_KEYS_LABEL = "openclaw.fleet.env-keys"; const FLEET_DISK_LIMIT_LABEL = "openclaw.fleet.disk-limit"; const FLEET_MANAGED_ENV_KEYS = [ "HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_WORKSPACE_DIR", "OPENCLAW_GATEWAY_TOKEN" ]; const FLEET_TENANT_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; const MAX_TCP_PORT = 65535; const RESERVED_ENV_KEYS = new Set(FLEET_MANAGED_ENV_KEYS); function hasInvalidEnvironmentFileValue(value) { return value.includes("\n") || value.includes("\r") || value.includes("\0"); } function validateTenantId(tenantId) { if (!FLEET_TENANT_PATTERN.test(tenantId)) throw new Error("Invalid tenant id. Use 1-40 lowercase letters, digits, or hyphens; start and end with a letter or digit."); return tenantId; } function validateFleetImage(image) { const normalized = image.trim(); if (!normalized) throw new Error("Fleet container image must not be empty."); if (normalized.startsWith("-")) throw new Error("Fleet container image must not begin with '-'."); return normalized; } function validateDiskSize(value) { const normalized = value.trim(); const numeric = Number.parseFloat(normalized); if (!/^[0-9]+(?:\.[0-9]+)?(?:b|k|kb|m|mb|g|gb|t|tb)?$/iu.test(normalized) || !Number.isFinite(numeric) || numeric <= 0) throw new Error("--disk must be a positive size such as 10g, 512m, or 1024."); return normalized; } function validateHostPort(port) { if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) throw new Error("Host port must be an integer from 1 to 65535."); return port; } function allocateHostPort(usedPorts, requestedPort) { const used = new Set(usedPorts); if (requestedPort !== void 0) { const port = validateHostPort(requestedPort); if (used.has(port)) throw new Error(`Host port ${port} is already allocated to another fleet cell.`); return port; } for (let port = FLEET_BASE_PORT; port <= MAX_TCP_PORT; port += 1) if (!used.has(port)) return port; throw new Error(`No free fleet host ports remain from ${FLEET_BASE_PORT} to ${MAX_TCP_PORT}.`); } function parseEnvAssignments(values) { const environment = {}; for (const assignment of values) { const separator = assignment.indexOf("="); if (separator <= 0) throw new Error("Invalid --env value; expected KEY=VAL."); const key = assignment.slice(0, separator); if (!ENV_KEY_PATTERN.test(key)) throw new Error("Invalid --env key; use letters, digits, and underscores."); if (RESERVED_ENV_KEYS.has(key)) throw new Error(`--env cannot override fleet-managed variable ${key}.`); environment[key] = assignment.slice(separator + 1); } return environment; } function buildCellEnvironment(token, userEnv) { if (hasInvalidEnvironmentFileValue(token)) throw new Error("Gateway token must not contain line breaks or null bytes."); for (const key of Object.keys(userEnv)) { if (!ENV_KEY_PATTERN.test(key)) throw new Error(`Invalid cell environment key: ${key}`); if (RESERVED_ENV_KEYS.has(key)) throw new Error(`Cell environment cannot override fleet-managed variable ${key}.`); if (hasInvalidEnvironmentFileValue(userEnv[key] ?? "")) throw new Error(`Cell environment value for ${key} must be one line.`); } return { HOME: FLEET_CONTAINER_HOME, OPENCLAW_HOME: FLEET_CONTAINER_HOME, OPENCLAW_STATE_DIR: FLEET_CONTAINER_STATE_DIR, OPENCLAW_CONFIG_PATH: `${FLEET_CONTAINER_STATE_DIR}/openclaw.json`, OPENCLAW_WORKSPACE_DIR: `${FLEET_CONTAINER_STATE_DIR}/workspace`, OPENCLAW_GATEWAY_TOKEN: token, ...userEnv }; } function cellContainerName(tenantId) { return `openclaw-cell-${validateTenantId(tenantId)}`; } function cellNetworkName(tenantId) { return `${cellContainerName(tenantId)}-net`; } function cellDataDir(stateDir, tenantId) { return path.join(stateDir, "fleet", "cells", validateTenantId(tenantId)); } function cellAuthSecretDir(stateDir, tenantId) { return path.join(stateDir, "fleet", "auth-profile-secrets", validateTenantId(tenantId)); } function cellOwnerId(dataDir) { return crypto.createHash("sha256").update(path.resolve(dataDir)).digest("hex").slice(0, 32); } function validateCellContainerProfile(profile) { validateTenantId(profile.tenantId); validateHostPort(profile.hostPort); if (profile.containerName !== cellContainerName(profile.tenantId)) throw new Error(`Fleet container name must be ${cellContainerName(profile.tenantId)}.`); if (profile.networkName !== cellNetworkName(profile.tenantId)) throw new Error(`Fleet network name must be ${cellNetworkName(profile.tenantId)}.`); if (validateFleetImage(profile.image) !== profile.image) throw new Error("Fleet container image must not have surrounding whitespace."); if (!profile.dataDir.trim()) throw new Error("Fleet cell data directory must not be empty."); if (!profile.authSecretDir.trim()) throw new Error("Fleet cell auth-secret directory must not be empty."); if (profile.ownerId !== cellOwnerId(profile.dataDir)) throw new Error("Fleet cell owner id does not match its data directory."); if (!/^[a-f0-9]{32}$/u.test(profile.attemptId)) throw new Error("Fleet cell attempt id must be 32 lowercase hexadecimal characters."); if (!profile.memory.trim()) throw new Error("Fleet cell memory limit must not be empty."); if (!profile.cpus.trim()) throw new Error("Fleet cell CPU limit must not be empty."); if (profile.diskSize !== void 0 && validateDiskSize(profile.diskSize) !== profile.diskSize) throw new Error("Fleet cell --disk limit must not have surrounding whitespace."); const cpus = Number(profile.cpus); if (!Number.isFinite(cpus) || cpus <= 0) throw new Error("Fleet cell CPU limit must be a positive number."); if (!Number.isInteger(profile.pidsLimit) || profile.pidsLimit < 1) throw new Error("Fleet cell PID limit must be a positive integer."); if (profile.containerUser) { if (!Number.isInteger(profile.containerUser.uid) || profile.containerUser.uid < 0 || !Number.isInteger(profile.containerUser.gid) || profile.containerUser.gid < 0) throw new Error("Container uid and gid must be non-negative integers."); if (profile.containerUser.mode === "podman-keep-id" && profile.runtime !== "podman") throw new Error("keep-id user mapping requires Podman."); } for (const [key, value] of Object.entries(profile.environment)) if (!ENV_KEY_PATTERN.test(key) || hasInvalidEnvironmentFileValue(value)) throw new Error(`Invalid fleet cell environment entry: ${key}`); } function buildCellContainerArgs(profile, operation, options) { validateCellContainerProfile(profile); if (!options.environmentFile.trim()) throw new Error("Fleet cell environment file path must not be empty."); const containerUserArgs = profile.containerUser ? [ ...profile.containerUser.mode === "podman-keep-id" ? ["--userns=keep-id"] : [], "--user", `${profile.containerUser.uid}:${profile.containerUser.gid}` ] : []; const userEnvironmentKeys = Object.keys(profile.environment).filter((key) => !RESERVED_ENV_KEYS.has(key)).toSorted(); const mountSuffix = profile.selinuxRelabel ? ":Z" : ""; return [ operation, ...operation === "run" ? ["-d"] : [], "--name", profile.containerName, "--label", `${FLEET_TENANT_LABEL}=${profile.tenantId}`, "--label", `${FLEET_OWNER_LABEL}=${profile.ownerId}`, "--label", `${FLEET_ATTEMPT_LABEL}=${profile.attemptId}`, "--label", `${FLEET_ENV_KEYS_LABEL}=${userEnvironmentKeys.join(",")}`, ...profile.diskSize ? ["--label", `${FLEET_DISK_LIMIT_LABEL}=${profile.diskSize}`] : [], "--init", ...containerUserArgs, "--cap-drop=ALL", "--security-opt", "no-new-privileges", "--pids-limit", String(profile.pidsLimit), "--memory", profile.memory, "--cpus", profile.cpus, ...profile.diskSize ? ["--storage-opt", `size=${profile.diskSize}`] : [], "--restart", "unless-stopped", "--network", profile.networkName, "-p", `127.0.0.1:${profile.hostPort}:${FLEET_GATEWAY_PORT}`, "--volume", `${profile.dataDir}:${FLEET_CONTAINER_STATE_DIR}${mountSuffix}`, "--volume", `${profile.authSecretDir}:${FLEET_CONTAINER_AUTH_SECRET_DIR}${mountSuffix}`, "--env-file", options.environmentFile, profile.image, "node", "dist/index.js", "gateway", "--bind", "lan", "--port", String(FLEET_GATEWAY_PORT) ]; } function buildCellRunArgs(profile, options) { return buildCellContainerArgs(profile, "run", options); } function buildCellCreateArgs(profile, options) { return buildCellContainerArgs(profile, "create", options); } //#endregion export { parseEnvAssignments as _, FLEET_GATEWAY_PORT as a, validateFleetImage as b, allocateHostPort as c, buildCellRunArgs as d, cellAuthSecretDir as f, cellOwnerId as g, cellNetworkName as h, FLEET_ENV_KEYS_LABEL as i, buildCellCreateArgs as l, cellDataDir as m, FLEET_ATTEMPT_LABEL as n, FLEET_OWNER_LABEL as o, cellContainerName as p, FLEET_DISK_LIMIT_LABEL as r, FLEET_TENANT_LABEL as s, DEFAULT_FLEET_IMAGE as t, buildCellEnvironment as u, validateCellContainerProfile as v, validateTenantId as x, validateDiskSize as y };