Files
NianToB/electron/gateway/config-sync.ts

621 lines
24 KiB
TypeScript

import { app } from 'electron';
import path from 'path';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
function fsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
function buildNodePathEnv(baseEnv: Record<string, string | undefined>, entries: string[]): string | undefined {
const existing = baseEnv.NODE_PATH?.split(path.delimiter).filter(Boolean) ?? [];
const candidates = entries
.map((entry) => entry.trim())
.filter(Boolean)
.filter((entry) => existsSync(fsPath(entry)));
const merged = [...new Set([...candidates, ...existing])];
return merged.length > 0 ? merged.join(path.delimiter) : undefined;
}
import { getAllSettings } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
import { getOpenClawResolvedDir, isOpenClawPresent } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { cleanupDanglingWeChatPluginState, listConfiguredChannelsFromConfig, readOpenClawConfig, writeOpenClawConfig, type ChannelConfigData } from '../utils/channel-config';
import { sanitizeOpenClawConfig, batchSyncConfigFields } from '../utils/openclaw-auth';
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { buildDotnetEnv } from '../utils/dotnet-runtime';
import { buildPlaywrightRuntimeEnv, ensureYinianPlaywrightRuntimeDirs } from '../utils/playwright-runtime';
import { copyPluginFromNodeModules, ensureCloudSyncPluginInstalled, fixupPluginManifest, cpSyncSafe, hasPluginRuntimeEntry } from '../utils/plugin-install';
import { stripSystemdSupervisorEnv } from './config-sync-env';
import { ensureYinianModelRuntimeConfigured } from '../utils/model-diagnostics';
import { cleanupOpenClawUserNativeClipboard } from '../utils/optional-native-cleanup';
import { syncDefaultProviderToRuntime } from '../services/providers/provider-runtime-sync';
export interface GatewayLaunchContext {
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
openclawDir: string;
entryScript: string;
gatewayArgs: string[];
forkEnv: Record<string, string | undefined>;
mode: 'dev' | 'packaged';
binPathExists: boolean;
loadedProviderKeyCount: number;
proxySummary: string;
channelStartupSummary: string;
}
// ── Auto-upgrade bundled plugins on startup ──────────────────────
const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> = {
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
feishu: { dirName: 'openclaw-lark', npmName: '@larksuite/openclaw-lark' },
};
const REMOVED_CHANNEL_PLUGIN_DIRS = ['dingtalk', 'wecom', 'feishu-openclaw-plugin'];
/**
* OpenClaw 3.22+ ships Discord, Telegram, and other channels as built-in
* extensions. If a previous ClawX version copied one of these into
* ~/.openclaw/extensions/, the broken copy overrides the working built-in
* plugin and must be removed.
*/
const BUILTIN_CHANNEL_EXTENSIONS = ['discord', 'telegram', 'qqbot'];
const CLOUD_SYNC_PLUGIN_ID = 'cloud-sync';
const DEFAULT_CLOUD_SYNC_SERVER_URL = 'https://onefeel.brother7.cn';
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function normalizeServerUrl(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed.replace(/\/+$/, '');
}
export function deriveCloudSyncServerUrl(): string {
const explicit = normalizeServerUrl(
process.env.YINIAN_CLOUD_SYNC_SERVER_URL ?? process.env.CLOUDCLAW_SERVER_URL,
);
if (explicit) return explicit;
const apiBaseUrl = normalizeServerUrl(process.env.YINIAN_API_BASE_URL);
if (apiBaseUrl) {
return apiBaseUrl.replace(/\/ingress$/i, '');
}
return DEFAULT_CLOUD_SYNC_SERVER_URL;
}
export async function ensureCloudSyncPluginConfigured(): Promise<void> {
if (process.env.YINIAN_CLOUD_SYNC_ENABLED === '0') {
return;
}
const installResult = ensureCloudSyncPluginInstalled();
if (installResult.warning) {
logger.warn(`[plugin] Cloud Sync: ${installResult.warning}`);
}
if (!installResult.installed) {
return;
}
const serverUrl = deriveCloudSyncServerUrl();
const config = await readOpenClawConfig();
config.plugins ??= {};
config.plugins.enabled = true;
const allow = Array.isArray(config.plugins.allow) ? config.plugins.allow : [];
if (!allow.includes(CLOUD_SYNC_PLUGIN_ID)) {
config.plugins.allow = [...allow, CLOUD_SYNC_PLUGIN_ID];
}
config.plugins.entries ??= {};
const existingEntry = config.plugins.entries[CLOUD_SYNC_PLUGIN_ID];
const entry: ChannelConfigData = isPlainRecord(existingEntry) ? { ...existingEntry } : {};
const existingEntryConfig = isPlainRecord(entry.config) ? entry.config : {};
entry.enabled = true;
entry.config = {
...existingEntryConfig,
serverUrl,
};
config.plugins.entries[CLOUD_SYNC_PLUGIN_ID] = entry;
await writeOpenClawConfig(config);
}
function cleanupStaleBuiltInExtensions(): void {
for (const ext of BUILTIN_CHANNEL_EXTENSIONS) {
const extDir = join(homedir(), '.openclaw', 'extensions', ext);
if (existsSync(fsPath(extDir))) {
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
try {
rmSync(fsPath(extDir), { recursive: true, force: true });
} catch (err) {
logger.warn(`[plugin] Failed to remove stale extension ${ext}:`, err);
}
}
}
}
function readPluginVersion(pkgJsonPath: string): string | null {
try {
const raw = readFileSync(fsPath(pkgJsonPath), 'utf-8');
const parsed = JSON.parse(raw) as { version?: string };
return parsed.version ?? null;
} catch {
return null;
}
}
function buildBundledPluginSources(pluginDirName: string): string[] {
return app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'resources', 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', pluginDirName),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', pluginDirName),
join(app.getAppPath(), 'resources', 'openclaw-plugins', pluginDirName),
join(process.cwd(), 'build', 'openclaw-plugins', pluginDirName),
join(process.cwd(), 'resources', 'openclaw-plugins', pluginDirName),
];
}
/**
* Auto-upgrade all configured channel plugins before Gateway start.
* - Packaged mode: uses bundled plugins from resources/ (includes deps)
* - Dev mode: falls back to node_modules/ with pnpm-aware dep collection
*/
function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
for (const channelType of configuredChannels) {
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
if (!pluginInfo) continue;
const { dirName, npmName } = pluginInfo;
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
const targetManifest = join(targetDir, 'openclaw.plugin.json');
const isInstalled = existsSync(fsPath(targetManifest));
const installedVersion = isInstalled ? readPluginVersion(join(targetDir, 'package.json')) : null;
// Try bundled sources first (packaged mode or if bundle-plugins was run)
const bundledSources = buildBundledPluginSources(dirName);
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
if (bundledDir) {
const sourceVersion = readPluginVersion(join(bundledDir, 'package.json'));
const installedRuntimeReady = isInstalled ? hasPluginRuntimeEntry(targetDir) : false;
const sourceRuntimeReady = hasPluginRuntimeEntry(bundledDir);
// Install or upgrade if version differs or plugin not installed
if (!isInstalled || (sourceVersion && installedVersion && sourceVersion !== installedVersion) || (!installedRuntimeReady && sourceRuntimeReady)) {
const reinstallReason = isInstalled && !installedRuntimeReady && sourceRuntimeReady ? 'repairing missing runtime entry for' : (isInstalled ? 'Auto-upgrading' : 'Installing');
logger.info(`[plugin] ${reinstallReason} ${channelType} plugin${isInstalled ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (bundled)`);
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
rmSync(fsPath(targetDir), { recursive: true, force: true });
cpSyncSafe(bundledDir, targetDir);
fixupPluginManifest(targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
}
} else if (isInstalled) {
// Same version already installed — still patch manifest ID in case it was
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
fixupPluginManifest(targetDir);
}
continue;
}
// Dev mode fallback: copy from node_modules/ with pnpm dep resolution
if (!app.isPackaged) {
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
if (!existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) continue;
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (!sourceVersion) continue;
const installedRuntimeReady = isInstalled ? hasPluginRuntimeEntry(targetDir) : false;
const sourceRuntimeReady = hasPluginRuntimeEntry(npmPkgPath);
// Skip only if installed AND same version — but still patch manifest ID.
if (isInstalled && installedVersion && sourceVersion === installedVersion && (installedRuntimeReady || !sourceRuntimeReady)) {
fixupPluginManifest(targetDir);
continue;
}
const reinstallReason = isInstalled && !installedRuntimeReady && sourceRuntimeReady ? 'Repairing missing runtime entry for' : (isInstalled ? 'Auto-upgrading' : 'Installing');
logger.info(`[plugin] ${reinstallReason} ${channelType} plugin${isInstalled ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
}
}
}
}
/**
* Remove channel plugin extensions from ~/.openclaw/extensions/ when their
* corresponding channel is no longer configured. This prevents the Gateway
* from scanning residual plugin manifests that were installed by a previous
* configuration but are no longer needed.
*/
function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): void {
const configuredSet = new Set(configuredChannels);
for (const dirName of REMOVED_CHANNEL_PLUGIN_DIRS) {
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
if (!existsSync(fsPath(targetDir))) continue;
logger.info(`[plugin] Removing disabled channel plugin: ${dirName}`);
try {
rmSync(fsPath(targetDir), { recursive: true, force: true });
} catch (err) {
logger.warn(`[plugin] Failed to remove disabled channel plugin ${dirName}:`, err);
}
}
for (const [channelType, pluginInfo] of Object.entries(CHANNEL_PLUGIN_MAP)) {
if (configuredSet.has(channelType)) continue;
const { dirName } = pluginInfo;
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
if (!existsSync(fsPath(targetDir))) continue;
logger.info(`[plugin] Removing unconfigured channel plugin: ${channelType} (${dirName})`);
try {
rmSync(fsPath(targetDir), { recursive: true, force: true });
} catch (err) {
logger.warn(`[plugin] Failed to remove unconfigured channel plugin ${channelType}:`, err);
}
}
}
/**
* Ensure extension-specific packages are resolvable from shared dist/ chunks.
*
* OpenClaw's Rollup bundler creates shared chunks in dist/ (e.g.
* sticker-cache-*.js) that eagerly `import "grammy"`. ESM bare specifier
* resolution walks from the importing file's directory upward:
* dist/node_modules/ → openclaw/node_modules/ → …
* It does NOT search `dist/extensions/telegram/node_modules/`.
*
* NODE_PATH only works for CJS require(), NOT for ESM import statements.
*
* Fix: create symlinks in openclaw/node_modules/ pointing to packages in
* dist/extensions/<ext>/node_modules/. This makes the standard ESM
* resolution algorithm find them. Skip-if-exists avoids overwriting
* openclaw's own deps (they take priority).
*/
let _extensionDepsLinked = false;
/**
* Reset the extension-deps-linked cache so the next
* ensureExtensionDepsResolvable() call re-scans and links.
* Called before each Gateway launch to pick up newly installed extensions.
*/
export function resetExtensionDepsLinked(): void {
_extensionDepsLinked = false;
}
function ensureExtensionDepsResolvable(openclawDir: string): void {
if (_extensionDepsLinked) return;
const extDir = join(openclawDir, 'dist', 'extensions');
const topNM = join(openclawDir, 'node_modules');
let linkedCount = 0;
try {
if (!existsSync(extDir)) return;
for (const ext of readdirSync(extDir, { withFileTypes: true })) {
if (!ext.isDirectory()) continue;
const extNM = join(extDir, ext.name, 'node_modules');
if (!existsSync(extNM)) continue;
for (const pkg of readdirSync(extNM, { withFileTypes: true })) {
if (pkg.name === '.bin') continue;
if (pkg.name.startsWith('@')) {
// Scoped package — iterate sub-entries
const scopeDir = join(extNM, pkg.name);
let scopeEntries;
try { scopeEntries = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
for (const sub of scopeEntries) {
if (!sub.isDirectory()) continue;
const dest = join(topNM, pkg.name, sub.name);
if (existsSync(dest)) continue;
try {
mkdirSync(join(topNM, pkg.name), { recursive: true });
symlinkSync(join(scopeDir, sub.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
} else {
const dest = join(topNM, pkg.name);
if (existsSync(dest)) continue;
try {
mkdirSync(topNM, { recursive: true });
symlinkSync(join(extNM, pkg.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
}
}
} catch {
// extensions dir may not exist or be unreadable — non-fatal
}
if (linkedCount > 0) {
logger.info(`[extension-deps] Linked ${linkedCount} extension packages into ${topNM}`);
}
_extensionDepsLinked = true;
}
// ── Pre-launch sync ──────────────────────────────────────────────
export async function syncGatewayConfigBeforeLaunch(
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
): Promise<void> {
// Reset the extension-deps cache so that newly installed extensions
// (e.g. user added a channel while the app was running) get their
// node_modules linked on the next Gateway spawn.
resetExtensionDepsLinked();
try {
const removedClipboardPackages = cleanupOpenClawUserNativeClipboard();
if (removedClipboardPackages > 0) {
logger.info(`[plugin] Removed optional native clipboard packages from user OpenClaw directories (${removedClipboardPackages})`);
}
} catch (err) {
logger.warn('Failed to clean optional native clipboard packages:', err);
}
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
try {
await sanitizeOpenClawConfig();
} catch (err) {
logger.warn('Failed to sanitize openclaw.json:', err);
}
try {
await cleanupDanglingWeChatPluginState();
} catch (err) {
logger.warn('Failed to clean dangling WeChat plugin state before launch:', err);
}
try {
await ensureCloudSyncPluginConfigured();
} catch (err) {
logger.warn('Failed to configure Cloud Sync plugin before launch:', err);
}
// Remove stale copies of built-in extensions (Discord, Telegram) that
// override OpenClaw's working built-in plugins and break channel loading.
try {
cleanupStaleBuiltInExtensions();
} catch (err) {
logger.warn('Failed to clean stale built-in extensions:', err);
}
// Auto-upgrade installed plugins before Gateway starts so that
// the plugin manifest ID matches what sanitize wrote to the config.
// Only install/upgrade plugins for channels that are actually configured
// in openclaw.json — do NOT expand the list from plugins.allow.
try {
const rawCfg = await readOpenClawConfig();
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
ensureConfiguredPluginsUpgraded(configuredChannels);
cleanupUnconfiguredChannelPlugins(configuredChannels);
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
}
// Batch gateway token, browser config, and session idle into one read+write cycle.
try {
await batchSyncConfigFields(appSettings.gatewayToken);
} catch (err) {
logger.warn('Failed to batch-sync config fields to openclaw.json:', err);
}
try {
await ensureYinianModelRuntimeConfigured();
} catch (err) {
logger.warn('Failed to configure Yinian model runtime defaults before launch:', err);
}
try {
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId) {
await syncDefaultProviderToRuntime(defaultProviderId);
}
} catch (err) {
logger.warn('Failed to sync default provider to OpenClaw before launch:', err);
}
}
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
const providerEnv: Record<string, string> = {};
const providerTypes = getKeyableProviderTypes();
let loadedProviderKeyCount = 0;
try {
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId) {
const defaultProvider = await getProvider(defaultProviderId);
const defaultProviderType = defaultProvider?.type;
const defaultProviderKey = await getApiKey(defaultProviderId);
if (defaultProviderType && defaultProviderKey) {
const envVar = getProviderEnvVar(defaultProviderType);
if (envVar) {
providerEnv[envVar] = defaultProviderKey;
loadedProviderKeyCount++;
}
}
}
} catch (err) {
logger.warn('Failed to load default provider key for environment injection:', err);
}
for (const providerType of providerTypes) {
try {
const key = await getApiKey(providerType);
if (key) {
const envVar = getProviderEnvVar(providerType);
if (envVar) {
providerEnv[envVar] = key;
loadedProviderKeyCount++;
}
}
} catch (err) {
logger.warn(`Failed to load API key for ${providerType}:`, err);
}
}
return { providerEnv, loadedProviderKeyCount };
}
async function resolveChannelStartupPolicy(): Promise<{
skipChannels: boolean;
channelStartupSummary: string;
}> {
try {
const rawCfg = await readOpenClawConfig();
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
if (configuredChannels.length === 0) {
return {
skipChannels: true,
channelStartupSummary: 'skipped(no configured channels)',
};
}
return {
skipChannels: false,
channelStartupSummary: `enabled(${configuredChannels.join(',')})`,
};
} catch (error) {
logger.warn('Failed to determine configured channels for gateway launch:', error);
return {
skipChannels: false,
channelStartupSummary: 'enabled(unknown)',
};
}
}
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
const openclawDir = getOpenClawResolvedDir();
const entryScript = join(openclawDir, 'openclaw.mjs');
if (!isOpenClawPresent()) {
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
const appSettings = await getAllSettings();
await syncGatewayConfigBeforeLaunch(appSettings);
if (!existsSync(entryScript)) {
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
}
const gatewayArgs = ['gateway', '--port', String(port), '--token', appSettings.gatewayToken, '--allow-unconfigured'];
const mode = app.isPackaged ? 'packaged' : 'dev';
const platform = process.platform;
const arch = process.arch;
const target = `${platform}-${arch}`;
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const bundledNodePath = path.join(binPath, process.platform === 'win32' ? 'node.exe' : 'node');
const bundledNpmCliPath = path.join(binPath, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
const bundledPackageManagerEnv = binPathExists && existsSync(bundledNodePath) && existsSync(bundledNpmCliPath)
? {
YINIAN_NODE_EXEC_PATH: bundledNodePath,
YINIAN_NPM_CLI_PATH: bundledNpmCliPath,
}
: {};
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
const uvEnv = await getUvMirrorEnv();
const proxyEnv = buildProxyEnv(appSettings);
const emptyBundledSkillsDir = join(homedir(), '.openclaw', '.yinian-empty-bundled-skills');
try {
mkdirSync(emptyBundledSkillsDir, { recursive: true });
} catch {
// Best effort. If this fails OpenClaw will fall back to its own defaults.
}
const resolvedProxy = resolveProxySettings(appSettings);
const proxySummary = appSettings.proxyEnabled
? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}`
: 'disabled';
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const baseEnvRecord = baseEnv as Record<string, string | undefined>;
const baseEnvPatched = binPathExists
? prependPathEntry(baseEnvRecord, binPath).env
: baseEnvRecord;
const baseEnvWithDotnet = buildDotnetEnv(baseEnvPatched);
ensureYinianPlaywrightRuntimeDirs();
const playwrightEnv = buildPlaywrightRuntimeEnv(baseEnvWithDotnet);
const nodePath = buildNodePathEnv(baseEnvPatched, [
join(process.cwd(), 'node_modules'),
join(app.getAppPath(), 'node_modules'),
join(openclawDir, 'node_modules'),
app.isPackaged ? join(process.resourcesPath, 'openclaw', 'node_modules') : '',
]);
const forkEnv: Record<string, string | undefined> = {
...stripSystemdSupervisorEnv(baseEnvWithDotnet),
...providerEnv,
...uvEnv,
...proxyEnv,
...playwrightEnv,
...bundledPackageManagerEnv,
...(nodePath ? { NODE_PATH: nodePath } : {}),
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
OPENCLAW_NO_RESPAWN: '1',
OPENCLAW_DISABLE_AGENTS_SKILLS: '1',
OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY: '1',
YINIAN_OPENCLAW_STUCK_ACTIVE_ABORT_MS: process.env.YINIAN_OPENCLAW_STUCK_ACTIVE_ABORT_MS || String(15 * 60_000),
OPENCLAW_BUNDLED_SKILLS_DIR: emptyBundledSkillsDir,
};
// Ensure extension-specific packages (e.g. grammy from the telegram
// extension) are resolvable by shared dist/ chunks via symlinks in
// openclaw/node_modules/. NODE_PATH does NOT work for ESM imports.
ensureExtensionDepsResolvable(openclawDir);
return {
appSettings,
openclawDir,
entryScript,
gatewayArgs,
forkEnv,
mode,
binPathExists,
loadedProviderKeyCount,
proxySummary,
channelStartupSummary,
};
}