feat: prepare Zhinian desktop pilot
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-05-07 21:49:20 +08:00
parent cddaf37016
commit 0abc48189c
103 changed files with 10975 additions and 2049 deletions

View File

@@ -22,6 +22,8 @@ import {
const OPENCLAW_DIR = join(homedir(), '.openclaw');
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
const WECOM_PLUGIN_ID = 'wecom';
const DISABLED_PLUGIN_CHANNEL_TYPES = new Set(['dingtalk', 'wecom', 'feishu']);
const DISABLED_PLUGIN_IDS = ['dingtalk', 'wecom', 'feishu', 'openclaw-lark', 'feishu-openclaw-plugin'];
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin ID needed.
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
@@ -417,6 +419,13 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
// ── Channel operations ───────────────────────────────────────────
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
if (DISABLED_PLUGIN_CHANNEL_TYPES.has(channelType)) {
for (const pluginId of DISABLED_PLUGIN_IDS) {
removePluginRegistration(currentConfig, pluginId);
}
return;
}
if (PLUGIN_CHANNELS.includes(channelType)) {
ensurePluginRegistration(currentConfig, channelType);
}

View File

@@ -8,7 +8,7 @@
*/
export const PORTS = {
/** ClawX GUI development server port */
CLAWX_DEV: 5173,
CLAWX_DEV: 5188,
/** ClawX GUI production port (for reference) */
CLAWX_GUI: 23333,

View File

@@ -0,0 +1,492 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { getOpenClawConfigDir } from './paths';
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { logger } from './logger';
const YINIAN_MODEL_TIMEOUT_SECONDS = 300;
const YINIAN_MODEL_PROVIDER_KEY = 'minimax';
const YINIAN_MODEL_ID = 'MiniMax-M2.7';
const YINIAN_MODEL_REF = `${YINIAN_MODEL_PROVIDER_KEY}/${YINIAN_MODEL_ID}`;
const YINIAN_INTERNAL_PROVIDER_KEYS = ['minimax', 'minimax-portal'];
const YINIAN_MODEL_AUTH_ALIAS_PROFILE_IDS = [
'minimax:cn',
'minimax-cn:default',
'minimax-portal-cn:default',
'minimax-portal:default',
];
const YINIAN_MODEL_AUTH_TARGET_PROFILE_ID = 'minimax:default';
const YINIAN_MODEL_AUTH_TARGET_PROVIDER = 'minimax';
type JsonObject = Record<string, unknown>;
type AuthProfileEntry = {
type?: unknown;
provider?: unknown;
[key: string]: unknown;
};
type AuthProfilesStore = {
version?: unknown;
profiles?: Record<string, AuthProfileEntry>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
[key: string]: unknown;
};
export type ModelDiagnosticStatus = 'ok' | 'warning' | 'error';
export interface YinianModelConfigCheck {
id: string;
label: string;
status: ModelDiagnosticStatus;
detail: string;
}
export interface YinianModelConfigDiagnostics {
capturedAt: number;
ok: boolean;
model: {
primary: string | null;
fallbacks: string[];
providerKey: string | null;
modelId: string | null;
};
runtime: {
pricingEnabled: boolean | null;
pricingCatalogFetchDisabled: boolean;
};
providers: Array<{
key: string;
configured: boolean;
baseUrl: string | null;
api: string | null;
timeoutSeconds: number | null;
authHeader: boolean | null;
modelCount: number;
}>;
authProfiles: {
path: string;
exists: boolean;
providers: Array<{
provider: string;
profileCount: number;
profileIds: string[];
types: string[];
hasDefaultProfile: boolean;
lastGoodProfileId: string | null;
}>;
};
checks: YinianModelConfigCheck[];
paths: {
openclawConfig: string;
authProfiles: string;
};
}
function isPlainRecord(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function getAuthProfilesPath(agentId = 'main'): string {
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, 'utf8');
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
}
function readAuthProfilesStore(filePath: string): Promise<AuthProfilesStore> {
return readJsonFile<AuthProfilesStore>(filePath).then((store) => {
if (store && isPlainRecord(store.profiles)) return store;
return { version: 1, profiles: {} };
});
}
function getPrimaryModel(config: JsonObject): string | null {
const agents = isPlainRecord(config.agents) ? config.agents : {};
const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
const model = isPlainRecord(defaults.model) ? defaults.model : {};
return typeof model.primary === 'string' && model.primary.trim() ? model.primary : null;
}
function getFallbackModels(config: JsonObject): string[] {
const agents = isPlainRecord(config.agents) ? config.agents : {};
const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
const model = isPlainRecord(defaults.model) ? defaults.model : {};
return Array.isArray(model.fallbacks)
? model.fallbacks.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: [];
}
function splitModelRef(modelRef: string | null): { providerKey: string | null; modelId: string | null } {
if (!modelRef) return { providerKey: null, modelId: null };
const slashIndex = modelRef.indexOf('/');
if (slashIndex <= 0 || slashIndex >= modelRef.length - 1) {
return { providerKey: null, modelId: modelRef };
}
return {
providerKey: modelRef.slice(0, slashIndex),
modelId: modelRef.slice(slashIndex + 1),
};
}
function groupAuthProfiles(store: AuthProfilesStore): YinianModelConfigDiagnostics['authProfiles']['providers'] {
const profiles = isPlainRecord(store.profiles) ? store.profiles : {};
const groups = new Map<string, { ids: string[]; types: Set<string> }>();
for (const [profileId, profile] of Object.entries(profiles)) {
if (!isPlainRecord(profile)) continue;
const provider = typeof profile.provider === 'string' && profile.provider.trim()
? profile.provider
: profileId.split(':')[0] || 'unknown';
const type = typeof profile.type === 'string' && profile.type.trim() ? profile.type : 'unknown';
const group = groups.get(provider) ?? { ids: [], types: new Set<string>() };
group.ids.push(profileId);
group.types.add(type);
groups.set(provider, group);
}
const lastGood = isPlainRecord(store.lastGood) ? store.lastGood as Record<string, unknown> : {};
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, group]) => ({
provider,
profileCount: group.ids.length,
profileIds: group.ids.sort(),
types: Array.from(group.types).sort(),
hasDefaultProfile: group.ids.includes(`${provider}:default`),
lastGoodProfileId: typeof lastGood[provider] === 'string' ? lastGood[provider] as string : null,
}));
}
function hasMatchingAuthProfile(store: AuthProfilesStore, providerKey: string | null): boolean {
if (!providerKey || !isPlainRecord(store.profiles)) return false;
return Object.values(store.profiles).some((profile) => (
isPlainRecord(profile) && profile.provider === providerKey
));
}
function hasConfiguredProvider(config: JsonObject, providerKey: string | null): boolean {
if (!providerKey) return false;
const models = isPlainRecord(config.models) ? config.models : {};
const providers = isPlainRecord(models.providers) ? models.providers : {};
return isPlainRecord(providers[providerKey]);
}
function buildProviderDiagnostics(config: JsonObject, primaryProviderKey: string | null): YinianModelConfigDiagnostics['providers'] {
const models = isPlainRecord(config.models) ? config.models : {};
const providers = isPlainRecord(models.providers) ? models.providers : {};
const providerKeys = Array.from(new Set([
...(primaryProviderKey ? [primaryProviderKey] : []),
...YINIAN_INTERNAL_PROVIDER_KEYS,
]));
return providerKeys.map((key) => {
const entry = isPlainRecord(providers[key]) ? providers[key] : {};
const modelsList = Array.isArray(entry.models) ? entry.models : [];
return {
key,
configured: isPlainRecord(providers[key]),
baseUrl: typeof entry.baseUrl === 'string' ? entry.baseUrl : null,
api: typeof entry.api === 'string' ? entry.api : null,
timeoutSeconds: typeof entry.timeoutSeconds === 'number' ? entry.timeoutSeconds : null,
authHeader: typeof entry.authHeader === 'boolean' ? entry.authHeader : null,
modelCount: modelsList.length,
};
});
}
function getPricingEnabled(config: JsonObject): boolean | null {
const models = isPlainRecord(config.models) ? config.models : {};
const pricing = isPlainRecord(models.pricing) ? models.pricing : null;
return typeof pricing?.enabled === 'boolean' ? pricing.enabled : null;
}
export async function ensureYinianModelRuntimeConfigured(): Promise<void> {
const config = await readOpenClawConfig() as JsonObject;
const models = isPlainRecord(config.models) ? { ...config.models } : {};
const providers = isPlainRecord(models.providers) ? { ...models.providers } : {};
const pricing = isPlainRecord(models.pricing) ? { ...models.pricing } : {};
let changed = false;
const currentYinianProvider = isPlainRecord(providers[YINIAN_MODEL_PROVIDER_KEY])
? { ...providers[YINIAN_MODEL_PROVIDER_KEY] }
: {};
const currentModels = Array.isArray(currentYinianProvider.models)
? currentYinianProvider.models.filter(isPlainRecord)
: [];
const hasYinianModel = currentModels.some((item) => item.id === YINIAN_MODEL_ID);
const nextYinianProvider = {
...currentYinianProvider,
baseUrl: typeof currentYinianProvider.baseUrl === 'string' && currentYinianProvider.baseUrl.trim()
? currentYinianProvider.baseUrl
: 'https://api.minimaxi.com/anthropic',
api: typeof currentYinianProvider.api === 'string' && currentYinianProvider.api.trim()
? currentYinianProvider.api
: 'anthropic-messages',
authHeader: typeof currentYinianProvider.authHeader === 'boolean'
? currentYinianProvider.authHeader
: true,
timeoutSeconds: YINIAN_MODEL_TIMEOUT_SECONDS,
models: hasYinianModel
? currentModels
: [
...currentModels,
{
id: YINIAN_MODEL_ID,
name: 'MiniMax M2.7',
reasoning: true,
input: ['text', 'image'],
contextWindow: 204800,
maxTokens: 131072,
},
],
};
if (JSON.stringify(providers[YINIAN_MODEL_PROVIDER_KEY]) !== JSON.stringify(nextYinianProvider)) {
providers[YINIAN_MODEL_PROVIDER_KEY] = nextYinianProvider;
changed = true;
}
for (const providerKey of YINIAN_INTERNAL_PROVIDER_KEYS) {
const currentProvider = providers[providerKey];
if (!isPlainRecord(currentProvider)) continue;
if (currentProvider.timeoutSeconds !== YINIAN_MODEL_TIMEOUT_SECONDS) {
providers[providerKey] = {
...currentProvider,
timeoutSeconds: YINIAN_MODEL_TIMEOUT_SECONDS,
};
changed = true;
}
}
if (pricing.enabled !== false) {
pricing.enabled = false;
changed = true;
}
if (models.mode !== 'merge') {
models.mode = 'merge';
changed = true;
}
const agents = isPlainRecord(config.agents) ? { ...config.agents } : {};
const defaults = isPlainRecord(agents.defaults) ? { ...agents.defaults } : {};
const defaultModel = isPlainRecord(defaults.model) ? { ...defaults.model } : {};
if (defaultModel.primary !== YINIAN_MODEL_REF) {
defaultModel.primary = YINIAN_MODEL_REF;
changed = true;
}
if (!Array.isArray(defaultModel.fallbacks)) {
defaultModel.fallbacks = ['minimax/MiniMax-M2.5'];
changed = true;
}
defaults.model = defaultModel;
if (!Array.isArray(defaults.skills)) {
defaults.skills = [];
changed = true;
}
const heartbeat = isPlainRecord(defaults.heartbeat) ? { ...defaults.heartbeat } : {};
if (heartbeat.every !== '0m') {
heartbeat.every = '0m';
defaults.heartbeat = heartbeat;
changed = true;
}
defaults.workspace = typeof defaults.workspace === 'string' && defaults.workspace.trim()
? defaults.workspace
: join(homedir(), '.openclaw', 'workspace');
agents.defaults = defaults;
const list = Array.isArray(agents.list) ? agents.list : [];
const normalizedList = list.map((item) => {
if (!isPlainRecord(item) || item.id !== 'main') return item;
const tools = isPlainRecord(item.tools) ? item.tools : {};
return {
...item,
tools: {
...tools,
profile: 'coding',
},
};
});
if (JSON.stringify(normalizedList) !== JSON.stringify(list)) {
agents.list = normalizedList;
changed = true;
}
const activeList = Array.isArray(agents.list) ? agents.list : list;
const hasMainAgent = activeList.some((item) => isPlainRecord(item) && item.id === 'main');
if (!hasMainAgent) {
agents.list = [
...activeList,
{
id: 'main',
name: '智念助手',
default: true,
workspace: join(homedir(), '.openclaw', 'workspace'),
agentDir: '~/.openclaw/agents/main/agent',
skills: [],
tools: { profile: 'coding' },
},
];
changed = true;
}
if (JSON.stringify(config.agents) !== JSON.stringify(agents)) {
config.agents = agents;
changed = true;
}
if (changed) {
models.providers = providers;
models.pricing = pricing;
config.models = models;
await writeOpenClawConfig(config);
logger.info('[provider-sync] Applied Yinian model runtime defaults');
}
await ensureYinianModelAuthProfileAliases();
}
export async function ensureYinianModelAuthProfileAliases(agentId = 'main'): Promise<void> {
const authPath = getAuthProfilesPath(agentId);
const store = await readAuthProfilesStore(authPath);
store.version = typeof store.version === 'number' ? store.version : 1;
store.profiles = isPlainRecord(store.profiles) ? store.profiles : {};
const target = store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID];
const sourceId = YINIAN_MODEL_AUTH_ALIAS_PROFILE_IDS.find((profileId) => isPlainRecord(store.profiles?.[profileId]));
const source = sourceId ? store.profiles[sourceId] : null;
let changed = false;
if (!isPlainRecord(target) && isPlainRecord(source)) {
store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID] = {
...source,
provider: YINIAN_MODEL_AUTH_TARGET_PROVIDER,
};
changed = true;
} else if (isPlainRecord(target) && target.provider !== YINIAN_MODEL_AUTH_TARGET_PROVIDER) {
store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID] = {
...target,
provider: YINIAN_MODEL_AUTH_TARGET_PROVIDER,
};
changed = true;
}
if (isPlainRecord(store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID])) {
const order = isPlainRecord(store.order) ? { ...store.order } as Record<string, string[]> : {};
const currentOrder = Array.isArray(order[YINIAN_MODEL_AUTH_TARGET_PROVIDER])
? order[YINIAN_MODEL_AUTH_TARGET_PROVIDER]
: [];
if (!currentOrder.includes(YINIAN_MODEL_AUTH_TARGET_PROFILE_ID)) {
order[YINIAN_MODEL_AUTH_TARGET_PROVIDER] = [
YINIAN_MODEL_AUTH_TARGET_PROFILE_ID,
...currentOrder.filter((item) => item !== YINIAN_MODEL_AUTH_TARGET_PROFILE_ID),
];
store.order = order;
changed = true;
}
const lastGood = isPlainRecord(store.lastGood) ? { ...store.lastGood } as Record<string, string> : {};
if (!lastGood[YINIAN_MODEL_AUTH_TARGET_PROVIDER]) {
lastGood[YINIAN_MODEL_AUTH_TARGET_PROVIDER] = YINIAN_MODEL_AUTH_TARGET_PROFILE_ID;
store.lastGood = lastGood;
changed = true;
}
}
if (changed) {
await writeJsonFile(authPath, store);
logger.info('[provider-sync] Normalized Yinian model auth profile aliases');
}
}
export async function buildYinianModelConfigDiagnostics(): Promise<YinianModelConfigDiagnostics> {
const openclawConfigPath = join(getOpenClawConfigDir(), 'openclaw.json');
const authProfilesPath = getAuthProfilesPath('main');
const config = await readOpenClawConfig() as JsonObject;
const authStore = await readAuthProfilesStore(authProfilesPath);
const primary = getPrimaryModel(config);
const fallbacks = getFallbackModels(config);
const { providerKey, modelId } = splitModelRef(primary);
const pricingEnabled = getPricingEnabled(config);
const providerConfigured = hasConfiguredProvider(config, providerKey);
const authProfileConfigured = hasMatchingAuthProfile(authStore, providerKey);
const providers = buildProviderDiagnostics(config, providerKey);
const primaryProvider = providers.find((provider) => provider.key === providerKey);
const checks: YinianModelConfigCheck[] = [
{
id: 'primary-model',
label: '默认模型',
status: primary ? 'ok' : 'error',
detail: primary ?? '未配置默认模型',
},
{
id: 'provider-entry',
label: '模型服务',
status: providerConfigured ? 'ok' : 'error',
detail: providerKey
? (providerConfigured ? `已配置 ${providerKey}` : `缺少 ${providerKey} 服务配置`)
: '无法从默认模型识别服务',
},
{
id: 'auth-profile',
label: '调用凭据',
status: authProfileConfigured ? 'ok' : 'error',
detail: providerKey
? (authProfileConfigured ? `已找到 ${providerKey} 的本地凭据` : `未找到 ${providerKey} 的本地凭据`)
: '无法检查调用凭据',
},
{
id: 'timeout',
label: '长任务等待',
status: (primaryProvider?.timeoutSeconds ?? 0) >= YINIAN_MODEL_TIMEOUT_SECONDS ? 'ok' : 'warning',
detail: primaryProvider?.timeoutSeconds
? `${primaryProvider.timeoutSeconds}`
: '未显式配置等待时长',
},
{
id: 'pricing-catalog',
label: '外部价格目录',
status: pricingEnabled === false ? 'ok' : 'warning',
detail: pricingEnabled === false ? '已关闭启动时外部目录拉取' : '未关闭,弱网环境可能拖慢后台启动',
},
];
return {
capturedAt: Date.now(),
ok: checks.every((check) => check.status !== 'error'),
model: {
primary,
fallbacks,
providerKey,
modelId,
},
runtime: {
pricingEnabled,
pricingCatalogFetchDisabled: pricingEnabled === false,
},
providers,
authProfiles: {
path: authProfilesPath,
exists: existsSync(authProfilesPath),
providers: groupAuthProfiles(authStore),
},
checks,
paths: {
openclawConfig: openclawConfigPath,
authProfiles: authProfilesPath,
},
};
}

