Files
NianToB/electron/gateway/runtime-deps.ts
2026-05-13 22:01:55 +08:00

1033 lines
35 KiB
TypeScript

import { spawn } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
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 ALLOW_NPM_RUNTIME_DEPS_REPAIR_ENV = 'YINIAN_ALLOW_OPENCLAW_RUNTIME_DEPS_NPM_REPAIR';
const EXPAND_RUNTIME_DEPS_FALLBACK_ENV = 'YINIAN_EXPAND_OPENCLAW_RUNTIME_DEPS';
const OPENCLAW_RUNTIME_DEPS_LOADROOT_PATCH_SEARCH = `const packagePlan = collectBundledPluginRuntimeDeps({
\t\t\textensionsDir,
\t\t\t...params.config ? { config: params.config } : {},
\t\t\tmanifestCache,
\t\t\t...normalizePluginId ? { normalizePluginId } : {}
\t\t});`;
const OPENCLAW_RUNTIME_DEPS_LOADROOT_PATCH_REPLACE = `const packagePlan = collectBundledPluginRuntimeDeps({
\t\t\textensionsDir,
\t\t\t...params.config ? { config: params.config } : { selectedPluginIds: new Set([params.pluginId]) },
\t\t\tmanifestCache,
\t\t\t...normalizePluginId ? { normalizePluginId } : {}
\t\t});`;
const OPENCLAW_AGENTS_SKILLS_PATCH_SEARCH = `\tconst osHomeDir = resolveUserHomeDir();
\tconst personalAgentsSkills = loadSkills({
\t\tdir: osHomeDir ? path.resolve(osHomeDir, ".agents", "skills") : path.resolve(".agents", "skills"),
\t\tsource: "agents-skills-personal"
\t});
\tconst projectAgentsSkills = loadSkills({
\t\tdir: path.resolve(workspaceDir, ".agents", "skills"),
\t\tsource: "agents-skills-project"
\t});`;
const OPENCLAW_AGENTS_SKILLS_PATCH_REPLACE = `\tconst openClawAgentsSkillsDisabled = process.env.OPENCLAW_DISABLE_AGENTS_SKILLS === "1";
\tconst osHomeDir = openClawAgentsSkillsDisabled ? void 0 : resolveUserHomeDir();
\tconst personalAgentsSkills = openClawAgentsSkillsDisabled ? [] : loadSkills({
\t\tdir: osHomeDir ? path.resolve(osHomeDir, ".agents", "skills") : path.resolve(".agents", "skills"),
\t\tsource: "agents-skills-personal"
\t});
\tconst projectAgentsSkills = openClawAgentsSkillsDisabled ? [] : loadSkills({
\t\tdir: path.resolve(workspaceDir, ".agents", "skills"),
\t\tsource: "agents-skills-project"
\t});`;
const OPENCLAW_MAIN_SESSION_RECOVERY_MARK_SEARCH = `\t\t\tif (!resolveEntryTranscriptLockPaths({
\t\t\t\tentry,
\t\t\t\tsessionsDir
\t\t\t}).some((lockPath) => interruptedLockPaths.has(lockPath))) continue;
\t\t\tentry.abortedLastRun = true;
\t\t\tstore[sessionKey] = entry;
\t\t\tresult.marked++;`;
const OPENCLAW_MAIN_SESSION_RECOVERY_MARK_REPLACE = `\t\t\tif (!resolveEntryTranscriptLockPaths({
\t\t\t\tentry,
\t\t\t\tsessionsDir
\t\t\t}).some((lockPath) => interruptedLockPaths.has(lockPath))) continue;
\t\t\tentry.abortedLastRun = true;
\t\t\tif (process.env.OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY === "1") {
\t\t\t\tentry.status = "failed";
\t\t\t\tentry.endedAt = Date.now();
\t\t\t\tentry.updatedAt = entry.endedAt;
\t\t\t} else {
\t\t\t\tentry.updatedAt = Date.now();
\t\t\t}
\t\t\tstore[sessionKey] = entry;
\t\t\tresult.marked++;`;
const OPENCLAW_MAIN_SESSION_RECOVERY_SCHEDULE_SEARCH = `function scheduleRestartAbortedMainSessionRecovery(params = {}) {
\tconst initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;`;
const OPENCLAW_MAIN_SESSION_RECOVERY_SCHEDULE_REPLACE = `function scheduleRestartAbortedMainSessionRecovery(params = {}) {
\tif (process.env.OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY === "1") {
\t\tlog.info("main-session restart recovery disabled by OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY");
\t\treturn;
\t}
\tconst initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;`;
const OPENCLAW_STUCK_ACTIVE_ABORT_SEARCH = `\t\t\t\tif (classification?.recoveryEligible) (opts?.recoverStuckSession ?? recoverStuckSession)({
\t\t\t\t\tsessionId: state.sessionId,
\t\t\t\t\tsessionKey: state.sessionKey,
\t\t\t\t\tageMs,
\t\t\t\t\tqueueDepth: state.queueDepth
\t\t\t\t});`;
const OPENCLAW_STUCK_ACTIVE_ABORT_REPLACE = `\t\t\t\tif (classification?.recoveryEligible) (opts?.recoverStuckSession ?? recoverStuckSession)({
\t\t\t\t\tsessionId: state.sessionId,
\t\t\t\t\tsessionKey: state.sessionKey,
\t\t\t\t\tageMs,
\t\t\t\t\tqueueDepth: state.queueDepth,
\t\t\t\t\tallowActiveAbort: (() => {
\t\t\t\t\t\tconst thresholdMs = Number(process.env.YINIAN_OPENCLAW_STUCK_ACTIVE_ABORT_MS || "900000");
\t\t\t\t\t\treturn Number.isFinite(thresholdMs) && thresholdMs > 0 && ageMs >= thresholdMs;
\t\t\t\t\t})()
\t\t\t\t});`;
const OPENCLAW_DESKTOP_RUNTIME_DEPENDENCY_NAMES = [
'@anthropic-ai/sdk',
'@anthropic-ai/vertex-sdk',
'@aws-sdk/client-bedrock',
'@aws-sdk/client-bedrock-runtime',
'@aws-sdk/credential-provider-node',
'@aws/bedrock-token-generator',
'@google/genai',
'@openai/codex',
'express',
'openai',
'playwright-core',
] as const;
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',
'@twurple/api',
'@twurple/auth',
'@twurple/chat',
'@urbit/aura',
'@whiskeysockets/baileys',
'@zed-industries/codex-acp',
'acpx',
'ajv',
'chokidar',
'commander',
'croner',
'discord-api-types',
'dotenv',
'docx',
'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',
'socks-proxy-agent',
'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> {
let inspected: { report: OpenClawRuntimeDepsReport; stdout: string; stderr: string };
try {
inspected = await runPluginsDepsCommand(launchContext, [
'plugins',
'deps',
'--json',
'--package-root',
launchContext.openclawDir,
], timeoutMs);
} catch (error) {
if (isOpenClawRuntimeDepsCommandUnavailableMessage(error instanceof Error ? error.message : String(error))) {
logger.warn('[plugins] OpenClaw plugins deps preflight is unavailable in this OpenClaw version; skipping legacy runtime dependency preflight.');
return;
}
throw error;
}
const removedStaleRoot = maybeRemoveStaleInstallRoot(launchContext, inspected.report);
const missingBefore = Array.isArray(inspected.report.missing) ? inspected.report.missing.length : 0;
const shouldRepair = removedStaleRoot || missingBefore > 0 || shouldMaterializeExternalInstallRoot(launchContext, inspected.report);
let finalReport = inspected.report;
let localMaterialized = false;
let npmRepaired = 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) {
if (process.env[ALLOW_NPM_RUNTIME_DEPS_REPAIR_ENV] !== '1') {
throw new Error([
`OpenClaw runtime dependency preflight still has ${missingAfterLocal} missing dependency/dependencies after local materialization.`,
`NPM/PNPM repair is disabled during normal desktop startup to avoid blocking conversations.`,
`Run the administrator repair flow or set ${ALLOW_NPM_RUNTIME_DEPS_REPAIR_ENV}=1 for a one-time repair.`,
].join(' '));
}
finalReport = (await runPluginsDepsCommand(launchContext, [
'plugins',
'deps',
'--repair',
'--json',
'--package-root',
launchContext.openclawDir,
], timeoutMs)).report;
npmRepaired = true;
}
}
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'}, npm=${npmRepaired ? 'yes' : 'no'})`);
}
export function isOpenClawRuntimeDepsCommandUnavailableMessage(message: string): boolean {
const normalized = message.toLowerCase();
return normalized.includes("unknown option '--json'")
|| normalized.includes("unknown command 'deps'")
|| normalized.includes('unknown command "deps"')
|| (normalized.includes('usage: openclaw plugins') && normalized.includes('unknown subcommand'));
}
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 requiredDeps = createDesiredDependencyMap(report, launchContext);
if (requiredDeps.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 (dependencyMapMatches(currentDeps, requiredDeps)) return false;
logger.warn(`[plugins] Resetting stale OpenClaw runtime dependency root (expected=${requiredDeps.size}, current=${Object.keys(currentDeps).length}): ${installRoot}`);
rmSync(installRoot, { recursive: true, force: true });
return true;
}
function shouldMaterializeExternalInstallRoot(
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 requiredDeps = createDesiredDependencyMap(report, launchContext);
if (requiredDeps.size === 0) return false;
const manifestPath = path.join(installRoot, 'package.json');
if (!existsSync(manifestPath)) return true;
try {
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
dependencies?: unknown;
};
const currentDeps = isPlainStringRecord(parsed.dependencies) ? parsed.dependencies : {};
return !dependencyMapMatches(currentDeps, requiredDeps);
} catch {
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 preferredByName = new Map(preferredItems.map((item) => [item.name, item]));
const queue: Array<{ name: string; parentDir?: string }> = [
...rootNames.map((name) => preferredByName.get(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 {
const manifestPath = req.resolve(`${packageName}/package.json`);
return findPackageRootForEntry(manifestPath, packageName) ?? path.dirname(manifestPath);
} 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 = createReportedDependencyMap(report);
for (const depName of OPENCLAW_DESKTOP_RUNTIME_DEPENDENCY_NAMES) {
addLocalDependencySpec(desired, launchContext, depName);
}
if (process.env[EXPAND_RUNTIME_DEPS_FALLBACK_ENV] !== '1') {
return desired;
}
for (const depName of OPENCLAW_BUNDLED_RUNTIME_DEPENDENCY_NAMES) {
addLocalDependencySpec(desired, launchContext, depName);
}
return desired;
}
function addLocalDependencySpec(
desired: Map<string, string>,
launchContext: GatewayLaunchContext,
depName: string,
): void {
if (desired.has(depName)) return;
const spec = readLocalDependencySpec(launchContext, depName) ?? readLocalPackageVersionSpec(launchContext, depName);
if (spec) {
desired.set(depName, spec);
}
}
function createReportedDependencyMap(report: OpenClawRuntimeDepsReport): Map<string, string> {
const desired = new Map<string, string>();
if (!Array.isArray(report.deps)) return desired;
for (const dep of report.deps) {
if (!dep || typeof dep.name !== 'string' || typeof dep.version !== 'string') continue;
desired.set(dep.name, dep.version);
}
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 dependencyMapMatches(current: Record<string, string>, desired: Map<string, string>): boolean {
if (Object.keys(current).length !== desired.size) return false;
for (const [key, version] of desired) {
if (current[key] !== version) {
return false;
}
}
return true;
}
function isDesktopMainSession(sessionKey: string, entry: Record<string, unknown>): boolean {
if (!sessionKey.startsWith('agent:main:')) return false;
if (sessionKey.includes(':subagent:') || sessionKey.includes(':cron:') || sessionKey.includes(':acp:')) return false;
const deliveryContext = entry.deliveryContext;
const origin = entry.origin;
const deliveryChannel = deliveryContext && typeof deliveryContext === 'object' && !Array.isArray(deliveryContext)
? (deliveryContext as Record<string, unknown>).channel
: undefined;
const originProvider = origin && typeof origin === 'object' && !Array.isArray(origin)
? (origin as Record<string, unknown>).provider
: undefined;
return entry.lastChannel === 'webchat' || deliveryChannel === 'webchat' || originProvider === 'webchat';
}
function markOrphanedDesktopMainSessionsFailed(): void {
const agentsRoot = path.join(homedir(), '.openclaw', 'agents');
if (!existsSync(agentsRoot)) return;
let agentDirs;
try {
agentDirs = readdirSync(agentsRoot, { withFileTypes: true });
} catch {
return;
}
for (const agentDir of agentDirs) {
if (!agentDir.isDirectory()) continue;
const sessionsDir = path.join(agentsRoot, agentDir.name, 'sessions');
const storePath = path.join(sessionsDir, 'sessions.json');
if (!existsSync(storePath)) continue;
try {
const raw = readFileSync(storePath, 'utf8');
const store = JSON.parse(raw) as Record<string, Record<string, unknown>>;
let changed = 0;
const now = Date.now();
for (const [sessionKey, entry] of Object.entries(store)) {
if (!entry || entry.status !== 'running' || !isDesktopMainSession(sessionKey, entry)) continue;
const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId.trim() : '';
if (!sessionId) continue;
if (existsSync(path.join(sessionsDir, `${sessionId}.jsonl.lock`))) continue;
entry.status = 'failed';
entry.abortedLastRun = true;
entry.endedAt = now;
entry.updatedAt = now;
store[sessionKey] = entry;
changed += 1;
}
if (changed > 0) {
writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
logger.warn(`[sessions] Marked ${changed} orphaned desktop main session(s) failed before Gateway start`);
}
} catch (error) {
logger.warn(`[sessions] Failed to clean orphaned desktop main sessions: ${storePath}`, error);
}
}
}
function patchOpenClawRuntimeDepsLoadRoot(launchContext: GatewayLaunchContext): void {
const distDir = path.join(launchContext.openclawDir, 'dist');
if (!existsSync(distDir)) return;
let entries;
try {
entries = readdirSync(distDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isFile() || !/^bundled-runtime-deps-.*\.js$/.test(entry.name)) continue;
const filePath = path.join(distDir, entry.name);
try {
const current = readFileSync(filePath, 'utf8');
if (current.includes(OPENCLAW_RUNTIME_DEPS_LOADROOT_PATCH_REPLACE)) return;
if (!current.includes(OPENCLAW_RUNTIME_DEPS_LOADROOT_PATCH_SEARCH)) continue;
writeFileSync(filePath, current.replace(
OPENCLAW_RUNTIME_DEPS_LOADROOT_PATCH_SEARCH,
OPENCLAW_RUNTIME_DEPS_LOADROOT_PATCH_REPLACE,
), 'utf8');
logger.info(`[plugins] Patched OpenClaw bundled runtime dependency loader: ${filePath}`);
return;
} catch (error) {
logger.warn('[plugins] Failed to patch OpenClaw bundled runtime dependency loader', error);
return;
}
}
}
function patchOpenClawAgentsSkillsDiscovery(launchContext: GatewayLaunchContext): void {
const distDir = path.join(launchContext.openclawDir, 'dist');
if (!existsSync(distDir)) return;
let entries;
try {
entries = readdirSync(distDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isFile() || !/^workspace-.*\.js$/.test(entry.name)) continue;
const filePath = path.join(distDir, entry.name);
try {
const current = readFileSync(filePath, 'utf8');
if (current.includes(OPENCLAW_AGENTS_SKILLS_PATCH_REPLACE)) return;
if (!current.includes(OPENCLAW_AGENTS_SKILLS_PATCH_SEARCH)) continue;
writeFileSync(filePath, current.replace(
OPENCLAW_AGENTS_SKILLS_PATCH_SEARCH,
OPENCLAW_AGENTS_SKILLS_PATCH_REPLACE,
), 'utf8');
logger.info(`[skills] Patched OpenClaw .agents skill discovery boundary: ${filePath}`);
return;
} catch (error) {
logger.warn('[skills] Failed to patch OpenClaw .agents skill discovery boundary', error);
return;
}
}
}
function patchOpenClawMainSessionRestartRecovery(launchContext: GatewayLaunchContext): void {
const distDir = path.join(launchContext.openclawDir, 'dist');
if (!existsSync(distDir)) return;
let entries;
try {
entries = readdirSync(distDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isFile() || !/^main-session-restart-recovery-.*\.js$/.test(entry.name)) continue;
const filePath = path.join(distDir, entry.name);
try {
let current = readFileSync(filePath, 'utf8');
let next = current;
if (!next.includes(OPENCLAW_MAIN_SESSION_RECOVERY_MARK_REPLACE)) {
if (!next.includes(OPENCLAW_MAIN_SESSION_RECOVERY_MARK_SEARCH)) continue;
next = next.replace(
OPENCLAW_MAIN_SESSION_RECOVERY_MARK_SEARCH,
OPENCLAW_MAIN_SESSION_RECOVERY_MARK_REPLACE,
);
}
if (!next.includes(OPENCLAW_MAIN_SESSION_RECOVERY_SCHEDULE_REPLACE)) {
if (!next.includes(OPENCLAW_MAIN_SESSION_RECOVERY_SCHEDULE_SEARCH)) continue;
next = next.replace(
OPENCLAW_MAIN_SESSION_RECOVERY_SCHEDULE_SEARCH,
OPENCLAW_MAIN_SESSION_RECOVERY_SCHEDULE_REPLACE,
);
}
if (next !== current) {
writeFileSync(filePath, next, 'utf8');
logger.info(`[sessions] Patched OpenClaw main session restart recovery boundary: ${filePath}`);
}
return;
} catch (error) {
logger.warn('[sessions] Failed to patch OpenClaw main session restart recovery boundary', error);
return;
}
}
}
function patchOpenClawStuckSessionRecovery(launchContext: GatewayLaunchContext): void {
const distDir = path.join(launchContext.openclawDir, 'dist');
if (!existsSync(distDir)) return;
let entries;
try {
entries = readdirSync(distDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isFile() || !/^diagnostic-.*\.js$/.test(entry.name)) continue;
const filePath = path.join(distDir, entry.name);
try {
const current = readFileSync(filePath, 'utf8');
if (current.includes(OPENCLAW_STUCK_ACTIVE_ABORT_REPLACE)) return;
if (!current.includes(OPENCLAW_STUCK_ACTIVE_ABORT_SEARCH)) continue;
writeFileSync(filePath, current.replace(
OPENCLAW_STUCK_ACTIVE_ABORT_SEARCH,
OPENCLAW_STUCK_ACTIVE_ABORT_REPLACE,
), 'utf8');
logger.info(`[sessions] Patched OpenClaw stuck session active-run recovery: ${filePath}`);
return;
} catch (error) {
logger.warn('[sessions] Failed to patch OpenClaw stuck session active-run recovery', error);
return;
}
}
}
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;
}
patchOpenClawRuntimeDepsLoadRoot(launchContext);
patchOpenClawAgentsSkillsDiscovery(launchContext);
patchOpenClawMainSessionRestartRecovery(launchContext);
patchOpenClawStuckSessionRecovery(launchContext);
markOrphanedDesktopMainSessionsFailed();
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);
}
}