feat: prepare Zhinian desktop pilot
This commit is contained in:
@@ -18,7 +18,7 @@ function fsPath(filePath: string): string {
|
||||
import { getAllSettings } from '../utils/store';
|
||||
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
|
||||
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';
|
||||
@@ -28,6 +28,8 @@ import { logger } from '../utils/logger';
|
||||
import { prependPathEntry } from '../utils/env-path';
|
||||
import { copyPluginFromNodeModules, ensureCloudSyncPluginInstalled, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install';
|
||||
import { stripSystemdSupervisorEnv } from './config-sync-env';
|
||||
import { ensureYinianModelRuntimeConfigured } from '../utils/model-diagnostics';
|
||||
import { cleanupOpenClawUserNativeClipboard } from '../utils/optional-native-cleanup';
|
||||
|
||||
|
||||
export interface GatewayLaunchContext {
|
||||
@@ -46,12 +48,9 @@ export interface GatewayLaunchContext {
|
||||
// ── Auto-upgrade bundled plugins on startup ──────────────────────
|
||||
|
||||
const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> = {
|
||||
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
|
||||
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
|
||||
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
|
||||
|
||||
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
|
||||
};
|
||||
const REMOVED_CHANNEL_PLUGIN_DIRS = ['dingtalk', 'wecom', 'feishu-openclaw-plugin'];
|
||||
|
||||
/**
|
||||
* OpenClaw 3.22+ ships Discord, Telegram, and other channels as built-in
|
||||
@@ -239,6 +238,17 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
|
||||
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;
|
||||
|
||||
@@ -347,6 +357,15 @@ export async function syncGatewayConfigBeforeLaunch(
|
||||
// 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 {
|
||||
@@ -395,6 +414,12 @@ export async function syncGatewayConfigBeforeLaunch(
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
|
||||
@@ -466,8 +491,8 @@ async function resolveChannelStartupPolicy(): Promise<{
|
||||
}
|
||||
|
||||
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
const openclawDir = getOpenClawResolvedDir();
|
||||
const entryScript = join(openclawDir, 'openclaw.mjs');
|
||||
|
||||
if (!isOpenClawPresent()) {
|
||||
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
|
||||
@@ -490,6 +515,14 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
? 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();
|
||||
@@ -510,6 +543,7 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
...providerEnv,
|
||||
...uvEnv,
|
||||
...proxyEnv,
|
||||
...bundledPackageManagerEnv,
|
||||
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
|
||||
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
|
||||
@@ -44,6 +44,7 @@ import { GatewayLifecycleController, LifecycleSupersededError } from './lifecycl
|
||||
import { launchGatewayProcess } from './process-launcher';
|
||||
import { GatewayRestartController } from './restart-controller';
|
||||
import { GatewayRestartGovernor } from './restart-governor';
|
||||
import { ensureOpenClawRuntimeDepsReady } from './runtime-deps';
|
||||
import {
|
||||
DEFAULT_GATEWAY_RELOAD_POLICY,
|
||||
loadGatewayReloadPolicy,
|
||||
@@ -145,9 +146,9 @@ export class GatewayManager extends EventEmitter {
|
||||
private reconnectAttemptsTotal = 0;
|
||||
private reconnectSuccessTotal = 0;
|
||||
private static readonly RELOAD_POLICY_REFRESH_MS = 15_000;
|
||||
private static readonly HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS = 12_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES = 3;
|
||||
private static readonly HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS = 25_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES = 5;
|
||||
// Windows-specific heartbeat parameters — more lenient to reduce log noise
|
||||
// from false positives caused by Windows Defender scans, system updates,
|
||||
// and synchronous event-loop blocking in the gateway.
|
||||
@@ -865,6 +866,7 @@ export class GatewayManager extends EventEmitter {
|
||||
*/
|
||||
private async startProcess(): Promise<void> {
|
||||
const launchContext = await prepareGatewayLaunchContext(this.status.port);
|
||||
await ensureOpenClawRuntimeDepsReady(launchContext);
|
||||
await unloadLaunchctlGatewayService();
|
||||
this.processExitCode = null;
|
||||
|
||||
|
||||
659
electron/gateway/runtime-deps.ts
Normal file
659
electron/gateway/runtime-deps.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import type { GatewayLaunchContext } from './config-sync';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const DEFAULT_RUNTIME_DEPS_PREFLIGHT_TIMEOUT_MS = 10 * 60_000;
|
||||
const MAX_CAPTURED_OUTPUT_LENGTH = 16_000;
|
||||
const OPENCLAW_BUNDLED_RUNTIME_DEPENDENCY_NAMES = [
|
||||
'@agentclientprotocol/claude-agent-acp',
|
||||
'@agentclientprotocol/sdk',
|
||||
'@anthropic-ai/sdk',
|
||||
'@anthropic-ai/vertex-sdk',
|
||||
'@aws-sdk/client-bedrock',
|
||||
'@aws-sdk/client-bedrock-runtime',
|
||||
'@aws-sdk/client-s3',
|
||||
'@aws-sdk/credential-provider-node',
|
||||
'@aws-sdk/s3-request-presigner',
|
||||
'@aws/bedrock-token-generator',
|
||||
'@azure/identity',
|
||||
'@clack/prompts',
|
||||
'@clawdbot/lobster',
|
||||
'@discordjs/voice',
|
||||
'@google/genai',
|
||||
'@grammyjs/runner',
|
||||
'@grammyjs/transformer-throttler',
|
||||
'@homebridge/ciao',
|
||||
'@lancedb/lancedb',
|
||||
'@larksuiteoapi/node-sdk',
|
||||
'@line/bot-sdk',
|
||||
'@lydell/node-pty',
|
||||
'@mariozechner/pi-agent-core',
|
||||
'@mariozechner/pi-ai',
|
||||
'@mariozechner/pi-coding-agent',
|
||||
'@matrix-org/matrix-sdk-crypto-nodejs',
|
||||
'@matrix-org/matrix-sdk-crypto-wasm',
|
||||
'@microsoft/teams.api',
|
||||
'@microsoft/teams.apps',
|
||||
'@modelcontextprotocol/sdk',
|
||||
'@mozilla/readability',
|
||||
'@openai/codex',
|
||||
'@opentelemetry/api',
|
||||
'@opentelemetry/api-logs',
|
||||
'@opentelemetry/exporter-logs-otlp-proto',
|
||||
'@opentelemetry/exporter-metrics-otlp-proto',
|
||||
'@opentelemetry/exporter-trace-otlp-proto',
|
||||
'@opentelemetry/resources',
|
||||
'@opentelemetry/sdk-logs',
|
||||
'@opentelemetry/sdk-metrics',
|
||||
'@opentelemetry/sdk-node',
|
||||
'@opentelemetry/sdk-trace-base',
|
||||
'@opentelemetry/semantic-conventions',
|
||||
'@pierre/diffs',
|
||||
'@pierre/theme',
|
||||
'@slack/bolt',
|
||||
'@slack/web-api',
|
||||
'@tencent-connect/qqbot-connector',
|
||||
'@tloncorp/tlon-skill',
|
||||
'@twurple/api',
|
||||
'@twurple/auth',
|
||||
'@twurple/chat',
|
||||
'@urbit/aura',
|
||||
'@whiskeysockets/baileys',
|
||||
'@zed-industries/codex-acp',
|
||||
'acpx',
|
||||
'ajv',
|
||||
'chokidar',
|
||||
'commander',
|
||||
'croner',
|
||||
'discord-api-types',
|
||||
'dotenv',
|
||||
'express',
|
||||
'fake-indexeddb',
|
||||
'gaxios',
|
||||
'global-agent',
|
||||
'google-auth-library',
|
||||
'grammy',
|
||||
'https-proxy-agent',
|
||||
'jimp',
|
||||
'jiti',
|
||||
'json5',
|
||||
'jsonwebtoken',
|
||||
'jszip',
|
||||
'jwks-rsa',
|
||||
'linkedom',
|
||||
'markdown-it',
|
||||
'matrix-js-sdk',
|
||||
'minimatch',
|
||||
'mpg123-decoder',
|
||||
'music-metadata',
|
||||
'node-edge-tts',
|
||||
'nostr-tools',
|
||||
'openai',
|
||||
'openshell',
|
||||
'opusscript',
|
||||
'pdfjs-dist',
|
||||
'playwright-core',
|
||||
'semver',
|
||||
'sharp',
|
||||
'silk-wasm',
|
||||
'sqlite-vec',
|
||||
'tar',
|
||||
'tokenjuice',
|
||||
'tslog',
|
||||
'typebox',
|
||||
'undici',
|
||||
'web-push',
|
||||
'ws',
|
||||
'yaml',
|
||||
'zca-js',
|
||||
'zod',
|
||||
] as const;
|
||||
|
||||
const preflightPromises = new Map<string, Promise<void>>();
|
||||
|
||||
interface OpenClawRuntimeDepsReport {
|
||||
installRoot?: string;
|
||||
deps?: Array<{ name?: unknown; version?: unknown }>;
|
||||
missing?: unknown[];
|
||||
repairedSpecs?: unknown[];
|
||||
}
|
||||
|
||||
function appendOutput(current: string, chunk: Buffer): string {
|
||||
const next = current + chunk.toString('utf8');
|
||||
if (next.length <= MAX_CAPTURED_OUTPUT_LENGTH) return next;
|
||||
return next.slice(next.length - MAX_CAPTURED_OUTPUT_LENGTH);
|
||||
}
|
||||
|
||||
function resolveNodeRunner(launchContext: GatewayLaunchContext): {
|
||||
command: string;
|
||||
env: Record<string, string | undefined>;
|
||||
} {
|
||||
const bundledNodePath = launchContext.forkEnv.YINIAN_NODE_EXEC_PATH;
|
||||
if (bundledNodePath && existsSync(bundledNodePath)) {
|
||||
return {
|
||||
command: bundledNodePath,
|
||||
env: { ...launchContext.forkEnv },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: process.execPath,
|
||||
env: {
|
||||
...launchContext.forkEnv,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runRuntimeDepsRepair(
|
||||
launchContext: GatewayLaunchContext,
|
||||
timeoutMs = DEFAULT_RUNTIME_DEPS_PREFLIGHT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const inspected = await runPluginsDepsCommand(launchContext, [
|
||||
'plugins',
|
||||
'deps',
|
||||
'--json',
|
||||
'--package-root',
|
||||
launchContext.openclawDir,
|
||||
], timeoutMs);
|
||||
|
||||
const removedStaleRoot = maybeRemoveStaleInstallRoot(launchContext, inspected.report);
|
||||
const missingBefore = Array.isArray(inspected.report.missing) ? inspected.report.missing.length : 0;
|
||||
const shouldRepair = removedStaleRoot || missingBefore > 0;
|
||||
|
||||
let finalReport = inspected.report;
|
||||
let localMaterialized = false;
|
||||
if (shouldRepair) {
|
||||
localMaterialized = tryMaterializeRuntimeDepsFromLocalPackages(launchContext, inspected.report);
|
||||
finalReport = (await runPluginsDepsCommand(launchContext, [
|
||||
'plugins',
|
||||
'deps',
|
||||
'--json',
|
||||
'--package-root',
|
||||
launchContext.openclawDir,
|
||||
], timeoutMs)).report;
|
||||
|
||||
const missingAfterLocal = Array.isArray(finalReport.missing) ? finalReport.missing.length : 0;
|
||||
if (missingAfterLocal > 0 || !localMaterialized) {
|
||||
finalReport = (await runPluginsDepsCommand(launchContext, [
|
||||
'plugins',
|
||||
'deps',
|
||||
'--repair',
|
||||
'--json',
|
||||
'--package-root',
|
||||
launchContext.openclawDir,
|
||||
], timeoutMs)).report;
|
||||
}
|
||||
}
|
||||
|
||||
const missingAfter = Array.isArray(finalReport.missing) ? finalReport.missing.length : 0;
|
||||
const repairedCount = Array.isArray(finalReport.repairedSpecs) ? finalReport.repairedSpecs.length : 0;
|
||||
if (missingAfter > 0) {
|
||||
throw new Error(`OpenClaw runtime dependency preflight still has ${missingAfter} missing dependency/dependencies after repair`);
|
||||
}
|
||||
|
||||
logger.info(`[plugins] OpenClaw runtime dependency preflight complete (missing=${missingAfter}, repaired=${repairedCount}, reset=${removedStaleRoot ? 'yes' : 'no'}, local=${localMaterialized ? 'yes' : 'no'})`);
|
||||
}
|
||||
|
||||
function runPluginsDepsCommand(
|
||||
launchContext: GatewayLaunchContext,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<{ report: OpenClawRuntimeDepsReport; stdout: string; stderr: string }> {
|
||||
const runner = resolveNodeRunner(launchContext);
|
||||
const commandArgs = [launchContext.entryScript, ...args];
|
||||
|
||||
logger.info(`[plugins] Checking OpenClaw runtime dependencies before Gateway start (packageRoot=${launchContext.openclawDir})`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(runner.command, commandArgs, {
|
||||
cwd: launchContext.openclawDir,
|
||||
env: {
|
||||
...runner.env,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`OpenClaw runtime dependency preflight timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout = appendOutput(stdout, chunk);
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr = appendOutput(stderr, chunk);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0 && !signal) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as OpenClawRuntimeDepsReport;
|
||||
resolve({ report: parsed, stdout, stderr });
|
||||
} catch (error) {
|
||||
reject(new Error([
|
||||
'OpenClaw runtime dependency preflight returned invalid JSON',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
stderr.trim(),
|
||||
stdout.trim(),
|
||||
].filter(Boolean).join('\n')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error([
|
||||
`OpenClaw runtime dependency preflight failed (code=${code ?? 'null'}, signal=${signal ?? 'none'})`,
|
||||
stderr.trim(),
|
||||
stdout.trim(),
|
||||
].filter(Boolean).join('\n')));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function maybeRemoveStaleInstallRoot(
|
||||
launchContext: GatewayLaunchContext,
|
||||
report: OpenClawRuntimeDepsReport,
|
||||
): boolean {
|
||||
const installRoot = typeof report.installRoot === 'string' ? report.installRoot : '';
|
||||
if (!installRoot) return false;
|
||||
|
||||
const resolvedInstallRoot = path.resolve(installRoot);
|
||||
const resolvedOpenClawDir = path.resolve(launchContext.openclawDir);
|
||||
if (resolvedInstallRoot === resolvedOpenClawDir) return false;
|
||||
|
||||
const desiredDeps = createDesiredDependencyMap(report, launchContext);
|
||||
if (desiredDeps.size === 0) return false;
|
||||
|
||||
const manifestPath = path.join(installRoot, 'package.json');
|
||||
if (!existsSync(manifestPath)) return false;
|
||||
|
||||
let currentDeps: Record<string, string>;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
|
||||
name?: unknown;
|
||||
dependencies?: unknown;
|
||||
};
|
||||
if (parsed.name !== 'openclaw-runtime-deps-install') {
|
||||
logger.warn(`[plugins] Removing stale OpenClaw runtime dependency root with unexpected manifest: ${installRoot}`);
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
currentDeps = isPlainStringRecord(parsed.dependencies) ? parsed.dependencies : {};
|
||||
} catch (error) {
|
||||
logger.warn(`[plugins] Removing unreadable OpenClaw runtime dependency root: ${installRoot}`, error);
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dependencyMapContainsAll(currentDeps, desiredDeps)) return false;
|
||||
|
||||
logger.warn(`[plugins] Resetting stale OpenClaw runtime dependency root (expected=${desiredDeps.size}, current=${Object.keys(currentDeps).length}): ${installRoot}`);
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryMaterializeRuntimeDepsFromLocalPackages(
|
||||
launchContext: GatewayLaunchContext,
|
||||
report: OpenClawRuntimeDepsReport,
|
||||
): boolean {
|
||||
const installRoot = typeof report.installRoot === 'string' ? report.installRoot : '';
|
||||
if (!installRoot) return false;
|
||||
|
||||
const resolvedInstallRoot = path.resolve(installRoot);
|
||||
const resolvedOpenClawDir = path.resolve(launchContext.openclawDir);
|
||||
if (resolvedInstallRoot === resolvedOpenClawDir) return false;
|
||||
|
||||
const desiredDeps = createDesiredDependencyMap(report, launchContext);
|
||||
if (desiredDeps.size === 0) return false;
|
||||
|
||||
const tempRoot = `${installRoot}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
mkdirSync(path.join(tempRoot, 'node_modules'), { recursive: true });
|
||||
|
||||
const localPackages = collectLocalRuntimePackageDirs(
|
||||
launchContext,
|
||||
Array.from(desiredDeps.keys()),
|
||||
[
|
||||
...readLocalPackageDependencyNames(launchContext.openclawDir).map((name) => ({
|
||||
name,
|
||||
parentDir: launchContext.openclawDir,
|
||||
})),
|
||||
...readLocalPackageDependencyNames(process.cwd()).map((name) => ({
|
||||
name,
|
||||
parentDir: process.cwd(),
|
||||
})),
|
||||
...listNodeModulesPackageNames(path.join(launchContext.openclawDir, 'node_modules')).map((name) => ({
|
||||
name,
|
||||
})),
|
||||
],
|
||||
);
|
||||
if (localPackages.missingRootName) {
|
||||
logger.warn(`[plugins] Local OpenClaw runtime dependency not found, falling back to npm repair: ${localPackages.missingRootName}`);
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [depName, packageDir] of localPackages.packageDirs) {
|
||||
const destination = path.join(tempRoot, 'node_modules', ...depName.split('/'));
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
symlinkSync(packageDir, destination, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
path.join(tempRoot, 'package.json'),
|
||||
`${JSON.stringify({
|
||||
name: 'openclaw-runtime-deps-install',
|
||||
private: true,
|
||||
dependencies: Object.fromEntries(
|
||||
Array.from(desiredDeps.entries()).sort(([left], [right]) => left.localeCompare(right)),
|
||||
),
|
||||
}, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
mkdirSync(path.dirname(installRoot), { recursive: true });
|
||||
renameSync(tempRoot, installRoot);
|
||||
logger.info(`[plugins] Materialized OpenClaw runtime dependencies from bundled/local packages (${desiredDeps.size} direct deps, ${localPackages.packageDirs.size} packages): ${installRoot}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
logger.warn('[plugins] Failed to materialize OpenClaw runtime dependencies from local packages; falling back to npm repair:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function collectLocalRuntimePackageDirs(
|
||||
launchContext: GatewayLaunchContext,
|
||||
rootNames: string[],
|
||||
preferredItems: Array<{ name: string; parentDir?: string }> = [],
|
||||
): {
|
||||
packageDirs: Map<string, string>;
|
||||
missingRootName: string | null;
|
||||
} {
|
||||
const packageDirs = new Map<string, string>();
|
||||
const seen = new Set<string>();
|
||||
const rootSet = new Set(rootNames);
|
||||
const queue: Array<{ name: string; parentDir?: string }> = [
|
||||
...rootNames.map((name) => ({ name })),
|
||||
...preferredItems.filter((item) => !rootSet.has(item.name)),
|
||||
];
|
||||
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const item = queue[index];
|
||||
if (seen.has(item.name) || isOptionalNativeClipboardPackage(item.name)) continue;
|
||||
|
||||
const packageDir = item.parentDir
|
||||
? resolvePackageDirFromPackage(item.parentDir, item.name) ?? resolveLocalPackageDir(launchContext, item.name)
|
||||
: resolveLocalPackageDir(launchContext, item.name);
|
||||
|
||||
if (!packageDir) {
|
||||
if (rootSet.has(item.name)) return { packageDirs, missingRootName: item.name };
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(item.name);
|
||||
packageDirs.set(item.name, packageDir);
|
||||
|
||||
for (const depName of readLocalPackageDependencyNames(packageDir)) {
|
||||
if (!seen.has(depName) && !isOptionalNativeClipboardPackage(depName)) {
|
||||
queue.push({ name: depName, parentDir: packageDir });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { packageDirs, missingRootName: null };
|
||||
}
|
||||
|
||||
function listNodeModulesPackageNames(nodeModulesDir: string): string[] {
|
||||
if (!existsSync(nodeModulesDir)) return [];
|
||||
|
||||
const names: string[] = [];
|
||||
try {
|
||||
for (const entry of readdirSafe(nodeModulesDir)) {
|
||||
if (entry === '.bin') continue;
|
||||
const entryPath = path.join(nodeModulesDir, entry);
|
||||
if (entry.startsWith('@')) {
|
||||
for (const scopedEntry of readdirSafe(entryPath)) {
|
||||
names.push(`${entry}/${scopedEntry}`);
|
||||
}
|
||||
} else {
|
||||
names.push(entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(names)).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function readdirSafe(dir: string): string[] {
|
||||
try {
|
||||
return existsSync(dir) ? readdirSync(dir) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isOptionalNativeClipboardPackage(name: string): boolean {
|
||||
return name === '@mariozechner/clipboard' || name.startsWith('@mariozechner/clipboard-');
|
||||
}
|
||||
|
||||
function readLocalPackageDependencyNames(packageDir: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as {
|
||||
dependencies?: unknown;
|
||||
optionalDependencies?: unknown;
|
||||
};
|
||||
return Array.from(new Set([
|
||||
...Object.keys(isPlainStringRecord(parsed.dependencies) ? parsed.dependencies : {}),
|
||||
...Object.keys(isPlainStringRecord(parsed.optionalDependencies) ? parsed.optionalDependencies : {}),
|
||||
])).sort((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePackageDirFromPackage(parentPackageDir: string, packageName: string): string | null {
|
||||
try {
|
||||
const req = createRequire(path.join(parentPackageDir, 'package.json'));
|
||||
try {
|
||||
return path.dirname(req.resolve(`${packageName}/package.json`));
|
||||
} catch {
|
||||
const entryPath = req.resolve(packageName);
|
||||
return findPackageRootForEntry(entryPath, packageName);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLocalPackageDir(
|
||||
launchContext: GatewayLaunchContext,
|
||||
packageName: string,
|
||||
): string | null {
|
||||
const directCandidates = [
|
||||
path.join(launchContext.openclawDir, 'node_modules', ...packageName.split('/')),
|
||||
path.join(process.cwd(), 'node_modules', ...packageName.split('/')),
|
||||
];
|
||||
for (const candidate of directCandidates) {
|
||||
if (existsSync(path.join(candidate, 'package.json'))) return candidate;
|
||||
}
|
||||
|
||||
const requireRoots = [
|
||||
path.join(launchContext.openclawDir, 'package.json'),
|
||||
path.join(process.cwd(), 'package.json'),
|
||||
];
|
||||
|
||||
for (const root of requireRoots) {
|
||||
try {
|
||||
const req = createRequire(root);
|
||||
try {
|
||||
return path.dirname(req.resolve(`${packageName}/package.json`));
|
||||
} catch {
|
||||
const entryPath = req.resolve(packageName);
|
||||
const packageDir = findPackageRootForEntry(entryPath, packageName);
|
||||
if (packageDir) return packageDir;
|
||||
}
|
||||
} catch {
|
||||
// Try the next require root.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPackageRootForEntry(entryPath: string, packageName: string): string | null {
|
||||
let current = path.dirname(entryPath);
|
||||
while (current && current !== path.dirname(current)) {
|
||||
const manifestPath = path.join(current, 'package.json');
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as { name?: unknown };
|
||||
if (parsed.name === packageName) return current;
|
||||
} catch {
|
||||
// Keep walking.
|
||||
}
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createDesiredDependencyMap(
|
||||
report: OpenClawRuntimeDepsReport,
|
||||
launchContext: GatewayLaunchContext,
|
||||
): Map<string, string> {
|
||||
const desired = new Map<string, string>();
|
||||
|
||||
if (Array.isArray(report.deps)) {
|
||||
for (const dep of report.deps) {
|
||||
if (!dep || typeof dep.name !== 'string' || typeof dep.version !== 'string') continue;
|
||||
desired.set(dep.name, dep.version);
|
||||
}
|
||||
}
|
||||
|
||||
for (const depName of OPENCLAW_BUNDLED_RUNTIME_DEPENDENCY_NAMES) {
|
||||
if (desired.has(depName)) continue;
|
||||
const spec = readLocalDependencySpec(launchContext, depName) ?? readLocalPackageVersionSpec(launchContext, depName);
|
||||
if (spec) {
|
||||
desired.set(depName, spec);
|
||||
}
|
||||
}
|
||||
return desired;
|
||||
}
|
||||
|
||||
function readLocalDependencySpec(
|
||||
launchContext: GatewayLaunchContext,
|
||||
packageName: string,
|
||||
): string | null {
|
||||
const roots = [process.cwd(), launchContext.openclawDir];
|
||||
for (const root of roots) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
||||
dependencies?: unknown;
|
||||
devDependencies?: unknown;
|
||||
optionalDependencies?: unknown;
|
||||
};
|
||||
for (const entries of [parsed.dependencies, parsed.devDependencies, parsed.optionalDependencies]) {
|
||||
if (isPlainStringRecord(entries) && entries[packageName]) {
|
||||
return entries[packageName];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try the next manifest.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readLocalPackageVersionSpec(
|
||||
launchContext: GatewayLaunchContext,
|
||||
packageName: string,
|
||||
): string | null {
|
||||
const packageDir = resolveLocalPackageDir(launchContext, packageName);
|
||||
if (!packageDir) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as {
|
||||
version?: unknown;
|
||||
};
|
||||
return typeof parsed.version === 'string' && parsed.version.trim() ? parsed.version.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainStringRecord(value: unknown): value is Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
return Object.values(value).every((entry) => typeof entry === 'string');
|
||||
}
|
||||
|
||||
function dependencyMapContainsAll(current: Record<string, string>, desired: Map<string, string>): boolean {
|
||||
for (const [key, version] of desired) {
|
||||
if (current[key] !== version) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensureOpenClawRuntimeDepsReady(
|
||||
launchContext: GatewayLaunchContext,
|
||||
): Promise<void> {
|
||||
if (process.env.YINIAN_SKIP_OPENCLAW_RUNTIME_DEPS_PREFLIGHT === '1') {
|
||||
logger.warn('[plugins] Skipping OpenClaw runtime dependency preflight via YINIAN_SKIP_OPENCLAW_RUNTIME_DEPS_PREFLIGHT=1');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${launchContext.entryScript}::${launchContext.openclawDir}`;
|
||||
const existing = preflightPromises.get(key);
|
||||
if (existing) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = runRuntimeDepsRepair(launchContext);
|
||||
preflightPromises.set(key, promise);
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
preflightPromises.delete(key);
|
||||
throw error;
|
||||
} finally {
|
||||
preflightPromises.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,8 @@ export async function runOpenClawDoctorRepair(): Promise<boolean> {
|
||||
? 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 baseProcessEnv = process.env as Record<string, string | undefined>;
|
||||
const baseEnvPatched = binPathExists
|
||||
? prependPathEntry(baseProcessEnv, binPath).env
|
||||
@@ -292,6 +294,12 @@ export async function runOpenClawDoctorRepair(): Promise<boolean> {
|
||||
const forkEnv: Record<string, string | undefined> = {
|
||||
...baseEnvPatched,
|
||||
...uvEnv,
|
||||
...(binPathExists && existsSync(bundledNodePath) && existsSync(bundledNpmCliPath)
|
||||
? {
|
||||
YINIAN_NODE_EXEC_PATH: bundledNodePath,
|
||||
YINIAN_NPM_CLI_PATH: bundledNpmCliPath,
|
||||
}
|
||||
: {}),
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user