View File

@@ -0,0 +1,431 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { app } from 'electron';
import { createServer } from 'node:net';
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { logger } from './logger';
const DEFAULT_NIANXX_PLAY_PORT = 3000;
const STARTUP_TIMEOUT_MS = 20_000;
const STARTUP_POLL_INTERVAL_MS = 500;
const HEALTH_PATH = '/api/desktop/health';
const RUNTIME_ENV_FILE_NAME = '.env.runtime';
type ProcessWithResourcesPath = NodeJS.Process & { resourcesPath?: string };
type NianxxPlayRuntimeKind = 'source' | 'standalone';
interface NianxxPlayRuntime {
kind: NianxxPlayRuntimeKind;
dir: string;
serverPath?: string;
}
interface NianxxPlayHealthPayload {
appId?: unknown;
ok?: unknown;
desktopManaged?: unknown;
}
export interface NianxxPlayServiceStatus {
success: boolean;
running: boolean;
starting: boolean;
managed: boolean;
baseUrl: string;
port: number;
projectDir?: string;
runtimeKind?: NianxxPlayRuntimeKind;
pid?: number;
error?: string;
}
let nianxxPlayProcess: ChildProcessWithoutNullStreams | null = null;
let lastServiceError: string | null = null;
let activePort: number | null = null;
function getConfiguredPort(): number {
const raw = process.env.NIANXX_PLAY_PORT?.trim();
if (!raw) return DEFAULT_NIANXX_PLAY_PORT;
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_NIANXX_PLAY_PORT;
}
function getBaseUrl(): string {
const explicitUrl = process.env.NIANXX_PLAY_URL?.trim();
if (explicitUrl) return explicitUrl.replace(/\/$/, '');
return `http://127.0.0.1:${activePort ?? getConfiguredPort()}`;
}
function allowExternalNianxxPlayRuntime(): boolean {
return Boolean(process.env.NIANXX_PLAY_URL?.trim());
}
function getNpmCommand(): string {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
function getScriptName(): string {
return process.env.NIANXX_PLAY_SCRIPT?.trim() || (process.env.NODE_ENV === 'production' ? 'start' : 'dev');
}
function getResourcePathCandidates(): string[] {
const resourcesPath = (process as ProcessWithResourcesPath).resourcesPath;
return [
process.env.NIANXX_PLAY_DIR?.trim() || '',
join(process.cwd(), '..', 'NianxxPlay'),
join(process.cwd(), 'NianxxPlay'),
join(process.cwd(), 'build', 'apps', 'nianxx-play'),
resourcesPath ? join(resourcesPath, 'nianxx-play') : '',
resourcesPath ? join(resourcesPath, 'resources', 'nianxx-play') : '',
].filter(Boolean);
}
function createRuntimeCandidate(candidate: string): NianxxPlayRuntime | undefined {
const dir = resolve(candidate);
const directStandaloneServer = join(dir, 'server.js');
const nestedStandaloneServer = join(dir, 'standalone', 'server.js');
if (existsSync(directStandaloneServer)) {
return { kind: 'standalone', dir, serverPath: directStandaloneServer };
}
if (existsSync(nestedStandaloneServer)) {
return { kind: 'standalone', dir: join(dir, 'standalone'), serverPath: nestedStandaloneServer };
}
if (existsSync(join(dir, 'package.json'))) {
return { kind: 'source', dir };
}
return undefined;
}
function resolveNianxxPlayRuntime(): NianxxPlayRuntime | undefined {
for (const candidate of getResourcePathCandidates()) {
const runtime = createRuntimeCandidate(candidate);
if (runtime) return runtime;
}
return undefined;
}
async function canReachNianxxPlay(baseUrl = getBaseUrl()): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1_500);
try {
const response = await fetch(`${baseUrl}${HEALTH_PATH}`, {
method: 'GET',
signal: controller.signal,
});
if (!response.ok) return false;
const payload = (await response.json().catch(() => undefined)) as NianxxPlayHealthPayload | undefined;
const isNianxxPlay = Boolean(payload && payload.appId === 'nianxx-play' && payload.ok);
if (!isNianxxPlay) return false;
return payload?.desktopManaged === true || allowExternalNianxxPlayRuntime();
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
function createStatus(overrides: Partial<NianxxPlayServiceStatus> = {}): NianxxPlayServiceStatus {
const runtime = resolveNianxxPlayRuntime();
const baseUrl = getBaseUrl();
return {
success: true,
running: false,
starting: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
managed: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
baseUrl,
port: activePort ?? getConfiguredPort(),
projectDir: runtime?.dir,
runtimeKind: runtime?.kind,
pid: nianxxPlayProcess?.pid,
error: lastServiceError ?? undefined,
...overrides,
};
}
function attachProcessLogger(stream: NodeJS.ReadableStream, level: 'info' | 'warn'): void {
let buffer = '';
stream.on('data', (chunk) => {
buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (level === 'warn') {
logger.warn(`[nianxx-play] ${trimmed}`);
} else {
logger.info(`[nianxx-play] ${trimmed}`);
}
}
});
}
async function waitUntilReachable(baseUrl: string): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < STARTUP_TIMEOUT_MS) {
if (await canReachNianxxPlay(baseUrl)) {
return true;
}
await delay(STARTUP_POLL_INTERVAL_MS);
}
return false;
}
async function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolveAvailable) => {
const server = createServer();
server.once('error', () => resolveAvailable(false));
server.once('listening', () => {
server.close(() => resolveAvailable(true));
});
server.listen(port, '127.0.0.1');
});
}
async function findAvailablePort(preferredPort: number): Promise<number> {
if (await isPortAvailable(preferredPort)) return preferredPort;
return new Promise((resolvePort, reject) => {
const server = createServer();
server.once('error', reject);
server.once('listening', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : preferredPort;
server.close(() => resolvePort(port));
});
server.listen(0, '127.0.0.1');
});
}
function getRuntimeDataDirs() {
const userData = app.getPath('userData');
const runtimeRoot = join(userData, 'apps', 'nianxx-play');
const dataDir = join(runtimeRoot, 'data');
const uploadDir = join(runtimeRoot, 'uploads');
const resultDir = join(runtimeRoot, 'generated-results');
mkdirSync(dataDir, { recursive: true });
mkdirSync(uploadDir, { recursive: true });
mkdirSync(resultDir, { recursive: true });
return { runtimeRoot, dataDir, uploadDir, resultDir };
}
function hasDirectoryEntries(dir: string): boolean {
try {
return readdirSync(dir).length > 0;
} catch {
return false;
}
}
function copyFileIfMissing(sourcePath: string, targetPath: string): void {
if (!existsSync(sourcePath) || existsSync(targetPath)) return;
mkdirSync(dirname(targetPath), { recursive: true });
cpSync(sourcePath, targetPath, { dereference: true });
}
function readJsonFile<T>(filePath: string): T | null {
try {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
} catch {
return null;
}
}
function getArrayLength(record: Record<string, unknown> | null, key: string): number {
const value = record?.[key];
return Array.isArray(value) ? value.length : 0;
}
function migrateStateFile(sourcePath: string, targetPath: string): void {
if (!existsSync(sourcePath)) return;
mkdirSync(dirname(targetPath), { recursive: true });
if (!existsSync(targetPath)) {
cpSync(sourcePath, targetPath, { dereference: true });
return;
}
const sourceState = readJsonFile<Record<string, unknown>>(sourcePath);
const targetState = readJsonFile<Record<string, unknown>>(targetPath);
if (!sourceState || !targetState) return;
const targetProjects = getArrayLength(targetState, 'projects');
const sourceProjects = getArrayLength(sourceState, 'projects');
if (targetProjects === 0 && sourceProjects > 0) {
writeFileSync(targetPath, JSON.stringify(sourceState, null, 2), 'utf8');
logger.info(`[nianxx-play] Migrated ${sourceProjects} project record(s) from bundled/source runtime data`);
}
}
function copyDirectoryIfEmpty(sourceDir: string, targetDir: string): void {
if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) return;
if (hasDirectoryEntries(targetDir)) return;
mkdirSync(targetDir, { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
}
function migrateExistingRuntimeData(runtime: NianxxPlayRuntime, dirs: ReturnType<typeof getRuntimeDataDirs>): void {
try {
migrateStateFile(join(runtime.dir, '.data', 'app-state.json'), join(dirs.dataDir, 'app-state.json'));
copyDirectoryIfEmpty(join(runtime.dir, 'public', 'uploads'), dirs.uploadDir);
copyDirectoryIfEmpty(join(runtime.dir, 'public', 'generated-results'), dirs.resultDir);
} catch (error) {
logger.warn('[nianxx-play] Failed to migrate existing local runtime data:', error);
}
}
function parseRuntimeEnvValue(raw: string): string {
const value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
try {
return JSON.parse(value);
} catch {
return value.slice(1, -1);
}
}
return value;
}
function loadBundledRuntimeEnv(runtime: NianxxPlayRuntime): Record<string, string> {
const runtimeEnvPath = join(runtime.dir, RUNTIME_ENV_FILE_NAME);
if (!existsSync(runtimeEnvPath)) return {};
try {
const values: Record<string, string> = {};
const raw = readFileSync(runtimeEnvPath, 'utf8');
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
values[match[1]] = parseRuntimeEnvValue(match[2]);
}
logger.info(`[nianxx-play] Loaded bundled runtime env (${Object.keys(values).length} values)`);
return values;
} catch (error) {
logger.warn('[nianxx-play] Failed to load bundled runtime env:', error);
return {};
}
}
function createRuntimeEnv(port: number, runtime: NianxxPlayRuntime) {
const dirs = getRuntimeDataDirs();
migrateExistingRuntimeData(runtime, dirs);
const bundledRuntimeEnv = loadBundledRuntimeEnv(runtime);
return {
...process.env,
...bundledRuntimeEnv,
PORT: String(port),
HOSTNAME: '127.0.0.1',
NEXT_TELEMETRY_DISABLED: '1',
NIANXXPLAY_RUNTIME_DIR: dirs.runtimeRoot,
NIANXXPLAY_DATA_DIR: dirs.dataDir,
NIANXXPLAY_UPLOAD_DIR: dirs.uploadDir,
NIANXXPLAY_RESULT_DIR: dirs.resultDir,
NIANXXPLAY_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,
NIANXXPLAY_DESKTOP_MANAGED: '1',
};
}
function spawnSourceRuntime(runtime: NianxxPlayRuntime, port: number): ChildProcessWithoutNullStreams {
const scriptName = getScriptName();
logger.info(`[nianxx-play] Starting source service: npm run ${scriptName} (cwd=${runtime.dir}, port=${port})`);
return spawn(getNpmCommand(), ['run', scriptName], {
cwd: runtime.dir,
env: createRuntimeEnv(port, runtime),
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
});
}
function spawnStandaloneRuntime(runtime: NianxxPlayRuntime, port: number): ChildProcessWithoutNullStreams {
if (!runtime.serverPath) {
throw new Error('NianxxPlay standalone server path is missing.');
}
logger.info(`[nianxx-play] Starting bundled service: ${runtime.serverPath} (port=${port})`);
return spawn(process.execPath, [runtime.serverPath], {
cwd: runtime.dir,
env: {
...createRuntimeEnv(port, runtime),
ELECTRON_RUN_AS_NODE: '1',
NODE_ENV: 'production',
},
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
});
}
function attachLifecycleHandlers(): void {
if (!nianxxPlayProcess) return;
attachProcessLogger(nianxxPlayProcess.stdout, 'info');
attachProcessLogger(nianxxPlayProcess.stderr, 'warn');
nianxxPlayProcess.once('exit', (code, signal) => {
const reason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
logger.warn(`[nianxx-play] Service exited with ${reason}`);
if (code && code !== 0) {
lastServiceError = `NianxxPlay exited with ${reason}`;
}
nianxxPlayProcess = null;
activePort = null;
});
nianxxPlayProcess.once('error', (error) => {
lastServiceError = error.message;
logger.warn('[nianxx-play] Failed to start service:', error);
nianxxPlayProcess = null;
activePort = null;
});
}
export async function getNianxxPlayServiceStatus(): Promise<NianxxPlayServiceStatus> {
const running = await canReachNianxxPlay();
return createStatus({
running,
error: running ? undefined : (lastServiceError ?? undefined),
});
}
export async function ensureNianxxPlayServiceStarted(): Promise<NianxxPlayServiceStatus> {
const baseUrl = getBaseUrl();
if (await canReachNianxxPlay(baseUrl)) {
lastServiceError = null;
return createStatus({ running: true, starting: false, managed: Boolean(nianxxPlayProcess), error: undefined });
}
const runtime = resolveNianxxPlayRuntime();
if (!runtime) {
lastServiceError = 'NianxxPlay runtime was not found.';
return createStatus({ success: false, running: false, starting: false, error: lastServiceError });
}
if (!nianxxPlayProcess || nianxxPlayProcess.killed) {
const port = await findAvailablePort(getConfiguredPort());
activePort = port;
nianxxPlayProcess = runtime.kind === 'standalone'
? spawnStandaloneRuntime(runtime, port)
: spawnSourceRuntime(runtime, port);
attachLifecycleHandlers();
}
const running = await waitUntilReachable(getBaseUrl());
if (!running) {
lastServiceError = `NianxxPlay did not become ready within ${Math.round(STARTUP_TIMEOUT_MS / 1000)}s.`;
} else {
lastServiceError = null;
}
return createStatus({
running,
starting: !running && Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
managed: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
error: running ? undefined : (lastServiceError ?? undefined),
});
}
export function stopNianxxPlayService(): void {
if (!nianxxPlayProcess || nianxxPlayProcess.killed) return;
logger.info('[nianxx-play] Stopping service');
nianxxPlayProcess.kill();
nianxxPlayProcess = null;
activePort = null;
}

View File

@@ -32,6 +32,17 @@ const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
const LEGACY_MINIMAX_OAUTH_PLUGIN_ID = 'minimax-portal-auth';
const MERGED_MINIMAX_PLUGIN_ID = 'minimax';
const YINIAN_DESKTOP_TOOLS_PROFILE = 'coding';
const YINIAN_INTERNAL_MODEL_REF = 'minimax/MiniMax-M2.7';
const YINIAN_CORE_PLUGIN_IDS = new Set([
'minimax',
'cloud-sync',
'openclaw-weixin',
'agentbus',
]);
const YINIAN_BASE_PLUGIN_IDS = new Set(['minimax', 'cloud-sync']);
const YINIAN_CORE_CHANNEL_IDS = new Set(['openclaw-weixin', 'agentbus']);
const YINIAN_DISABLED_CHANNEL_IDS = new Set(['feishu', 'dingtalk', 'wecom']);
interface BundledPluginManifest {
id: string;
@@ -1031,6 +1042,87 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isYinianManagedConfig(config: Record<string, unknown>): boolean {
const models = isPlainRecord(config.models) ? config.models : null;
const providers = models && isPlainRecord(models.providers) ? models.providers : null;
if (providers && isPlainRecord(providers[OPENCLAW_PROVIDER_KEY_MINIMAX])) return true;
const agents = isPlainRecord(config.agents) ? config.agents : null;
const defaults = agents && isPlainRecord(agents.defaults) ? agents.defaults : null;
const model = defaults && isPlainRecord(defaults.model) ? defaults.model : null;
return model?.primary === YINIAN_INTERNAL_MODEL_REF;
}
function trimYinianPluginSurface(config: Record<string, unknown>): boolean {
if (!isYinianManagedConfig(config)) return false;
const plugins = isPlainRecord(config.plugins) ? config.plugins : null;
let modified = false;
if (plugins) {
if (Array.isArray(plugins.allow)) {
const channels = isPlainRecord(config.channels) ? config.channels : null;
const nextAllow = (plugins.allow as unknown[])
.filter((pluginId): pluginId is string => {
if (typeof pluginId !== 'string') return false;
if (YINIAN_BASE_PLUGIN_IDS.has(pluginId)) return true;
return YINIAN_CORE_CHANNEL_IDS.has(pluginId) && Boolean(channels?.[pluginId]);
});
for (const pluginId of YINIAN_BASE_PLUGIN_IDS) {
if (!nextAllow.includes(pluginId)) nextAllow.push(pluginId);
}
for (const pluginId of YINIAN_CORE_CHANNEL_IDS) {
if (channels?.[pluginId] && !nextAllow.includes(pluginId)) nextAllow.push(pluginId);
}
if (JSON.stringify(nextAllow) !== JSON.stringify(plugins.allow)) {
plugins.allow = nextAllow;
modified = true;
console.log(`[sanitize] Trimmed plugins.allow to Yinian core plugins: ${nextAllow.join(', ')}`);
}
}
if (isPlainRecord(plugins.entries)) {
for (const pluginId of Object.keys(plugins.entries)) {
if (YINIAN_CORE_PLUGIN_IDS.has(pluginId)) continue;
delete plugins.entries[pluginId];
modified = true;
console.log(`[sanitize] Removed non-core plugin entry "${pluginId}" from Yinian OpenClaw config`);
}
}
}
const channels = isPlainRecord(config.channels) ? config.channels : null;
if (channels) {
for (const channelId of Object.keys(channels)) {
if (YINIAN_CORE_CHANNEL_IDS.has(channelId)) continue;
delete channels[channelId];
modified = true;
console.log(`[sanitize] Removed disabled channel "${channelId}" from Yinian OpenClaw config`);
}
}
const agents = isPlainRecord(config.agents) ? config.agents : null;
if (Array.isArray(agents?.list)) {
const nextAgents = agents.list.filter((entry) => {
if (!isPlainRecord(entry)) return true;
const id = typeof entry.id === 'string' ? entry.id : '';
const channelType = typeof entry.channelType === 'string'
? entry.channelType
: typeof entry.channel === 'string'
? entry.channel
: '';
if (YINIAN_DISABLED_CHANNEL_IDS.has(channelType)) return false;
return !Array.from(YINIAN_DISABLED_CHANNEL_IDS).some((disabledId) => id.startsWith(`channel-${disabledId}-`));
});
if (nextAgents.length !== agents.list.length) {
agents.list = nextAgents;
modified = true;
console.log('[sanitize] Removed disabled channel agents from Yinian OpenClaw config');
}
}
return modified;
}
function removeLegacyMoonshotKimiSearchConfig(config: Record<string, unknown>): boolean {
const tools = isPlainRecord(config.tools) ? config.tools : null;
const web = tools && isPlainRecord(tools.web) ? tools.web : null;
@@ -1687,6 +1779,15 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
if (modified) config.plugins = validPlugins;
} else if (typeof plugins === 'object') {
const pluginsObj = plugins as Record<string, unknown>;
const KNOWN_INVALID_PLUGINS_ROOT_KEYS = ['bundledDiscovery'];
for (const key of KNOWN_INVALID_PLUGINS_ROOT_KEYS) {
if (key in pluginsObj) {
console.log(`[sanitize] Removing deprecated key "plugins.${key}" from openclaw.json`);
delete pluginsObj[key];
modified = true;
}
}
if (Array.isArray(pluginsObj.load)) {
const validLoad: unknown[] = [];
for (const p of pluginsObj.load) {
@@ -1784,14 +1885,16 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
// ── tools.profile & sessions.visibility ───────────────────────
// OpenClaw 3.8+ requires tools.profile = 'full' and tools.sessions.visibility = 'all'
// for ClawX to properly integrate with its updated tool system.
const toolsConfig = (config.tools as Record<string, unknown> | undefined) || {};
// 智念助手需要保留多轮上下文、文件生成与能力包调用。
// OpenClaw 的 messaging/minimal 档会进入 raw model run导致每轮不 replay 历史。
// 因此桌面端使用 coding 档UI 层再隐藏普通用户不需要理解的开发者概念。
const toolsConfig = isPlainRecord(config.tools) ? config.tools : {};
let toolsModified = false;
if (toolsConfig.profile !== 'full') {
toolsConfig.profile = 'full';
if (toolsConfig.profile !== YINIAN_DESKTOP_TOOLS_PROFILE) {
toolsConfig.profile = YINIAN_DESKTOP_TOOLS_PROFILE;
toolsModified = true;
console.log(`[sanitize] Set tools.profile="${YINIAN_DESKTOP_TOOLS_PROFILE}" for Yinian desktop chat latency`);
}
const sessions = (toolsConfig.sessions as Record<string, unknown> | undefined) || {};
@@ -1821,6 +1924,56 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
modified = true;
}
// ── agents.defaults desktop defaults ──────────────────────────
// OpenClaw 会把可见 skills 汇总进系统上下文。对智念助手的普通对话,
// 默认不展开全量 skill 目录;后续由“快捷任务/能力包”按需注入。
// OpenClaw 默认每 30 分钟在 main session 跑一次 heartbeat。桌面端不
// 使用这类后台心跳任务,避免它变成普通用户可见的历史会话。
if (isYinianManagedConfig(config)) {
const agentsConfig = isPlainRecord(config.agents) ? config.agents : {};
const defaults = isPlainRecord(agentsConfig.defaults) ? agentsConfig.defaults : {};
if (!Array.isArray(defaults.skills)) {
defaults.skills = [];
agentsConfig.defaults = defaults;
config.agents = agentsConfig;
modified = true;
console.log('[sanitize] Set agents.defaults.skills=[] for Yinian desktop lightweight chat');
}
const heartbeat = isPlainRecord(defaults.heartbeat) ? defaults.heartbeat : {};
if (heartbeat.every !== '0m') {
heartbeat.every = '0m';
defaults.heartbeat = heartbeat;
agentsConfig.defaults = defaults;
config.agents = agentsConfig;
modified = true;
console.log('[sanitize] Disabled OpenClaw agent heartbeat for Yinian desktop');
}
if (Array.isArray(agentsConfig.list)) {
const nextList = agentsConfig.list.map((entry) => {
if (!isPlainRecord(entry) || entry.id !== 'main') return entry;
const tools = isPlainRecord(entry.tools) ? entry.tools : {};
if (tools.profile === YINIAN_DESKTOP_TOOLS_PROFILE) return entry;
return {
...entry,
tools: {
...tools,
profile: YINIAN_DESKTOP_TOOLS_PROFILE,
},
};
});
if (JSON.stringify(nextList) !== JSON.stringify(agentsConfig.list)) {
agentsConfig.list = nextList;
config.agents = agentsConfig;
modified = true;
console.log(`[sanitize] Set main agent tools.profile="${YINIAN_DESKTOP_TOOLS_PROFILE}" for Yinian desktop context replay`);
}
}
}
if (trimYinianPluginSurface(config)) {
modified = true;
}
// ── plugins.entries.feishu cleanup ──────────────────────────────
// Normalize feishu plugin ids dynamically based on installed manifest.
// Different environments may report either "openclaw-lark" or
@@ -2224,6 +2377,10 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
if (trimYinianPluginSurface(config)) {
modified = true;
}
if (modified) {
await writeOpenClawJson(config);
console.log('[sanitize] openclaw.json sanitized successfully');

View File

@@ -8,8 +8,8 @@ import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
symlinkSync,
unlinkSync,
} from 'node:fs';
import { spawn } from 'node:child_process';
import { homedir } from 'node:os';
@@ -150,10 +150,9 @@ export async function installOpenClawCli(): Promise<{
try {
mkdirSync(targetDir, { recursive: true });
// Remove existing file/symlink to avoid EEXIST
if (existsSync(target)) {
unlinkSync(target);
}
// Remove existing file/symlink to avoid EEXIST. `existsSync` is false for
// broken symlinks, so use rmSync directly with force.
rmSync(target, { force: true });
symlinkSync(wrapperSrc, target);
chmodSync(wrapperSrc, 0o755);
@@ -304,7 +303,7 @@ export async function autoInstallCliIfNeeded(
if (isCliInstalled()) {
if (target && wrapperSrc && existsSync(target)) {
try {
unlinkSync(target);
rmSync(target, { force: true });
symlinkSync(wrapperSrc, target);
logger.debug(`Refreshed CLI symlink: ${target} -> ${wrapperSrc}`);
} catch {

View File

@@ -0,0 +1,74 @@
import { existsSync, readdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
function fsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
return `\\\\?\\${filePath.replace(/\//g, '\\')}`;
}
function removeClipboardPackagesFromScope(scopeDir: string): number {
if (!existsSync(fsPath(scopeDir))) return 0;
let removed = 0;
let entries: string[] = [];
try {
entries = readdirSync(fsPath(scopeDir));
} catch {
return 0;
}
for (const entry of entries) {
if (entry !== 'clipboard' && !entry.startsWith('clipboard-')) continue;
try {
rmSync(fsPath(join(scopeDir, entry)), { recursive: true, force: true });
removed += 1;
} catch {
// Best-effort cleanup only.
}
}
return removed;
}
function cleanupNodeModules(nodeModulesDir: string): number {
return removeClipboardPackagesFromScope(join(nodeModulesDir, '@mariozechner'));
}
function cleanupNodeModulesChildren(rootDir: string): number {
if (!existsSync(fsPath(rootDir))) return 0;
let removed = 0;
let entries: Array<{ name: string; isDirectory: () => boolean }> = [];
try {
entries = readdirSync(fsPath(rootDir), { withFileTypes: true }) as typeof entries;
} catch {
return 0;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
removed += cleanupNodeModules(join(rootDir, entry.name, 'node_modules'));
}
return removed;
}
export function cleanupOpenClawRuntimeNativeClipboard(openclawDir: string): number {
return cleanupNodeModules(join(openclawDir, 'node_modules'));
}
export function cleanupOpenClawUserNativeClipboard(): number {
const openclawHome = join(homedir(), '.openclaw');
let removed = 0;
removed += cleanupNodeModules(join(openclawHome, 'runtime', 'openclaw', 'node_modules'));
removed += cleanupNodeModules(join(openclawHome, 'npm', 'node_modules'));
removed += cleanupNodeModules(join(openclawHome, 'node_modules'));
removed += cleanupNodeModulesChildren(join(openclawHome, 'extensions'));
removed += cleanupNodeModulesChildren(join(openclawHome, 'plugin-runtime-deps'));
return removed;
}

View File

@@ -6,6 +6,7 @@ import { createRequire } from 'node:module';
import { dirname, isAbsolute, join, normalize } from 'path';
import { homedir } from 'os';
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'fs';
import { cleanupOpenClawRuntimeNativeClipboard } from './optional-native-cleanup';
const require = createRequire(import.meta.url);
@@ -33,6 +34,8 @@ const REQUIRED_OPENCLAW_CONTEXT_MODULES = [
'qrcode-terminal/vendor/QRCode/index.js',
'qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js',
] as const;
const YINIAN_OPENCLAW_RUNTIME_PATCH_MARKER = '.yinian-runtime-patch.json';
const YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION = '2026-05-07-desktop-fast-chat-v1';
export {
quoteForCmd,
@@ -172,6 +175,25 @@ function hasRequiredOpenClawContextModules(dir: string): boolean {
}
}
function readYinianOpenClawRuntimePatchVersion(dir: string): string | undefined {
try {
const markerPath = join(dir, YINIAN_OPENCLAW_RUNTIME_PATCH_MARKER);
if (!existsSync(fsPath(markerPath))) return undefined;
const marker = JSON.parse(readFileSync(fsPath(markerPath), 'utf-8')) as { version?: string };
return marker.version;
} catch {
return undefined;
}
}
function hasYinianOpenClawRuntimePatch(dir: string): boolean {
return readYinianOpenClawRuntimePatchVersion(dir) === YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION;
}
function hasPackagedOpenClawRuntimeRequirements(dir: string): boolean {
return hasRequiredOpenClawContextModules(dir) && hasYinianOpenClawRuntimePatch(dir);
}
function samePath(left: string, right: string): boolean {
try {
return realpathSync(fsPath(left)) === realpathSync(fsPath(right));
@@ -253,10 +275,12 @@ function findExternalOpenClawDir(excludedDirs: string[]): string | null {
seen.add(candidate);
if (excludedDirs.some((excluded) => samePath(candidate, excluded))) continue;
if (!isValidOpenClawPackageDir(candidate)) continue;
if (hasRequiredOpenClawContextModules(candidate)) return candidate;
logOpenClawRuntime('[openclaw-runtime] Ignoring external OpenClaw installation because required app dependencies are missing', {
if (hasPackagedOpenClawRuntimeRequirements(candidate)) return candidate;
logOpenClawRuntime('[openclaw-runtime] Ignoring external OpenClaw installation because it is not a patched Yinian runtime', {
candidate,
requiredModules: REQUIRED_OPENCLAW_CONTEXT_MODULES,
runtimePatchVersion: readYinianOpenClawRuntimePatchVersion(candidate) ?? null,
expectedRuntimePatchVersion: YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION,
});
}
@@ -271,7 +295,7 @@ function installBundledOpenClawToManagedRuntime(bundledDir: string, managedDir:
? readOpenClawVersion(managedDir)
: undefined;
if (managedVersion && bundledVersion && managedVersion === bundledVersion && hasRequiredOpenClawContextModules(managedDir)) {
if (managedVersion && bundledVersion && managedVersion === bundledVersion && hasPackagedOpenClawRuntimeRequirements(managedDir)) {
return false;
}
@@ -279,6 +303,12 @@ function installBundledOpenClawToManagedRuntime(bundledDir: string, managedDir:
rmSync(fsPath(tempDir), { recursive: true, force: true });
mkdirSync(fsPath(dirname(tempDir)), { recursive: true });
cpSync(fsPath(bundledDir), fsPath(tempDir), { recursive: true, dereference: true });
const removedClipboardPackages = cleanupOpenClawRuntimeNativeClipboard(tempDir);
if (removedClipboardPackages > 0) {
logOpenClawRuntime('[openclaw-runtime] Removed optional native clipboard packages from managed runtime', {
removedClipboardPackages,
});
}
rmSync(fsPath(managedDir), { recursive: true, force: true });
cpSync(fsPath(tempDir), fsPath(managedDir), { recursive: true, dereference: true });
rmSync(fsPath(tempDir), { recursive: true, force: true });
@@ -303,7 +333,7 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
return cachedOpenClawRuntime;
}
if (hasRequiredOpenClawContextModules(managedDir)) {
if (hasPackagedOpenClawRuntimeRequirements(managedDir)) {
cachedOpenClawRuntime = {
dir: managedDir,
source: 'managed',
@@ -337,7 +367,7 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
}
}
if (hasRequiredOpenClawContextModules(managedDir)) {
if (hasPackagedOpenClawRuntimeRequirements(managedDir)) {
cachedOpenClawRuntime = {
dir: managedDir,
source: 'managed',
@@ -356,9 +386,11 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
}
if (isValidOpenClawPackageDir(managedDir)) {
logOpenClawRuntime('[openclaw-runtime] Ignoring managed OpenClaw runtime because required app dependencies are missing', {
logOpenClawRuntime('[openclaw-runtime] Ignoring managed OpenClaw runtime because it is not a patched Yinian runtime', {
managedDir,
requiredModules: REQUIRED_OPENCLAW_CONTEXT_MODULES,
runtimePatchVersion: readYinianOpenClawRuntimePatchVersion(managedDir) ?? null,
expectedRuntimePatchVersion: YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION,
});
}
@@ -415,10 +447,11 @@ export function reinstallManagedOpenClawRuntime(): OpenClawRuntimeResolution {
installedFromBundled = installBundledOpenClawToManagedRuntime(bundledDir, managedDir);
}
const managedReady = hasPackagedOpenClawRuntimeRequirements(managedDir);
cachedOpenClawRuntime = {
dir: hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir,
source: hasRequiredOpenClawContextModules(managedDir) ? 'managed' : isValidOpenClawPackageDir(bundledDir) ? 'bundled' : 'missing',
version: readOpenClawVersion(hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir),
dir: managedReady ? managedDir : bundledDir,
source: managedReady ? 'managed' : isValidOpenClawPackageDir(bundledDir) ? 'bundled' : 'missing',
version: readOpenClawVersion(managedReady ? managedDir : bundledDir),
bundledDir,
managedDir,
installedFromBundled,

View File

@@ -1,9 +1,8 @@
/**
* Shared OpenClaw Plugin Install Utilities
*
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins
* (DingTalk, WeCom, Feishu, WeChat). Used both at app startup (to auto-upgrade
* stale plugins) and when a user configures a channel.
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins.
* 智念助手内测包只主动管理核心插件,避免无用渠道插件被重新拉回。
*
* Note: QQBot was moved to a built-in channel in OpenClaw 3.31 and is no longer
* managed as a plugin.
@@ -115,11 +114,9 @@ function toErrorDiagnostic(error: unknown): { code?: string; name?: string; mess
// ── Known plugin-ID corrections ─────────────────────────────────────────────
// Some npm packages ship with an openclaw.plugin.json whose "id" field
// doesn't match the ID the plugin code actually exports. After copying we
// patch both the manifest AND the compiled JS so the Gateway accepts them.
const MANIFEST_ID_FIXES: Record<string, string> = {
'wecom-openclaw-plugin': 'wecom',
};
// doesn't match the ID the plugin code actually exports. Keep this hook for
// future bundled plugins that may need it.
const MANIFEST_ID_FIXES: Record<string, string> = {};
/**
* After a plugin has been copied to ~/.openclaw/extensions/<dir>, fix any
@@ -231,10 +228,6 @@ function patchPluginEntryIds(targetDir: string): void {
// ── Plugin npm name mapping ──────────────────────────────────────────────────
const PLUGIN_NPM_NAMES: Record<string, string> = {
dingtalk: '@soimy/dingtalk',
wecom: '@wecom/wecom-openclaw-plugin',
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};
@@ -498,24 +491,6 @@ export function buildCandidateSources(pluginDirName: string): string[] {
// ── Per-channel plugin helpers ───────────────────────────────────────────────
export function ensureDingTalkPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk');
}
export function ensureWeComPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
}
export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled(
'feishu-openclaw-plugin',
buildCandidateSources('feishu-openclaw-plugin'),
'Feishu',
);
}
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
}
@@ -530,10 +505,6 @@ export function ensureCloudSyncPluginInstalled(): { installed: boolean; warning?
* All bundled plugins, in the same order as after-pack.cjs BUNDLED_PLUGINS.
*/
const ALL_BUNDLED_PLUGINS = [
{ fn: ensureDingTalkPluginInstalled, label: 'DingTalk' },
{ fn: ensureWeComPluginInstalled, label: 'WeCom' },
{ fn: ensureFeishuPluginInstalled, label: 'Feishu' },
{ fn: ensureWeChatPluginInstalled, label: 'WeChat' },
{ fn: ensureCloudSyncPluginInstalled, label: 'Cloud Sync' },
] as const;

View File

@@ -28,6 +28,8 @@ export interface YinianInitializationStatus {
const INTERNAL_PROVIDER_KEY = 'minimax';
const INTERNAL_MODEL_ID = 'MiniMax-M2.7';
const INTERNAL_MODEL_REF = `${INTERNAL_PROVIDER_KEY}/${INTERNAL_MODEL_ID}`;
const INTERNAL_MODEL_TIMEOUT_SECONDS = 300;
const DESKTOP_TOOLS_PROFILE = 'coding';
let initializationInFlight: Promise<YinianInitializationStatus> | null = null;
const DEFAULT_STEPS: YinianInitializationStep[] = [
@@ -143,10 +145,12 @@ async function seedInternalModelConfig(): Promise<void> {
const config = await readJsonFile(configPath);
const models = asObject(config.models);
const providers = asObject(models.providers);
const pricing = asObject(models.pricing);
providers[INTERNAL_PROVIDER_KEY] = {
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
authHeader: true,
timeoutSeconds: INTERNAL_MODEL_TIMEOUT_SECONDS,
models: [
{
id: INTERNAL_MODEL_ID,
@@ -158,10 +162,19 @@ async function seedInternalModelConfig(): Promise<void> {
},
],
};
pricing.enabled = false;
models.mode = 'merge';
models.providers = providers;
models.pricing = pricing;
config.models = models;
const tools = asObject(config.tools);
tools.profile = DESKTOP_TOOLS_PROFILE;
const sessions = asObject(tools.sessions);
sessions.visibility = 'all';
tools.sessions = sessions;
config.tools = tools;
const agents = asObject(config.agents);
const defaults = asObject(agents.defaults);
defaults.model = {
@@ -169,6 +182,11 @@ async function seedInternalModelConfig(): Promise<void> {
fallbacks: ['minimax/MiniMax-M2.5'],
};
defaults.workspace = join(homedir(), '.openclaw', 'workspace');
defaults.skills = [];
defaults.heartbeat = {
...asObject(defaults.heartbeat),
every: '0m',
};
agents.defaults = defaults;
if (!Array.isArray(agents.list)) {
agents.list = [
@@ -178,8 +196,23 @@ async function seedInternalModelConfig(): Promise<void> {
default: true,
workspace: join(homedir(), '.openclaw', 'workspace'),
agentDir: '~/.openclaw/agents/main/agent',
skills: [],
tools: { profile: DESKTOP_TOOLS_PROFILE },
},
];
} else {
agents.list = agents.list.map((entry) => {
const agent = asObject(entry);
if (agent.id !== 'main') return entry;
return {
...agent,
skills: Array.isArray(agent.skills) ? agent.skills : [],
tools: {
...asObject(agent.tools),
profile: DESKTOP_TOOLS_PROFILE,
},
};
});
}
config.agents = agents;