Commit f9e6de26 authored by AI-甘富林's avatar AI-甘富林

fix bundled runtime startup and installer packaging

parent 8d5b8ec7
node_modules/ node_modules/
dist/ dist/
.pnpm-store/ .pnpm-store/
.turbo/ .turbo/
...@@ -6,6 +6,8 @@ dist/ ...@@ -6,6 +6,8 @@ dist/
.tmp/ .tmp/
.tmp-gateway-probe/ .tmp-gateway-probe/
.claude/ .claude/
.codex/
Microsoft/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*.log *.log
......
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
"dev:build": "tsup --config tsup.config.ts --watch", "dev:build": "tsup --config tsup.config.ts --watch",
"dev:start": "wait-on tcp:5173 file:dist/main/index.js && electronmon .", "dev:start": "wait-on tcp:5173 file:dist/main/index.js && electronmon .",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"package": "corepack pnpm run build && electron-builder --config electron-builder.yml", "package": "corepack pnpm --dir ../.. run materialize:runtime && corepack pnpm run build && electron-builder --config electron-builder.yml",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
......
import path from "node:path"; import path from "node:path";
import { appendFile, readFile, writeFile } from "node:fs/promises"; import { appendFile, readFile, writeFile } from "node:fs/promises";
import { BrowserWindow, app } from "electron"; import { BrowserWindow, app } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client"; import { GatewayClient } from "@qjclaw/gateway-client";
...@@ -338,12 +338,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -338,12 +338,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const credits = session.state === "authenticated" ? await api.credits.getSummary() : null; const credits = session.state === "authenticated" ? await api.credits.getSummary() : null;
const skills = session.state === "authenticated" ? await api.skills.list() : []; const skills = session.state === "authenticated" ? await api.skills.list() : [];
const workspace = await api.workspace.getSummary(); const workspace = await api.workspace.getSummary();
const readyWorkspaceSkills = workspace.skills.filter((skill) => skill.ready);
const readySkills = skills.filter((skill) => skill.ready);
const selectedSkillId = preferredSkillId const selectedSkillId = preferredSkillId
? (workspace.skills.find((skill) => skill.id === preferredSkillId)?.id ? (readyWorkspaceSkills.find((skill) => skill.id === preferredSkillId)?.id
?? skills.find((skill) => skill.id === preferredSkillId)?.id ?? readySkills.find((skill) => skill.id === preferredSkillId)?.id)
?? workspace.skills[0]?.id : (readyWorkspaceSkills[0]?.id ?? readySkills[0]?.id);
?? skills[0]?.id)
: (workspace.skills[0]?.id ?? skills[0]?.id);
const sessions = await api.chat.listSessions(); const sessions = await api.chat.listSessions();
const sessionId = state?.activeSessionId || sessions[0]?.id || "desktop-main"; const sessionId = state?.activeSessionId || sessions[0]?.id || "desktop-main";
const system = await api.system.getSummary(); const system = await api.system.getSummary();
...@@ -494,9 +494,14 @@ async function bootstrap(): Promise<void> { ...@@ -494,9 +494,14 @@ async function bootstrap(): Promise<void> {
runtimeDataDir: path.join(systemSummary.userDataPath, "runtime"), runtimeDataDir: path.join(systemSummary.userDataPath, "runtime"),
logFilePath: path.join(systemSummary.logsPath, "runtime-manager.log"), logFilePath: path.join(systemSummary.logsPath, "runtime-manager.log"),
requestedMode: resolveRequestedRuntimeMode(config.runtimeMode), requestedMode: resolveRequestedRuntimeMode(config.runtimeMode),
managedConfigResolver: async ({ action, defaultConfig }) => runtimeCloudClient.buildManagedConfig(defaultConfig, action) managedConfigResolver: async ({ action, defaultConfig }) => runtimeCloudClient.buildManagedConfig(defaultConfig, action),
strictBundledRuntime: systemSummary.isPackaged
}); });
await runtimeManager.configure(); await runtimeManager.configure();
const runtimeStatus = await runtimeManager.status();
if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") {
throw new Error(`Packaged app bundled runtime is not ready: ${runtimeStatus.payloadState}`);
}
const gatewayClient = new GatewayClient({ const gatewayClient = new GatewayClient({
url: resolveEffectiveGatewayUrl(config.gatewayUrl, localOpenClawConfig?.gatewayUrl), url: resolveEffectiveGatewayUrl(config.gatewayUrl, localOpenClawConfig?.gatewayUrl),
...@@ -534,16 +539,21 @@ async function bootstrap(): Promise<void> { ...@@ -534,16 +539,21 @@ async function bootstrap(): Promise<void> {
}); });
if (resolveRequestedRuntimeMode(config.runtimeMode) !== "external-gateway" && (await secretManager.getApiKey())) { if (resolveRequestedRuntimeMode(config.runtimeMode) !== "external-gateway" && (await secretManager.getApiKey())) {
await runtimeManager.start(); try {
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection(); await runtimeCloudClient.fetchConfig("init");
if (runtimeGatewayConnection.url) { await runtimeManager.start();
await gatewayClient.reconfigure( const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
runtimeGatewayConnection.url, if (runtimeGatewayConnection.url) {
runtimeGatewayConnection.token, await gatewayClient.reconfigure(
(await secretManager.getDeviceToken()) ?? undefined runtimeGatewayConnection.url,
); runtimeGatewayConnection.token,
(await secretManager.getDeviceToken()) ?? undefined
);
}
await runtimeCloudSupervisor.start();
} catch (error) {
console.error("Bundled runtime bootstrap skipped:", error instanceof Error ? error.message : String(error));
} }
await runtimeCloudSupervisor.start();
} }
registerDesktopIpc({ registerDesktopIpc({
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
type DesktopApi, type DesktopApi,
type GatewayStatus, type GatewayStatus,
type PluginSummary, type PluginSummary,
type RuntimeCloudFetchAction,
type RuntimeCloudStatus, type RuntimeCloudStatus,
type RuntimeStatus, type RuntimeStatus,
type SaveConfigInput, type SaveConfigInput,
...@@ -57,50 +58,50 @@ function toControlUiUrl(gatewayUrl: string): string { ...@@ -57,50 +58,50 @@ function toControlUiUrl(gatewayUrl: string): string {
const PLUGIN_SPECS = [ const PLUGIN_SPECS = [
{ {
id: "spreadsheet-tools", id: "spreadsheet-tools",
name: "表格工具", name: "Spreadsheet Tools",
description: "读取、统计和处理 Excel、CSV 等常见表格文件。", description: "Read and process Excel or CSV files.",
packages: ["openpyxl", "pandas"], packages: ["openpyxl", "pandas"],
includedByDefault: true includedByDefault: true
}, },
{ {
id: "document-tools", id: "document-tools",
name: "文档工具", name: "Document Tools",
description: "读取和处理 txt、md、docx、pdf 等常见文档。", description: "Read and process txt, md, docx, or pdf files.",
packages: ["pypdf", "python-docx", "charset-normalizer"], packages: ["pypdf", "python-docx", "charset-normalizer"],
includedByDefault: true includedByDefault: true
}, },
{ {
id: "web-tools", id: "web-tools",
name: "网页信息提取", name: "Web Tools",
description: "抓取网页内容并进行提取、清洗和汇总功能。", description: "Fetch and extract web content.",
packages: ["requests", "beautifulsoup4", "lxml"], packages: ["requests", "beautifulsoup4", "lxml"],
includedByDefault: true includedByDefault: true
}, },
{ {
id: "file-tools", id: "file-tools",
name: "文件工具", name: "File Tools",
description: "进行文件复制、移动和归档等操作。", description: "Copy, move, and archive files.",
packages: [], packages: [],
includedByDefault: true includedByDefault: true
}, },
{ {
id: "runtime-diagnostics", id: "runtime-diagnostics",
name: "运行时诊断", name: "Runtime Diagnostics",
description: "查看运行时信息、日志和本地运行状态。", description: "Inspect runtime status and logs.",
packages: [], packages: [],
includedByDefault: true includedByDefault: true
}, },
{ {
id: "browser-automation", id: "browser-automation",
name: "网页自动化", name: "Browser Automation",
description: "自动编写脚本并执行网页浏览、点击和表单操作。", description: "Automate browser actions with scripts.",
packages: [], packages: [],
includedByDefault: false includedByDefault: false
}, },
{ {
id: "ocr-tools", id: "ocr-tools",
name: "OCR 文档识别", name: "OCR Tools",
description: "识别扫描件和图片文字,属于扩展插件。", description: "Recognize text from images or scans.",
packages: [], packages: [],
includedByDefault: false includedByDefault: false
} }
...@@ -142,7 +143,15 @@ function buildChatSummary( ...@@ -142,7 +143,15 @@ function buildChatSummary(
return { return {
chatReady: false, chatReady: false,
chatLaunchState: "unbound", chatLaunchState: "unbound",
chatStatusMessage: "请先绑定员工密钥。" chatStatusMessage: "闂備浇宕垫慨鏉懨洪妶澶婂簥闁哄被鍎遍崒銊︾箾閹寸偞鐨戠痪鎯с偢閺岀喓鈧稒顭囩粻姗€鏌¢崱鏇炲祮闁哄本绋戦埥澶娾枍椤撗傜凹閻庨潧銈搁獮鍥敊閻熼澹曢梻鍌氱墛缁嬫帡藟閵忋倖鐓欓柛娑橈功閻帒鈹?"
};
}
if (runtimeCloudStatus.state === "error") {
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeCloudStatus.lastError ?? "OpenClaw 闂備礁鎼ˇ顐﹀疾濠婂牆绀夋慨妞诲亾闁靛棔绶氶獮瀣晝閳ь剛鐚惧澶嬬厸闁割偁鍨洪弳鈺呮⒒閸涱噯鑰挎慨濠冩そ瀵墎鎹勯妸鎰╁€濋弻锝夊Χ閸涱噮妫﹂悗瑙勬礃缁诲牓骞冮埡鍛€绘俊顖滎儠閸嬫ê鈹戦悩顔肩仾闁稿氦鍋愰崚鎺楀礈瑜庨崰鍡涙煥閺囩偛鈧瓕绻?"
}; };
} }
...@@ -151,7 +160,7 @@ function buildChatSummary( ...@@ -151,7 +160,7 @@ function buildChatSummary(
return { return {
chatReady: true, chatReady: true,
chatLaunchState: "ready", chatLaunchState: "ready",
chatStatusMessage: "运行时已经就绪。" chatStatusMessage: "闂備礁鎼ˇ顐﹀疾濠婂牆绀夋慨妞诲亾闁靛棔绶氶獮瀣晝閳ь剛鐚惧澶嬪仯闁告繂瀚幆鍫ユ煕閵堝棗绗х紒杈ㄦ尰閹峰懘宕妷褜鍞舵繝娈垮枟鑿ч柛鏂挎捣濡叉劙骞掑Δ濠冩櫆闂佺鏈〃鍛?"
}; };
} }
...@@ -159,7 +168,7 @@ function buildChatSummary( ...@@ -159,7 +168,7 @@ function buildChatSummary(
return { return {
chatReady: false, chatReady: false,
chatLaunchState: "error", chatLaunchState: "error",
chatStatusMessage: runtimeStatus.lastError ?? runtimeStatus.message ?? "运行时启动失败,请稍后重试。" chatStatusMessage: runtimeStatus.lastError ?? runtimeStatus.message ?? "闂備礁鎼ˇ顐﹀疾濠婂牆绀夋慨妞诲亾闁靛棔绶氶獮瀣晝閳ь剛鐚惧澶嬪仯闁惧繒鎳撻崝瀣煕鎼淬垻鎳囬柡灞剧洴瀵剛鎷犻幓鎺濈€抽梻渚€娼уú锕傚垂瑜版帒绠憸鐗堝笒鍞銈嗙墬缁酣藝椤曗偓閺岋綁鎮╅崣澶婃灎濡炪們鍎查幑鍥春閿濆顫呴柕鍫濇嚀琚濋梺鐟板悑閻n亪宕濈仦瑙f瀺闁靛繈鍊栭崑锝夋煕閵夛絽濡界痪鐐倐閺?"
}; };
} }
...@@ -167,7 +176,7 @@ function buildChatSummary( ...@@ -167,7 +176,7 @@ function buildChatSummary(
return { return {
chatReady: false, chatReady: false,
chatLaunchState: "error", chatLaunchState: "error",
chatStatusMessage: gatewayStatus.lastError ?? gatewayStatus.message ?? "网关连接失败,请稍后重试。" chatStatusMessage: gatewayStatus.lastError ?? gatewayStatus.message ?? "缂傚倸鍊搁崐鎼佸疮椤栫偛鍨傞柣銏㈩焾閻鎲告惔鈽嗙劷濠电姵纰嶉崐鐑芥煛婢跺鐏ユい锕€寮剁换婵嬪閳ュ啿濮哥紓渚囧枛婢т粙骞夐幘顔芥櫇闁稿本绋掑▍鏍倵閸忓浜鹃梺鍛婂姈閸庡啿鈻撻懠顒傜=濞达絽澹婇崕蹇曠磼婢跺灏︽鐐插暙铻栭柛娑卞枤閸樻帡鎮楅獮鍨姎闁绘绻愬嵄闁归棿鐒﹂悡?"
}; };
} }
...@@ -175,14 +184,14 @@ function buildChatSummary( ...@@ -175,14 +184,14 @@ function buildChatSummary(
return { return {
chatReady: false, chatReady: false,
chatLaunchState: "starting", chatLaunchState: "starting",
chatStatusMessage: runtimeStatus.message || "运行时正在启动中。" chatStatusMessage: runtimeStatus.message || "闂備礁鎼ˇ顐﹀疾濠婂牆绀夋慨妞诲亾闁靛棔绶氶獮瀣晝閳ь剛鐚惧澶嬬厾闁告稑顭崯蹇涙煕閺傚搫浜鹃梻鍌欑窔濞艰崵鎷归悢鐓庣鐎光偓閸曨偆鐣鹃柟鍏肩暘閸斿瞼绮堟径鎰厪濠电偛鐏濋埀顒佺洴瀹曘垽顢楅崟顒傚帾?"
}; };
} }
return { return {
chatReady: false, chatReady: false,
chatLaunchState: "starting", chatLaunchState: "starting",
chatStatusMessage: gatewayStatus?.message ?? "正在连接网关。" chatStatusMessage: gatewayStatus?.message ?? "濠电姵顔栭崰妤冩崲閹邦喖绶ら柦妯侯檧閼版寧銇勮箛鎾村櫤濞存嚎鍊濋弻锝夊箛椤撶喓绋囨繝銏f硾缁夊墎妲愰幘璇茬闁宠桨鑳舵禒鎾⒑閸涘浼曢柛銉仜?"
}; };
} }
export function registerDesktopIpc(services: MainServices): DesktopApi { export function registerDesktopIpc(services: MainServices): DesktopApi {
...@@ -252,6 +261,30 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -252,6 +261,30 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
await runtimeCloudSupervisor.stop(reason); await runtimeCloudSupervisor.stop(reason);
}; };
const startManagedRuntime = async (
reason: string,
options: {
action?: RuntimeCloudFetchAction;
restart?: boolean;
config?: AppConfig;
inputToken?: string;
} = {}
): Promise<RuntimeStatus> => {
const nextConfig = options.config ?? await configService.load();
const apiKey = await secretManager.getApiKey();
if (nextConfig.runtimeMode === "external-gateway" || !apiKey) {
await runtimeCloudSupervisor.stop(reason);
await reconfigureGatewayClient(nextConfig, options.inputToken);
return runtimeManager.status();
}
await runtimeCloudClient.fetchConfig(options.action ?? "init");
const status = options.restart ? await runtimeManager.restart() : await runtimeManager.start();
await reconfigureGatewayClient(nextConfig, options.inputToken);
await syncRuntimeCloudSupervisor(reason);
return status;
};
const buildWorkspaceSummary = async (): Promise<WorkspaceSummary> => { const buildWorkspaceSummary = async (): Promise<WorkspaceSummary> => {
const runtimeStatus = await runtimeManager.status(); const runtimeStatus = await runtimeManager.status();
let runtimeCloudStatus = await runtimeCloudClient.getStatus(); let runtimeCloudStatus = await runtimeCloudClient.getStatus();
...@@ -366,8 +399,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -366,8 +399,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
skillId, skillId,
skillName: selectedSkill?.name, skillName: selectedSkill?.name,
message: skillId message: skillId
? "技能 " + (selectedSkill?.name ?? skillId) + " 将使用当前员工配置中的模型 " + configuredModelLabel + "。" ? `Skill ${selectedSkill?.name ?? skillId} is bound to cloud model ${configuredModelLabel}.`
: "当前对话将使用员工配置中的模型 " + configuredModelLabel + "。" : `Using cloud default model ${configuredModelLabel}.`
}; };
} }
...@@ -379,8 +412,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -379,8 +412,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
skillId, skillId,
skillName: selectedSkill?.name, skillName: selectedSkill?.name,
message: skillId message: skillId
? "云端模型配置数据不可用,技能 " + (selectedSkill?.name ?? skillId) + " 将回退到本地标签 " + config.defaultModel + "。" ? `Skill ${selectedSkill?.name ?? skillId} is using local fallback model ${config.defaultModel}.`
: "云端模型配置数据不可用,当前对话将回退到本地标签 " + config.defaultModel + "。" : `Using local fallback model ${config.defaultModel}.`
}; };
}; };
...@@ -413,25 +446,14 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -413,25 +446,14 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
ipcMain.handle(IPC_CHANNELS.runtimeGetStatus, async () => runtimeManager.status()); ipcMain.handle(IPC_CHANNELS.runtimeGetStatus, async () => runtimeManager.status());
ipcMain.handle(IPC_CHANNELS.runtimeTailLogs, async (_event, limit?: number) => runtimeManager.tailLogs(limit)); ipcMain.handle(IPC_CHANNELS.runtimeTailLogs, async (_event, limit?: number) => runtimeManager.tailLogs(limit));
ipcMain.handle(IPC_CHANNELS.runtimeStart, async () => { ipcMain.handle(IPC_CHANNELS.runtimeStart, async () => startManagedRuntime("runtime-start", { action: "init" }));
const status = await runtimeManager.start();
await reconfigureGatewayClient();
await syncRuntimeCloudSupervisor("runtime-start");
return status;
});
ipcMain.handle(IPC_CHANNELS.runtimeStop, async () => { ipcMain.handle(IPC_CHANNELS.runtimeStop, async () => {
await runtimeCloudSupervisor.stop("runtime-stop"); await runtimeCloudSupervisor.stop("runtime-stop");
const status = await runtimeManager.stop(); const status = await runtimeManager.stop();
await reconfigureGatewayClient(); await reconfigureGatewayClient();
return status; return status;
}); });
ipcMain.handle(IPC_CHANNELS.runtimeRestart, async () => { ipcMain.handle(IPC_CHANNELS.runtimeRestart, async () => startManagedRuntime("runtime-restart", { action: "init", restart: true }));
await runtimeCloudSupervisor.stop("runtime-restart");
const status = await runtimeManager.restart();
await reconfigureGatewayClient();
await syncRuntimeCloudSupervisor("runtime-restart");
return status;
});
ipcMain.handle(IPC_CHANNELS.runtimeHealth, async () => runtimeManager.health()); ipcMain.handle(IPC_CHANNELS.runtimeHealth, async () => runtimeManager.health());
ipcMain.handle(IPC_CHANNELS.runtimeCloudGetStatus, async () => runtimeCloudClient.getStatus()); ipcMain.handle(IPC_CHANNELS.runtimeCloudGetStatus, async () => runtimeCloudClient.getStatus());
ipcMain.handle(IPC_CHANNELS.runtimeCloudFetchConfig, async (_event, action) => runtimeCloudClient.fetchConfig(action)); ipcMain.handle(IPC_CHANNELS.runtimeCloudFetchConfig, async (_event, action) => runtimeCloudClient.fetchConfig(action));
...@@ -452,13 +474,17 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -452,13 +474,17 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
await runtimeManager.setRequestedMode(config.runtimeMode); await runtimeManager.setRequestedMode(config.runtimeMode);
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) { if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await runtimeManager.start(); await startManagedRuntime("config-save", {
action: "init",
restart: true,
config,
inputToken: input.gatewayToken
});
} else { } else {
await runtimeCloudSupervisor.stop("config-save"); await runtimeCloudSupervisor.stop("config-save");
await reconfigureGatewayClient(config, input.gatewayToken);
await syncRuntimeCloudSupervisor("config-save");
} }
await reconfigureGatewayClient(config, input.gatewayToken);
await syncRuntimeCloudSupervisor("config-save");
void dailyReportService.runDueCheck().catch(() => undefined); void dailyReportService.runDueCheck().catch(() => undefined);
return getEffectiveConfig(); return getEffectiveConfig();
}); });
...@@ -620,25 +646,14 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -620,25 +646,14 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runtime: { runtime: {
getStatus: () => runtimeManager.status(), getStatus: () => runtimeManager.status(),
tailLogs: (limit?: number) => runtimeManager.tailLogs(limit), tailLogs: (limit?: number) => runtimeManager.tailLogs(limit),
start: async () => { start: async () => startManagedRuntime("runtime-start", { action: "init" }),
const status = await runtimeManager.start();
await reconfigureGatewayClient();
await syncRuntimeCloudSupervisor("runtime-start");
return status;
},
stop: async () => { stop: async () => {
await runtimeCloudSupervisor.stop("runtime-stop"); await runtimeCloudSupervisor.stop("runtime-stop");
const status = await runtimeManager.stop(); const status = await runtimeManager.stop();
await reconfigureGatewayClient(); await reconfigureGatewayClient();
return status; return status;
}, },
restart: async () => { restart: async () => startManagedRuntime("runtime-restart", { action: "init", restart: true }),
await runtimeCloudSupervisor.stop("runtime-restart");
const status = await runtimeManager.restart();
await reconfigureGatewayClient();
await syncRuntimeCloudSupervisor("runtime-restart");
return status;
},
health: () => runtimeManager.health() health: () => runtimeManager.health()
}, },
runtimeCloud: { runtimeCloud: {
...@@ -664,13 +679,17 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -664,13 +679,17 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
await runtimeManager.setRequestedMode(config.runtimeMode); await runtimeManager.setRequestedMode(config.runtimeMode);
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) { if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await runtimeManager.start(); await startManagedRuntime("config-save", {
action: "init",
restart: true,
config,
inputToken: input.gatewayToken
});
} else { } else {
await runtimeCloudSupervisor.stop("config-save"); await runtimeCloudSupervisor.stop("config-save");
await reconfigureGatewayClient(config, input.gatewayToken);
await syncRuntimeCloudSupervisor("config-save");
} }
await reconfigureGatewayClient(config, input.gatewayToken);
await syncRuntimeCloudSupervisor("config-save");
void dailyReportService.runDueCheck().catch(() => undefined); void dailyReportService.runDueCheck().catch(() => undefined);
return getEffectiveConfig(); return getEffectiveConfig();
} }
......
...@@ -11,6 +11,12 @@ interface PreparedSkillExecution { ...@@ -11,6 +11,12 @@ interface PreparedSkillExecution {
localPath: string; localPath: string;
} }
interface BundledRuntimeManifestShape {
packagedOpenClawEntry?: string;
packagedSkillsRoot?: string;
sourceOpenClawEntry?: string;
}
const MANAGED_SKILL_PREFIX = "qjclaw-cloud-"; const MANAGED_SKILL_PREFIX = "qjclaw-cloud-";
function slugify(value: string): string { function slugify(value: string): string {
...@@ -125,7 +131,17 @@ export class RuntimeSkillBridgeService { ...@@ -125,7 +131,17 @@ export class RuntimeSkillBridgeService {
const runtimePaths = this.runtimeManager.resolveBundledPaths(); const runtimePaths = this.runtimeManager.resolveBundledPaths();
const manifestPath = path.join(runtimePaths.runtimeDir, "runtime-manifest.json"); const manifestPath = path.join(runtimePaths.runtimeDir, "runtime-manifest.json");
const manifestRaw = await readFile(manifestPath, "utf8"); const manifestRaw = await readFile(manifestPath, "utf8");
const manifest = JSON.parse(stripBom(manifestRaw)) as { sourceOpenClawEntry?: string }; const manifest = JSON.parse(stripBom(manifestRaw)) as BundledRuntimeManifestShape;
const packagedSkillsRoot = typeof manifest.packagedSkillsRoot === "string" ? manifest.packagedSkillsRoot.trim() : "";
if (packagedSkillsRoot) {
return path.resolve(runtimePaths.runtimeDir, packagedSkillsRoot);
}
const packagedEntry = typeof manifest.packagedOpenClawEntry === "string" ? manifest.packagedOpenClawEntry.trim() : "";
if (packagedEntry) {
return path.join(path.dirname(path.resolve(runtimePaths.runtimeDir, packagedEntry)), "skills");
}
const sourceEntry = typeof manifest.sourceOpenClawEntry === "string" ? manifest.sourceOpenClawEntry.trim() : ""; const sourceEntry = typeof manifest.sourceOpenClawEntry === "string" ? manifest.sourceOpenClawEntry.trim() : "";
if (sourceEntry) { if (sourceEntry) {
return path.join(path.dirname(sourceEntry), "skills"); return path.join(path.dirname(sourceEntry), "skills");
......
...@@ -5,6 +5,6 @@ ...@@ -5,6 +5,6 @@
- `vendor/openclaw-runtime` is reserved for the pinned runtime payload - `vendor/openclaw-runtime` is reserved for the pinned runtime payload
- `installer-smoke.ps1` performs a real silent NSIS install into `.tmp`, launches the installed app in smoke mode, and validates packaged paths plus diagnostics output - `installer-smoke.ps1` performs a real silent NSIS install into `.tmp`, launches the installed app in smoke mode, and validates packaged paths plus diagnostics output
- `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output - `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output
- `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` from the machine's installed `node.exe`, `openclaw`, local OpenClaw config, and a locked Python dependency set - `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it
- `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end - `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end
- `installer-smoke.ps1` also validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python` - `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`
\ No newline at end of file
...@@ -79,7 +79,7 @@ if ($setupProcess.ExitCode -ne 0) { ...@@ -79,7 +79,7 @@ if ($setupProcess.ExitCode -ne 0) {
$installedExe = Join-Path $InstallDir 'QianjiangClaw.exe' $installedExe = Join-Path $InstallDir 'QianjiangClaw.exe'
$resourcesAsar = Join-Path $InstallDir 'resources\app.asar' $resourcesAsar = Join-Path $InstallDir 'resources\app.asar'
$runtimeResourceDir = Join-Path $InstallDir 'resources\vendor\openclaw-runtime' $runtimeResourceDir = Join-Path $InstallDir 'resources\vendor\openclaw-runtime'
$packagedPythonExe = Join-Path $runtimeResourceDir 'python\Scripts\python.exe' $packagedPythonExe = Join-Path $runtimeResourceDir 'python\python.exe'
$packagedPythonManifest = Join-Path $runtimeResourceDir 'python\python-manifest.json' $packagedPythonManifest = Join-Path $runtimeResourceDir 'python\python-manifest.json'
if (-not (Test-Path $installedExe)) { if (-not (Test-Path $installedExe)) {
throw "Installed executable not found at $installedExe" throw "Installed executable not found at $installedExe"
...@@ -225,6 +225,12 @@ if (String(streamSmoke.finalContent || '') !== String(sendResult.lastMessage && ...@@ -225,6 +225,12 @@ if (String(streamSmoke.finalContent || '') !== String(sendResult.lastMessage &&
if (expectBundled === 'true') { if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {}; const runtimeStatus = sendResult.runtimeStatusAfterProbe || {};
const runtimeHealth = sendResult.runtimeHealthAfterProbe || {}; const runtimeHealth = sendResult.runtimeHealthAfterProbe || {};
if (runtimeStatus.selectedMode !== 'bundled-runtime') {
throw new Error('Installed smoke did not select bundled-runtime mode: ' + runtimeStatus.selectedMode);
}
if (runtimeStatus.payloadState !== 'ready') {
throw new Error('Installed smoke bundled runtime payload is not ready: ' + runtimeStatus.payloadState);
}
if (runtimeStatus.activeMode !== 'bundled-runtime') { if (runtimeStatus.activeMode !== 'bundled-runtime') {
throw new Error('Installed smoke did not switch to bundled-runtime mode: ' + runtimeStatus.activeMode); throw new Error('Installed smoke did not switch to bundled-runtime mode: ' + runtimeStatus.activeMode);
} }
...@@ -262,6 +268,8 @@ const summary = { ...@@ -262,6 +268,8 @@ const summary = {
runtimeActiveMode: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.activeMode || ''), runtimeActiveMode: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.activeMode || ''),
runtimeProcessState: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.processState || ''), runtimeProcessState: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.processState || ''),
runtimeGatewayUrl: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.gatewayUrl || ''), runtimeGatewayUrl: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.gatewayUrl || ''),
runtimePayloadState: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.payloadState || ''),
runtimeLastError: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.lastError || ''),
runtimePythonReady: Boolean(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.pythonReady), runtimePythonReady: Boolean(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.pythonReady),
runtimePythonVersion: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.pythonVersion || ''), runtimePythonVersion: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.pythonVersion || ''),
runtimePythonPackages: sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.installedPythonPackages || [], runtimePythonPackages: sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.installedPythonPackages || [],
...@@ -280,11 +288,21 @@ const summary = { ...@@ -280,11 +288,21 @@ const summary = {
console.log(JSON.stringify(summary, null, 2)); console.log(JSON.stringify(summary, null, 2));
"@ "@
$runtimeModeValue = if ($RuntimeMode) { $RuntimeMode } else { '' } $runtimeModeValue = if ($RuntimeMode) { $RuntimeMode } else { '' }
$summary = & node -e $validator $SmokeOutput $UserDataPath $LogsPath $runtimeModeValue $expectBundledValue $runtimeResourceDir $packagedPythonExe $packagedPythonManifest $SetupExe $InstallDir $installedExe ([string]$appExitCode) $runtimeManagerLog = Join-Path $LogsPath 'runtime-manager.log'
if ($LASTEXITCODE -ne 0) { try {
throw 'Installed smoke validation failed.' $summary = & node -e $validator $SmokeOutput $UserDataPath $LogsPath $runtimeModeValue $expectBundledValue $runtimeResourceDir $packagedPythonExe $packagedPythonManifest $SetupExe $InstallDir $installedExe ([string]$appExitCode)
} if ($LASTEXITCODE -ne 0) {
throw 'Installed smoke validation failed.'
}
Write-Output $summary Write-Output $summary
Stop-SmokeAppProcesses Stop-SmokeAppProcesses
exit 0 exit 0
\ No newline at end of file } catch {
if (Test-Path $runtimeManagerLog) {
Write-Host '==== runtime-manager.log ===='
Get-Content $runtimeManagerLog -Tail 200 | Write-Host
}
Stop-SmokeAppProcesses
throw
}
...@@ -52,6 +52,8 @@ $SourceNodeExe = [System.IO.Path]::GetFullPath($SourceNodeExe) ...@@ -52,6 +52,8 @@ $SourceNodeExe = [System.IO.Path]::GetFullPath($SourceNodeExe)
$SourceOpenClawEntry = [System.IO.Path]::GetFullPath($SourceOpenClawEntry) $SourceOpenClawEntry = [System.IO.Path]::GetFullPath($SourceOpenClawEntry)
$SourcePythonExe = [System.IO.Path]::GetFullPath($SourcePythonExe) $SourcePythonExe = [System.IO.Path]::GetFullPath($SourcePythonExe)
$RequirementsPath = [System.IO.Path]::GetFullPath($RequirementsPath) $RequirementsPath = [System.IO.Path]::GetFullPath($RequirementsPath)
$SourceOpenClawDir = Split-Path $SourceOpenClawEntry -Parent
$SourcePythonDir = Split-Path $SourcePythonExe -Parent
if (-not (Test-Path $SourceConfigPath)) { if (-not (Test-Path $SourceConfigPath)) {
throw "OpenClaw config not found at $SourceConfigPath" throw "OpenClaw config not found at $SourceConfigPath"
...@@ -68,66 +70,74 @@ if (-not (Test-Path $SourcePythonExe)) { ...@@ -68,66 +70,74 @@ if (-not (Test-Path $SourcePythonExe)) {
if (-not (Test-Path $RequirementsPath)) { if (-not (Test-Path $RequirementsPath)) {
throw "Runtime requirements lock file not found at $RequirementsPath" throw "Runtime requirements lock file not found at $RequirementsPath"
} }
if (-not (Test-Path $SourceOpenClawDir)) {
throw "OpenClaw package directory not found at $SourceOpenClawDir"
}
if (-not (Test-Path $SourcePythonDir)) {
throw "Python installation directory not found at $SourcePythonDir"
}
$nodeDir = Join-Path $RuntimeDir 'node' $stagingDir = Join-Path (Split-Path $RuntimeDir -Parent) ('openclaw-runtime.staging.' + ([guid]::NewGuid().ToString('N')))
$openclawDir = Join-Path $RuntimeDir 'openclaw' $nodeDir = Join-Path $stagingDir 'node'
$configDir = Join-Path $RuntimeDir 'config' $openclawDir = Join-Path $stagingDir 'openclaw'
$pythonDir = Join-Path $RuntimeDir 'python' $openclawPackageDir = Join-Path $openclawDir 'package'
$manifestPath = Join-Path $RuntimeDir 'runtime-manifest.json' $configDir = Join-Path $stagingDir 'config'
$pythonDir = Join-Path $stagingDir 'python'
$manifestPath = Join-Path $stagingDir 'runtime-manifest.json'
$wrapperPath = Join-Path $openclawDir 'index.js' $wrapperPath = Join-Path $openclawDir 'index.js'
$configPath = Join-Path $configDir 'openclaw.json' $configPath = Join-Path $configDir 'openclaw.json'
$pythonManifestPath = Join-Path $pythonDir 'python-manifest.json' $pythonManifestPath = Join-Path $pythonDir 'python-manifest.json'
$pythonRequirementsCopy = Join-Path $pythonDir 'runtime-requirements.lock.txt' $pythonRequirementsCopy = Join-Path $pythonDir 'runtime-requirements.lock.txt'
$payloadReadmePath = Join-Path $stagingDir 'README.md'
$payloadPythonExe = Join-Path $pythonDir 'python.exe'
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
if (Test-Path $pythonDir) { if (Test-Path $stagingDir) {
Remove-Item -Recurse -Force $pythonDir Remove-Item -Recurse -Force $stagingDir
} }
New-Item -ItemType Directory -Force -Path $nodeDir, $openclawDir, $configDir | Out-Null try {
Copy-Item -Path $SourceNodeExe -Destination (Join-Path $nodeDir 'node.exe') -Force New-Item -ItemType Directory -Force -Path $nodeDir, $openclawDir, $configDir | Out-Null
Copy-Item -Path $SourceNodeExe -Destination (Join-Path $nodeDir 'node.exe') -Force
$config = Get-Content $SourceConfigPath -Raw | ConvertFrom-Json
if (-not $config.gateway) {
$config | Add-Member -NotePropertyName gateway -NotePropertyValue ([pscustomobject]@{})
}
if (-not $config.gateway.auth) {
$config.gateway | Add-Member -NotePropertyName auth -NotePropertyValue ([pscustomobject]@{})
}
$config.gateway.mode = 'local'
$config.gateway.bind = 'loopback'
$config.gateway.port = $GatewayPort
$config.gateway.auth.mode = 'token'
$config.gateway.auth.token = $GatewayToken
$utf8NoBom = New-Object System.Text.UTF8Encoding($false) Write-Host "Copying OpenClaw package from $SourceOpenClawDir"
[System.IO.File]::WriteAllText($configPath, ($config | ConvertTo-Json -Depth 100), $utf8NoBom) Copy-Item -Path $SourceOpenClawDir -Destination $openclawPackageDir -Recurse -Force
Write-Host "Creating bundled Python runtime under $pythonDir" $config = Get-Content $SourceConfigPath -Raw | ConvertFrom-Json
& $SourcePythonExe -m venv $pythonDir if (-not $config.gateway) {
if ($LASTEXITCODE -ne 0) { $config | Add-Member -NotePropertyName gateway -NotePropertyValue ([pscustomobject]@{})
throw "Failed to create Python virtual environment at $pythonDir" }
} if (-not $config.gateway.auth) {
$config.gateway | Add-Member -NotePropertyName auth -NotePropertyValue ([pscustomobject]@{})
}
$config.gateway.mode = 'local'
$config.gateway.bind = 'loopback'
$config.gateway.port = $GatewayPort
$config.gateway.auth.mode = 'token'
$config.gateway.auth.token = $GatewayToken
[System.IO.File]::WriteAllText($configPath, ($config | ConvertTo-Json -Depth 100), $utf8NoBom)
$venvPythonExe = Join-Path $pythonDir 'Scripts\python.exe' Write-Host "Copying bundled Python runtime from $SourcePythonDir"
if (-not (Test-Path $venvPythonExe)) { Copy-Item -Path $SourcePythonDir -Destination $pythonDir -Recurse -Force
throw "Bundled Python executable not found at $venvPythonExe" if (-not (Test-Path $payloadPythonExe)) {
} throw "Bundled Python executable not found at $payloadPythonExe"
}
Write-Host 'Upgrading pip in bundled Python runtime' Write-Host 'Upgrading pip in bundled Python runtime'
& $venvPythonExe -m pip install --disable-pip-version-check --upgrade pip & $payloadPythonExe -m pip install --disable-pip-version-check --upgrade pip
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw 'Failed to upgrade pip in bundled Python runtime.' throw 'Failed to upgrade pip in bundled Python runtime.'
} }
Write-Host "Installing locked runtime dependencies from $RequirementsPath" Write-Host "Installing locked runtime dependencies from $RequirementsPath"
& $venvPythonExe -m pip install --disable-pip-version-check -r $RequirementsPath & $payloadPythonExe -m pip install --disable-pip-version-check -r $RequirementsPath
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw 'Failed to install bundled Python dependencies.' throw 'Failed to install bundled Python dependencies.'
} }
Copy-Item -Path $RequirementsPath -Destination $pythonRequirementsCopy -Force Copy-Item -Path $RequirementsPath -Destination $pythonRequirementsCopy -Force
$manifestScript = @" $manifestScript = @"
import importlib.metadata as metadata import importlib.metadata as metadata
import json import json
import pathlib import pathlib
...@@ -147,7 +157,6 @@ for line in requirements_path.read_text(encoding='utf-8').splitlines(): ...@@ -147,7 +157,6 @@ for line in requirements_path.read_text(encoding='utf-8').splitlines():
payload = { payload = {
'pythonVersion': sys.version.split()[0], 'pythonVersion': sys.version.split()[0],
'requirementsPath': str(requirements_path),
'requestedPackages': requested, 'requestedPackages': requested,
'resolvedPackages': sorted( 'resolvedPackages': sorted(
[ [
...@@ -160,46 +169,64 @@ payload = { ...@@ -160,46 +169,64 @@ payload = {
} }
print(json.dumps(payload)) print(json.dumps(payload))
"@ "@
$pythonManifestJson = & $venvPythonExe -c $manifestScript $pythonRequirementsCopy $pythonManifestJson = & $payloadPythonExe -c $manifestScript $pythonRequirementsCopy
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw 'Failed to inspect bundled Python manifest.' throw 'Failed to inspect bundled Python manifest.'
} }
$pythonManifest = $pythonManifestJson | ConvertFrom-Json $pythonManifestProbe = $pythonManifestJson | ConvertFrom-Json
$pythonManifest | Add-Member -NotePropertyName sourcePythonExe -NotePropertyValue $SourcePythonExe $materializedAt = (Get-Date).ToString('o')
$pythonManifest | Add-Member -NotePropertyName materializedAt -NotePropertyValue ((Get-Date).ToString('o')) $pythonManifest = [ordered]@{
[System.IO.File]::WriteAllText($pythonManifestPath, ($pythonManifest | ConvertTo-Json -Depth 100), $utf8NoBom) pythonVersion = $pythonManifestProbe.pythonVersion
executable = 'python.exe'
$manifest = [ordered]@{ requirementsPath = 'runtime-requirements.lock.txt'
sourceNodeExe = $SourceNodeExe requestedPackages = @($pythonManifestProbe.requestedPackages)
sourceOpenClawEntry = $SourceOpenClawEntry resolvedPackages = @($pythonManifestProbe.resolvedPackages)
sourceConfigPath = $SourceConfigPath materializedAt = $materializedAt
sourcePythonExe = $SourcePythonExe }
requirementsPath = $RequirementsPath [System.IO.File]::WriteAllText($pythonManifestPath, ($pythonManifest | ConvertTo-Json -Depth 100), $utf8NoBom)
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken $openClawPackageJsonPath = Join-Path $openclawPackageDir 'package.json'
materializedAt = (Get-Date).ToString('o') $openClawPackage = if (Test-Path $openClawPackageJsonPath) {
pythonExecutable = $venvPythonExe Get-Content $openClawPackageJsonPath -Raw | ConvertFrom-Json
pythonManifestPath = $pythonManifestPath } else {
pythonVersion = $pythonManifest.pythonVersion $null
installedPythonPackages = @($pythonManifest.requestedPackages | ForEach-Object { "$($_.name)==$($_.version)" }) }
}
[System.IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 100), $utf8NoBom) $manifest = [ordered]@{
bundledNodeExecutable = 'node/node.exe'
$wrapper = @" packagedOpenClawEntry = 'openclaw/package/openclaw.mjs'
packagedSkillsRoot = 'openclaw/package/skills'
defaultConfigPath = 'config/openclaw.json'
pythonExecutable = 'python/python.exe'
pythonManifestPath = 'python/python-manifest.json'
requirementsPath = 'python/runtime-requirements.lock.txt'
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
materializedAt = $materializedAt
pythonVersion = $pythonManifest.pythonVersion
openClawVersion = if ($openClawPackage) { $openClawPackage.version } else { $null }
installedPythonPackages = @($pythonManifest.requestedPackages | ForEach-Object { "$($_.name)==$($_.version)" })
}
[System.IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 100), $utf8NoBom)
$wrapper = @"
const { readFileSync } = require('node:fs'); const { readFileSync } = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { pathToFileURL } = require('node:url'); const { pathToFileURL } = require('node:url');
async function main() { async function main() {
const manifestPath = path.join(__dirname, '..', 'runtime-manifest.json'); const runtimeDir = path.join(__dirname, '..');
const manifestPath = path.join(runtimeDir, 'runtime-manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (!manifest.sourceOpenClawEntry) { const entryRelative = typeof manifest.packagedOpenClawEntry === 'string' ? manifest.packagedOpenClawEntry.trim() : '';
throw new Error('runtime-manifest.json is missing sourceOpenClawEntry'); if (!entryRelative) {
throw new Error('runtime-manifest.json is missing packagedOpenClawEntry');
} }
process.argv[1] = manifest.sourceOpenClawEntry; const entryPath = path.resolve(runtimeDir, entryRelative);
await import(pathToFileURL(manifest.sourceOpenClawEntry).href); process.argv[1] = entryPath;
await import(pathToFileURL(entryPath).href);
} }
main().catch((error) => { main().catch((error) => {
...@@ -208,20 +235,67 @@ main().catch((error) => { ...@@ -208,20 +235,67 @@ main().catch((error) => {
process.exit(1); process.exit(1);
}); });
"@ "@
[System.IO.File]::WriteAllText($wrapperPath, $wrapper, $utf8NoBom) [System.IO.File]::WriteAllText($wrapperPath, $wrapper, $utf8NoBom)
[PSCustomObject]@{ $readme = @"
ok = $true # Bundled Runtime Payload
runtimeDir = $RuntimeDir
nodeExecutable = (Join-Path $nodeDir 'node.exe') Immutable packaged payload under `vendor/openclaw-runtime/` includes:
openClawEntry = $wrapperPath
configPath = $configPath - `node/node.exe`
manifestPath = $manifestPath - `openclaw/index.js`
pythonExecutable = $venvPythonExe - `openclaw/package/openclaw.mjs`
pythonManifestPath = $pythonManifestPath - `config/openclaw.json`
gatewayPort = $GatewayPort - `python/python.exe`
gatewayToken = $GatewayToken - `python/python-manifest.json`
sourceOpenClawEntry = $SourceOpenClawEntry - `python/runtime-requirements.lock.txt`
requirementsPath = $RequirementsPath
installedPythonPackages = $manifest.installedPythonPackages Mutable runtime data lives outside the installer payload and should be created under Electron `userData/runtime/`.
} | ConvertTo-Json -Depth 20
\ No newline at end of file The payload is considered ready only when the Node entry, OpenClaw package, Python executable, Python manifest, and locked Python imports all validate successfully on the target machine.
"@
[System.IO.File]::WriteAllText($payloadReadmePath, $readme.TrimStart(), $utf8NoBom)
$requiredPaths = @(
(Join-Path $nodeDir 'node.exe'),
$wrapperPath,
(Join-Path $openclawPackageDir 'openclaw.mjs'),
$openClawPackageJsonPath,
$configPath,
$payloadPythonExe,
$pythonManifestPath,
$manifestPath,
$payloadReadmePath
)
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path $requiredPath)) {
throw "Bundled runtime materialization failed; missing $requiredPath"
}
}
if (Test-Path $RuntimeDir) {
Remove-Item -Recurse -Force $RuntimeDir
}
Move-Item -Path $stagingDir -Destination $RuntimeDir
$stagingDir = $null
[PSCustomObject]@{
ok = $true
runtimeDir = $RuntimeDir
nodeExecutable = (Join-Path $RuntimeDir 'node\node.exe')
openClawEntry = (Join-Path $RuntimeDir 'openclaw\index.js')
openClawPackageDir = (Join-Path $RuntimeDir 'openclaw\package')
configPath = (Join-Path $RuntimeDir 'config\openclaw.json')
manifestPath = (Join-Path $RuntimeDir 'runtime-manifest.json')
pythonExecutable = (Join-Path $RuntimeDir 'python\python.exe')
pythonManifestPath = (Join-Path $RuntimeDir 'python\python-manifest.json')
requirementsPath = (Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt')
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
installedPythonPackages = $manifest.installedPythonPackages
} | ConvertTo-Json -Depth 20
} finally {
if ($stagingDir -and (Test-Path $stagingDir)) {
Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue
}
}
...@@ -4,11 +4,11 @@ ...@@ -4,11 +4,11 @@
"version": "0.1.0", "version": "0.1.0",
"packageManager": "pnpm@10.0.0", "packageManager": "pnpm@10.0.0",
"scripts": { "scripts": {
"build": "corepack pnpm --filter @qjclaw/shared-types build && corepack pnpm --filter @qjclaw/runtime-manager build && corepack pnpm --filter @qjclaw/gateway-client build && corepack pnpm --filter @qjclaw/ui build && corepack pnpm --filter @qjclaw/desktop build", "build": "corepack pnpm --filter @qjclaw/shared-types build && corepack pnpm --filter @qjclaw/gateway-client build && corepack pnpm --filter @qjclaw/runtime-manager build && corepack pnpm --filter @qjclaw/ui build && corepack pnpm --filter @qjclaw/desktop build",
"clean": "corepack pnpm -r run clean", "clean": "corepack pnpm -r run clean",
"dev": "corepack pnpm --parallel --filter @qjclaw/ui --filter @qjclaw/desktop dev", "dev": "corepack pnpm --parallel --filter @qjclaw/ui --filter @qjclaw/desktop dev",
"lint": "corepack pnpm -r run lint", "lint": "corepack pnpm -r run lint",
"package": "corepack pnpm build && corepack pnpm --filter @qjclaw/desktop exec electron-builder --config electron-builder.yml", "package": "corepack pnpm run materialize:runtime && corepack pnpm build && corepack pnpm --filter @qjclaw/desktop exec electron-builder --config electron-builder.yml",
"typecheck": "corepack pnpm -r run typecheck", "typecheck": "corepack pnpm -r run typecheck",
"smoke:installer": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1", "smoke:installer": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1",
"smoke:execution-policy": "powershell -ExecutionPolicy Bypass -File build/scripts/electron-smoke.ps1", "smoke:execution-policy": "powershell -ExecutionPolicy Bypass -File build/scripts/electron-smoke.ps1",
......
...@@ -126,10 +126,44 @@ export interface GatewayPromptStreamHandlers { ...@@ -126,10 +126,44 @@ export interface GatewayPromptStreamHandlers {
onError?: (value: { sessionId: string; runId?: string; error: Error }) => void; onError?: (value: { sessionId: string; runId?: string; error: Error }) => void;
} }
const CLIENT_ID = "gateway-client"; export interface GatewayConnectParamsInput {
const CLIENT_MODE = "backend"; token?: string;
const ROLE = "operator"; deviceToken?: string;
const SCOPES = ["operator.read", "operator.write"]; device?: SignedDeviceProof;
}
export function buildGatewayConnectParams(input: GatewayConnectParamsInput = {}) {
const auth = input.token || input.deviceToken
? {
token: input.token,
deviceToken: input.deviceToken
}
: undefined;
return {
minProtocol: 3,
maxProtocol: 3,
client: {
id: GATEWAY_CLIENT_ID,
version: "qianjiangclaw-desktop",
platform: process.platform,
mode: GATEWAY_CLIENT_MODE
},
role: GATEWAY_CLIENT_ROLE,
scopes: [...GATEWAY_CLIENT_SCOPES],
caps: [...GATEWAY_CLIENT_CAPS],
auth,
device: input.device,
locale: "zh-CN",
userAgent: "qianjiangclaw-desktop"
};
}
export const GATEWAY_CLIENT_ID = "gateway-client";
export const GATEWAY_CLIENT_MODE = "backend";
export const GATEWAY_CLIENT_ROLE = "operator";
export const GATEWAY_CLIENT_SCOPES = ["operator.read", "operator.write"] as const;
export const GATEWAY_CLIENT_CAPS = ["tool-events"] as const;
const ANSI_ESCAPE_PATTERN = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; const ANSI_ESCAPE_PATTERN = /\u001B\[[0-9;?]*[ -/]*[@-~]/g;
const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g; const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g;
const LOG_PREFIX_PATTERN = /^[^A-Za-z0-9\[{(]+(?=[A-Za-z])/; const LOG_PREFIX_PATTERN = /^[^A-Za-z0-9\[{(]+(?=[A-Za-z])/;
...@@ -428,43 +462,24 @@ export class GatewayClient { ...@@ -428,43 +462,24 @@ export class GatewayClient {
const device = this.deviceIdentity const device = this.deviceIdentity
? await this.deviceIdentity.signConnectChallenge({ ? await this.deviceIdentity.signConnectChallenge({
clientId: CLIENT_ID, clientId: GATEWAY_CLIENT_ID,
clientMode: CLIENT_MODE, clientMode: GATEWAY_CLIENT_MODE,
role: ROLE, role: GATEWAY_CLIENT_ROLE,
scopes: SCOPES, scopes: [...GATEWAY_CLIENT_SCOPES],
token: this.token, token: this.token,
nonce nonce
}) })
: undefined; : undefined;
const auth = this.token || this.deviceToken
? {
token: this.token,
deviceToken: this.deviceToken
}
: undefined;
this.sendFrame({ this.sendFrame({
type: "req", type: "req",
id: "1", id: "1",
method: "connect", method: "connect",
params: { params: buildGatewayConnectParams({
minProtocol: 3, token: this.token,
maxProtocol: 3, deviceToken: this.deviceToken,
client: { device
id: CLIENT_ID, })
version: "qianjiangclaw-desktop",
platform: process.platform,
mode: CLIENT_MODE
},
role: ROLE,
scopes: SCOPES,
caps: ["tool-events"],
auth,
device,
locale: "zh-CN",
userAgent: "qianjiangclaw-desktop"
}
}); });
this.appendLog("info", "Sent Gateway connect handshake."); this.appendLog("info", "Sent Gateway connect handshake.");
return; return;
...@@ -978,7 +993,7 @@ export class GatewayClient { ...@@ -978,7 +993,7 @@ export class GatewayClient {
private stripStructuredLogPrefix(message: string): string { private stripStructuredLogPrefix(message: string): string {
return message return message
.replace(LOG_PREFIX_PATTERN, "") .replace(LOG_PREFIX_PATTERN, "")
.replace(/�\?/g, "ok ") .replace(/闁跨喓绁?/g, "ok ")
.replace(/[?]{2,}/g, "") .replace(/[?]{2,}/g, "")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
......
{ {
"name": "@qjclaw/runtime-manager", "name": "@qjclaw/runtime-manager",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"scripts": { "scripts": {
"build": "tsup --config tsup.config.ts", "build": "tsup --config tsup.config.ts",
"clean": "rimraf dist", "clean": "rimraf dist",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@qjclaw/shared-types": "workspace:*" "@qjclaw/gateway-client": "workspace:*",
}, "@qjclaw/shared-types": "workspace:*",
"devDependencies": { "ws": "^8.18.3"
"@types/node": "^22.10.2", },
"rimraf": "^6.0.1", "devDependencies": {
"tsup": "^8.3.5", "@types/node": "^22.10.2",
"typescript": "^5.7.3" "@types/ws": "^8.18.1",
} "rimraf": "^6.0.1",
} "tsup": "^8.3.5",
"typescript": "^5.7.3"
}
}
\ No newline at end of file
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { execFile, spawn, type ChildProcess } from "node:child_process"; import { execFile, spawn, type ChildProcess } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import WebSocket from "ws";
import { buildGatewayConnectParams } from "@qjclaw/gateway-client";
import type { import type {
LogEntry, LogEntry,
RuntimeCloudFetchAction, RuntimeCloudFetchAction,
...@@ -14,11 +17,19 @@ import type { ...@@ -14,11 +17,19 @@ import type {
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const GATEWAY_CONNECT_REQUEST_ID = "runtime-manager-connect";
const GATEWAY_STATUS_REQUEST_ID = "runtime-manager-status";
const GATEWAY_PROBE_TIMEOUT_MS = 4_000;
const GATEWAY_READY_TIMEOUT_MS = 45_000;
const GATEWAY_READY_POLL_INTERVAL_MS = 500;
const MANAGED_CHILD_PID_PREFIX = "__QJC_MANAGED_CHILD_PID__=";
export interface RuntimeResolvedPaths { export interface RuntimeResolvedPaths {
runtimeDir: string; runtimeDir: string;
nodeExecutable: string; nodeExecutable: string;
openClawEntry: string; openClawEntry: string;
packagedOpenClawEntry: string;
runtimeManifestPath: string;
defaultConfigPath: string; defaultConfigPath: string;
pythonExecutable: string; pythonExecutable: string;
pythonManifestPath: string; pythonManifestPath: string;
...@@ -44,6 +55,7 @@ export interface RuntimeManagerOptions { ...@@ -44,6 +55,7 @@ export interface RuntimeManagerOptions {
logFilePath: string; logFilePath: string;
requestedMode?: RuntimeModePreference; requestedMode?: RuntimeModePreference;
managedConfigResolver?: ManagedConfigResolver; managedConfigResolver?: ManagedConfigResolver;
strictBundledRuntime?: boolean;
} }
interface RuntimeGatewayConnection { interface RuntimeGatewayConnection {
...@@ -86,6 +98,22 @@ interface PythonPayloadProbeResult { ...@@ -86,6 +98,22 @@ interface PythonPayloadProbeResult {
error?: string; error?: string;
} }
interface GatewayProbeResult {
ready: boolean;
checkedAt: string;
lastError?: string;
}
interface GatewayProbeErrorShape {
code?: string;
message?: string;
details?: {
code?: string;
reason?: string;
recommendedNextStep?: string;
};
}
async function pathExists(targetPath: string): Promise<boolean> { async function pathExists(targetPath: string): Promise<boolean> {
try { try {
await access(targetPath); await access(targetPath);
...@@ -126,6 +154,173 @@ const PYTHON_RUNTIME_IMPORTS = [ ...@@ -126,6 +154,173 @@ const PYTHON_RUNTIME_IMPORTS = [
["pyyaml", "yaml"] ["pyyaml", "yaml"]
] as const; ] as const;
function formatExecError(error: unknown): string {
if (error instanceof Error) {
const typedError = error as Error & { code?: number | string; stderr?: string };
const details = [
typedError.message,
typeof typedError.code !== "undefined" ? `code=${typedError.code}` : undefined,
typeof typedError.stderr === "string" && typedError.stderr.trim()
? `stderr=${typedError.stderr.trim()}`
: undefined
].filter((value): value is string => Boolean(value));
return details.join("; ");
}
return String(error);
}
function trimTrailingPunctuation(value: string): string {
return value.trim().replace(/[.\s]+$/u, "");
}
function escapePowerShellSingleQuoted(value: string): string {
return value.replace(/'/g, "''");
}
function formatGatewayProbeError(error: GatewayProbeErrorShape | undefined): string {
const parts = [
error?.message,
error?.details?.reason,
error?.details?.recommendedNextStep,
error?.code,
error?.details?.code
].filter((value): value is string => Boolean(value && value.trim()));
return parts.join(" | ") || "Gateway rejected the readiness probe.";
}
async function probeGatewayReadiness(
url: string,
token?: string,
timeoutMs = GATEWAY_PROBE_TIMEOUT_MS
): Promise<GatewayProbeResult> {
return new Promise<GatewayProbeResult>((resolve) => {
let settled = false;
let handshakeSent = false;
let connectAccepted = false;
const finish = (result: Omit<GatewayProbeResult, "checkedAt">) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
try {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
} catch {
// Ignore cleanup failures while finishing the probe.
}
resolve({
checkedAt: new Date().toISOString(),
...result
});
};
const timer = setTimeout(() => {
finish({
ready: false,
lastError: `Timed out while probing bundled Gateway readiness at ${url}.`
});
}, timeoutMs);
const ws = new WebSocket(url);
ws.on("message", (data: WebSocket.RawData) => {
try {
const frame = JSON.parse(data.toString()) as Record<string, unknown>;
const eventName = String(frame.event ?? "");
if (eventName === "connect.challenge" && !handshakeSent) {
handshakeSent = true;
const nonce = String((frame.payload as Record<string, unknown> | undefined)?.nonce ?? "");
if (!nonce) {
finish({
ready: false,
lastError: "Gateway did not provide a challenge nonce during the readiness probe."
});
return;
}
ws.send(JSON.stringify({
type: "req",
id: GATEWAY_CONNECT_REQUEST_ID,
method: "connect",
params: buildGatewayConnectParams({ token })
}));
return;
}
if (frame.type !== "res") {
return;
}
if (frame.id === GATEWAY_CONNECT_REQUEST_ID) {
if (frame.ok === false) {
finish({
ready: false,
lastError: formatGatewayProbeError(frame.error as GatewayProbeErrorShape | undefined)
});
return;
}
connectAccepted = true;
finish({ ready: true });
}
} catch (error) {
finish({
ready: false,
lastError: `Gateway readiness probe returned malformed data: ${formatExecError(error)}`
});
}
});
ws.on("error", (error) => {
finish({
ready: false,
lastError: `Failed to connect to ${url}. Check that OpenClaw Gateway is reachable. ${formatExecError(error)}`
});
});
ws.on("close", (code: number) => {
if (settled) {
return;
}
finish({
ready: false,
lastError: connectAccepted
? `Gateway closed before readiness probe completed (${code}).`
: `Gateway closed during readiness probe (${code}).`
});
});
});
}
function formatPayloadIssue(
missingFiles: string[],
pythonProbe: PythonPayloadProbeResult,
pythonExecutable: string
): string | undefined {
if (missingFiles.length > 0) {
const missingLabels = missingFiles.map((targetPath) => path.basename(targetPath)).join(", ");
return `required payload files are missing (${missingLabels})`;
}
if (pythonProbe.error) {
return `the bundled Python executable could not be launched at ${pythonExecutable}: ${pythonProbe.error}`;
}
if (pythonProbe.missingModules.length > 0) {
return `the bundled Python dependency probe failed. Missing modules: ${pythonProbe.missingModules.join(", ")}`;
}
return undefined;
}
async function probePythonPayload(pythonExecutable: string): Promise<PythonPayloadProbeResult> { async function probePythonPayload(pythonExecutable: string): Promise<PythonPayloadProbeResult> {
const inlineScript = [ const inlineScript = [
"import importlib.util, json, sys", "import importlib.util, json, sys",
...@@ -161,15 +356,18 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo ...@@ -161,15 +356,18 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo
ready: false, ready: false,
installedPackages: [], installedPackages: [],
missingModules: PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName), missingModules: PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName),
error: error instanceof Error ? error.message : String(error) error: formatExecError(error)
}; };
} }
} }
function decideSelectedMode( function decideSelectedMode(
requestedMode: RuntimeModePreference, requestedMode: RuntimeModePreference,
payloadState: RuntimeStatus["payloadState"] payloadState: RuntimeStatus["payloadState"],
payloadIssue?: string
): RuntimeDecision { ): RuntimeDecision {
const issueClause = payloadIssue ? trimTrailingPunctuation(payloadIssue) : undefined;
if (requestedMode === "external-gateway") { if (requestedMode === "external-gateway") {
return { return {
selectedMode: "external-gateway", selectedMode: "external-gateway",
...@@ -197,19 +395,27 @@ function decideSelectedMode( ...@@ -197,19 +395,27 @@ function decideSelectedMode(
if (requestedMode === "bundled-runtime") { if (requestedMode === "bundled-runtime") {
return { return {
selectedMode: "external-gateway", selectedMode: "external-gateway",
modeReason: "Bundled runtime was requested, but the payload layout is incomplete, so Electron Main is falling back to external-gateway mode.", modeReason: issueClause
message: "Bundled runtime was requested, but required payload files are missing, so the app is staying in external Gateway mode." ? `Bundled runtime was requested, but ${issueClause}, so Electron Main is falling back to external-gateway mode.`
: "Bundled runtime was requested, but the payload layout is incomplete, so Electron Main is falling back to external-gateway mode.",
message: issueClause
? `Bundled runtime was requested, but ${issueClause}, so the app is staying in external Gateway mode.`
: "Bundled runtime was requested, but the payload layout is incomplete, so the app is staying in external Gateway mode."
}; };
} }
return { return {
selectedMode: "external-gateway", selectedMode: "external-gateway",
modeReason: payloadState === "placeholder" modeReason: issueClause
? "Auto mode selected external-gateway because the bundled runtime directory exists but the payload is incomplete." ? `Auto mode selected external-gateway because ${issueClause}.`
: "Auto mode selected external-gateway because no bundled runtime payload is present.", : payloadState === "placeholder"
message: payloadState === "placeholder" ? "Auto mode selected external-gateway because the bundled runtime directory exists but the payload is incomplete."
? "Bundled runtime directory is present, but the executable payload is incomplete, so the app is staying in external Gateway mode." : "Auto mode selected external-gateway because no bundled runtime payload is present.",
: "No bundled runtime payload is present yet, so the app is staying in external Gateway mode." message: issueClause
? `Bundled runtime is not ready because ${issueClause}, so the app is staying in external Gateway mode.`
: payloadState === "placeholder"
? "Bundled runtime directory is present, but the executable payload is incomplete, so the app is staying in external Gateway mode."
: "No bundled runtime payload is present yet, so the app is staying in external Gateway mode."
}; };
} }
...@@ -218,10 +424,12 @@ export class RuntimeManager extends EventEmitter { ...@@ -218,10 +424,12 @@ export class RuntimeManager extends EventEmitter {
private readonly runtimeDataDir: string; private readonly runtimeDataDir: string;
private readonly logFilePath: string; private readonly logFilePath: string;
private readonly managedConfigResolver?: ManagedConfigResolver; private readonly managedConfigResolver?: ManagedConfigResolver;
private readonly strictBundledRuntime: boolean;
private requestedMode: RuntimeModePreference; private requestedMode: RuntimeModePreference;
private readonly logs: LogEntry[] = []; private readonly logs: LogEntry[] = [];
private runtimeStatus: RuntimeStatus; private runtimeStatus: RuntimeStatus;
private child?: ChildProcess; private child?: ChildProcess;
private managedChildPid?: number;
private payloadState: RuntimeStatus["payloadState"] = "missing"; private payloadState: RuntimeStatus["payloadState"] = "missing";
private detectedFiles: string[] = []; private detectedFiles: string[] = [];
private missingFiles: string[] = []; private missingFiles: string[] = [];
...@@ -230,12 +438,13 @@ export class RuntimeManager extends EventEmitter { ...@@ -230,12 +438,13 @@ export class RuntimeManager extends EventEmitter {
private pythonVersion?: string; private pythonVersion?: string;
private installedPythonPackages: string[] = []; private installedPythonPackages: string[] = [];
private pythonMissingModules: string[] = []; private pythonMissingModules: string[] = [];
private payloadIssue?: string;
private checkedAt?: string; private checkedAt?: string;
private lastStoppedAt?: string; private lastStoppedAt?: string;
private lastStartedAt?: string; private lastStartedAt?: string;
private lastExitCode?: number | null; private lastExitCode?: number | null;
private lastError?: string; private lastError?: string;
private startPromise?: Promise<RuntimeStatus>;
constructor(options: RuntimeManagerOptions) { constructor(options: RuntimeManagerOptions) {
super(); super();
this.vendorRuntimeDir = options.vendorRuntimeDir; this.vendorRuntimeDir = options.vendorRuntimeDir;
...@@ -243,6 +452,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -243,6 +452,7 @@ export class RuntimeManager extends EventEmitter {
this.logFilePath = options.logFilePath; this.logFilePath = options.logFilePath;
this.requestedMode = options.requestedMode ?? "auto"; this.requestedMode = options.requestedMode ?? "auto";
this.managedConfigResolver = options.managedConfigResolver; this.managedConfigResolver = options.managedConfigResolver;
this.strictBundledRuntime = options.strictBundledRuntime ?? false;
const resolved = this.resolveBundledPaths(); const resolved = this.resolveBundledPaths();
this.runtimeStatus = { this.runtimeStatus = {
...@@ -275,7 +485,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -275,7 +485,7 @@ export class RuntimeManager extends EventEmitter {
message: "Bundled runtime has not been probed yet.", message: "Bundled runtime has not been probed yet.",
modeReason: "Runtime mode decision has not been evaluated yet.", modeReason: "Runtime mode decision has not been evaluated yet.",
detectedFiles: [], detectedFiles: [],
missingFiles: [resolved.nodeExecutable, resolved.openClawEntry, resolved.defaultConfigPath, resolved.pythonExecutable, resolved.pythonManifestPath], missingFiles: [resolved.nodeExecutable, resolved.openClawEntry, resolved.packagedOpenClawEntry, resolved.runtimeManifestPath, resolved.defaultConfigPath, resolved.pythonExecutable, resolved.pythonManifestPath],
checkedAt: undefined checkedAt: undefined
}; };
...@@ -298,8 +508,10 @@ export class RuntimeManager extends EventEmitter { ...@@ -298,8 +508,10 @@ export class RuntimeManager extends EventEmitter {
runtimeDir: this.vendorRuntimeDir, runtimeDir: this.vendorRuntimeDir,
nodeExecutable: path.join(this.vendorRuntimeDir, "node", "node.exe"), nodeExecutable: path.join(this.vendorRuntimeDir, "node", "node.exe"),
openClawEntry: path.join(this.vendorRuntimeDir, "openclaw", "index.js"), openClawEntry: path.join(this.vendorRuntimeDir, "openclaw", "index.js"),
packagedOpenClawEntry: path.join(this.vendorRuntimeDir, "openclaw", "package", "openclaw.mjs"),
runtimeManifestPath: path.join(this.vendorRuntimeDir, "runtime-manifest.json"),
defaultConfigPath: path.join(this.vendorRuntimeDir, "config", "openclaw.json"), defaultConfigPath: path.join(this.vendorRuntimeDir, "config", "openclaw.json"),
pythonExecutable: path.join(this.vendorRuntimeDir, "python", "Scripts", "python.exe"), pythonExecutable: path.join(this.vendorRuntimeDir, "python", "python.exe"),
pythonManifestPath: path.join(this.vendorRuntimeDir, "python", "python-manifest.json"), pythonManifestPath: path.join(this.vendorRuntimeDir, "python", "python-manifest.json"),
managedConfigPath: path.join(this.runtimeDataDir, "state", "openclaw.runtime.json"), managedConfigPath: path.join(this.runtimeDataDir, "state", "openclaw.runtime.json"),
readmePath: path.join(this.vendorRuntimeDir, "README.md"), readmePath: path.join(this.vendorRuntimeDir, "README.md"),
...@@ -312,10 +524,23 @@ export class RuntimeManager extends EventEmitter { ...@@ -312,10 +524,23 @@ export class RuntimeManager extends EventEmitter {
async detectRuntime(): Promise<boolean> { async detectRuntime(): Promise<boolean> {
const paths = this.resolveBundledPaths(); const paths = this.resolveBundledPaths();
const [runtimeDirExists, nodeExists, openClawExists, configExists, pythonExists, pythonManifestExists, readmeExists, gatewayConnection] = await Promise.all([ const [
runtimeDirExists,
nodeExists,
openClawExists,
packagedOpenClawExists,
runtimeManifestExists,
configExists,
pythonExists,
pythonManifestExists,
readmeExists,
gatewayConnection
] = await Promise.all([
pathExists(paths.runtimeDir), pathExists(paths.runtimeDir),
pathExists(paths.nodeExecutable), pathExists(paths.nodeExecutable),
pathExists(paths.openClawEntry), pathExists(paths.openClawEntry),
pathExists(paths.packagedOpenClawEntry),
pathExists(paths.runtimeManifestPath),
pathExists(paths.defaultConfigPath), pathExists(paths.defaultConfigPath),
pathExists(paths.pythonExecutable), pathExists(paths.pythonExecutable),
pathExists(paths.pythonManifestPath), pathExists(paths.pythonManifestPath),
...@@ -328,7 +553,8 @@ export class RuntimeManager extends EventEmitter { ...@@ -328,7 +553,8 @@ export class RuntimeManager extends EventEmitter {
: { : {
ready: false, ready: false,
installedPackages: [], installedPackages: [],
missingModules: PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName) missingModules: PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName),
error: undefined
}; };
this.pythonReady = pythonProbe.ready; this.pythonReady = pythonProbe.ready;
...@@ -340,6 +566,8 @@ export class RuntimeManager extends EventEmitter { ...@@ -340,6 +566,8 @@ export class RuntimeManager extends EventEmitter {
runtimeDirExists ? paths.runtimeDir : null, runtimeDirExists ? paths.runtimeDir : null,
nodeExists ? paths.nodeExecutable : null, nodeExists ? paths.nodeExecutable : null,
openClawExists ? paths.openClawEntry : null, openClawExists ? paths.openClawEntry : null,
packagedOpenClawExists ? paths.packagedOpenClawEntry : null,
runtimeManifestExists ? paths.runtimeManifestPath : null,
configExists ? paths.defaultConfigPath : null, configExists ? paths.defaultConfigPath : null,
pythonExists ? paths.pythonExecutable : null, pythonExists ? paths.pythonExecutable : null,
pythonManifestExists ? paths.pythonManifestPath : null, pythonManifestExists ? paths.pythonManifestPath : null,
...@@ -350,31 +578,45 @@ export class RuntimeManager extends EventEmitter { ...@@ -350,31 +578,45 @@ export class RuntimeManager extends EventEmitter {
runtimeDirExists ? null : paths.runtimeDir, runtimeDirExists ? null : paths.runtimeDir,
nodeExists ? null : paths.nodeExecutable, nodeExists ? null : paths.nodeExecutable,
openClawExists ? null : paths.openClawEntry, openClawExists ? null : paths.openClawEntry,
packagedOpenClawExists ? null : paths.packagedOpenClawEntry,
runtimeManifestExists ? null : paths.runtimeManifestPath,
configExists ? null : paths.defaultConfigPath, configExists ? null : paths.defaultConfigPath,
pythonExists ? null : paths.pythonExecutable, pythonExists ? null : paths.pythonExecutable,
pythonManifestExists ? null : paths.pythonManifestPath pythonManifestExists ? null : paths.pythonManifestPath
].filter((value): value is string => Boolean(value)); ].filter((value): value is string => Boolean(value));
this.payloadState = nodeExists && openClawExists && configExists && pythonExists && pythonManifestExists && pythonProbe.ready const hasCoreFiles = nodeExists
&& openClawExists
&& packagedOpenClawExists
&& runtimeManifestExists
&& configExists
&& pythonExists
&& pythonManifestExists;
this.payloadState = hasCoreFiles && pythonProbe.ready
? "ready" ? "ready"
: runtimeDirExists || readmeExists : runtimeDirExists || readmeExists
? "placeholder" ? "placeholder"
: "missing"; : "missing";
this.payloadIssue = this.payloadState === "ready"
? undefined
: formatPayloadIssue(this.missingFiles, pythonProbe, paths.pythonExecutable);
this.gatewayConnection = gatewayConnection; this.gatewayConnection = gatewayConnection;
this.checkedAt = new Date().toISOString(); this.checkedAt = new Date().toISOString();
if (this.payloadState === "ready") { if (this.payloadState === "ready") {
this.appendLog("info", `Bundled runtime payload is ready at ${this.vendorRuntimeDir}.`); this.appendLog("info", `Bundled runtime payload is ready at ${this.vendorRuntimeDir}.`);
} else if (this.payloadState === "placeholder") { } else if (this.payloadState === "placeholder") {
const pythonReason = pythonProbe.error const issue = this.payloadIssue
? ` Python payload probe failed: ${pythonProbe.error}` ? trimTrailingPunctuation(this.payloadIssue)
: this.pythonMissingModules.length > 0 : "the bundled runtime payload did not pass validation";
? ` Missing Python modules: ${this.pythonMissingModules.join(", ")}.` this.appendLog("warn", `Bundled runtime directory exists at ${this.vendorRuntimeDir}, but ${issue}.`);
: "";
this.appendLog("warn", `Bundled runtime directory exists at ${this.vendorRuntimeDir}, but required payload files are missing or incomplete.${pythonReason}`);
} else { } else {
this.appendLog("warn", `Bundled runtime payload is missing at ${this.vendorRuntimeDir}.`); const issue = this.payloadIssue
? trimTrailingPunctuation(this.payloadIssue)
: `Bundled runtime payload is missing at ${this.vendorRuntimeDir}`;
this.appendLog("warn", `${issue}.`);
} }
this.refreshStatus(); this.refreshStatus();
...@@ -382,16 +624,45 @@ export class RuntimeManager extends EventEmitter { ...@@ -382,16 +624,45 @@ export class RuntimeManager extends EventEmitter {
} }
async start(): Promise<RuntimeStatus> { async start(): Promise<RuntimeStatus> {
if (this.startPromise) {
this.appendLog("info", "Bundled runtime startup is already in progress.");
return this.startPromise;
}
const startOperation = this.performStart().finally(() => {
if (this.startPromise === startOperation) {
this.startPromise = undefined;
}
});
this.startPromise = startOperation;
return startOperation;
}
private async performStart(): Promise<RuntimeStatus> {
await this.detectRuntime(); await this.detectRuntime();
if (this.child && this.child.exitCode === null && !this.child.killed) { if (this.child && this.child.exitCode === null && !this.child.killed) {
this.appendLog("info", "Managed bundled runtime is already running."); this.appendLog(
"info",
this.runtimeStatus.processState === "running"
? "Managed bundled runtime is already running."
: "Managed bundled runtime process is already active."
);
this.lastError = undefined; this.lastError = undefined;
this.refreshStatus("running"); this.refreshStatus(this.runtimeStatus.processState === "running" ? "running" : undefined);
return this.status();
}
if (this.strictBundledRuntime && this.payloadState !== "ready") {
this.lastError = this.payloadIssue
? `Bundled runtime is required for packaged builds, but ${trimTrailingPunctuation(this.payloadIssue)}.`
: "Bundled runtime is required for packaged builds, but the payload is not ready.";
this.appendLog("error", this.lastError);
this.refreshStatus("error");
return this.status(); return this.status();
} }
const decision = decideSelectedMode(this.requestedMode, this.payloadState); const decision = decideSelectedMode(this.requestedMode, this.payloadState, this.payloadIssue);
if (decision.selectedMode !== "bundled-runtime") { if (decision.selectedMode !== "bundled-runtime") {
this.lastError = decision.message; this.lastError = decision.message;
this.appendLog("warn", decision.message); this.appendLog("warn", decision.message);
...@@ -400,7 +671,9 @@ export class RuntimeManager extends EventEmitter { ...@@ -400,7 +671,9 @@ export class RuntimeManager extends EventEmitter {
} }
if (this.payloadState !== "ready") { if (this.payloadState !== "ready") {
this.lastError = "Bundled runtime cannot start because the payload layout is incomplete."; this.lastError = this.payloadIssue
? `Bundled runtime cannot start because ${trimTrailingPunctuation(this.payloadIssue)}.`
: "Bundled runtime cannot start because the payload layout is incomplete.";
this.appendLog("warn", this.lastError); this.appendLog("warn", this.lastError);
this.refreshStatus("error"); this.refreshStatus("error");
return this.status(); return this.status();
...@@ -411,6 +684,13 @@ export class RuntimeManager extends EventEmitter { ...@@ -411,6 +684,13 @@ export class RuntimeManager extends EventEmitter {
await mkdir(paths.runtimeStateDir, { recursive: true }); await mkdir(paths.runtimeStateDir, { recursive: true });
await mkdir(paths.runtimeLogsDir, { recursive: true }); await mkdir(paths.runtimeLogsDir, { recursive: true });
const childStdoutLogPath = path.join(paths.runtimeLogsDir, "runtime-child.stdout.log");
const childStderrLogPath = path.join(paths.runtimeLogsDir, "runtime-child.stderr.log");
await Promise.all([
writeFile(childStdoutLogPath, "", "utf8"),
writeFile(childStderrLogPath, "", "utf8")
]);
let managedConfigPath: string; let managedConfigPath: string;
try { try {
managedConfigPath = await this.prepareManagedConfig(paths, "init"); managedConfigPath = await this.prepareManagedConfig(paths, "init");
...@@ -427,41 +707,37 @@ export class RuntimeManager extends EventEmitter { ...@@ -427,41 +707,37 @@ export class RuntimeManager extends EventEmitter {
this.lastStartedAt = new Date().toISOString(); this.lastStartedAt = new Date().toISOString();
this.refreshStatus("starting"); this.refreshStatus("starting");
const childArgs = [
paths.openClawEntry,
"gateway",
"run",
"--bind",
"loopback",
...(this.gatewayConnection.url
? ["--port", String(new URL(this.gatewayConnection.url).port || 18789)]
: []),
...(this.gatewayConnection.token
? ["--token", this.gatewayConnection.token]
: [])
];
this.appendLog("info", `Bundled runtime managed config: ${managedConfigPath}.`);
this.appendLog("info", `Bundled runtime stdout log: ${childStdoutLogPath}.`);
this.appendLog("info", `Bundled runtime stderr log: ${childStderrLogPath}.`);
this.appendLog("info", `Launching bundled runtime command: ${paths.nodeExecutable} ${childArgs.join(" ")}.`);
const childEnv = this.buildManagedChildEnv(paths, managedConfigPath);
const spawnOptions = {
cwd: paths.runtimeDir,
env: childEnv,
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
windowsHide: false
};
this.managedChildPid = undefined;
let child: ChildProcess; let child: ChildProcess;
try { try {
child = spawn(paths.nodeExecutable, [ child = spawn(paths.nodeExecutable, childArgs, spawnOptions);
paths.openClawEntry,
"gateway",
"run",
"--force",
"--bind",
"loopback",
...(this.gatewayConnection.url
? ["--port", String(new URL(this.gatewayConnection.url).port || 18789)]
: []),
...(this.gatewayConnection.token
? ["--token", this.gatewayConnection.token]
: [])
], {
cwd: paths.runtimeDir,
env: {
...process.env,
HOME: this.runtimeDataDir,
USERPROFILE: this.runtimeDataDir,
OPENCLAW_HOME: this.runtimeDataDir,
OPENCLAW_STATE_DIR: paths.runtimeStateDir,
OPENCLAW_CONFIG_PATH: managedConfigPath,
PYTHONUTF8: "1",
PYTHONIOENCODING: "utf-8",
PATH: [
path.dirname(paths.pythonExecutable),
path.join(paths.runtimeDir, "python"),
process.env.PATH ?? ""
].filter(Boolean).join(path.delimiter)
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true
});
} catch (error) { } catch (error) {
this.lastError = `Bundled runtime failed to spawn: ${error instanceof Error ? error.message : String(error)}`; this.lastError = `Bundled runtime failed to spawn: ${error instanceof Error ? error.message : String(error)}`;
this.appendLog("error", this.lastError); this.appendLog("error", this.lastError);
...@@ -470,12 +746,15 @@ export class RuntimeManager extends EventEmitter { ...@@ -470,12 +746,15 @@ export class RuntimeManager extends EventEmitter {
} }
this.child = child; this.child = child;
child.stdout?.on("data", (chunk: Buffer) => this.appendChunk("info", chunk)); child.stdout?.on("data", (chunk: Buffer) => {
child.stderr?.on("data", (chunk: Buffer) => this.appendChunk("warn", chunk)); this.appendChunk("info", chunk, childStdoutLogPath);
});
child.stderr?.on("data", (chunk: Buffer) => this.appendChunk("warn", chunk, childStderrLogPath));
child.once("error", (error) => { child.once("error", (error) => {
this.lastError = `Bundled runtime failed to start: ${error.message}`; this.lastError = `Bundled runtime failed to start: ${error.message}`;
this.lastStoppedAt = new Date().toISOString(); this.lastStoppedAt = new Date().toISOString();
this.child = undefined; this.child = undefined;
this.managedChildPid = undefined;
this.appendLog("error", this.lastError); this.appendLog("error", this.lastError);
this.refreshStatus("error"); this.refreshStatus("error");
}); });
...@@ -484,6 +763,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -484,6 +763,7 @@ export class RuntimeManager extends EventEmitter {
this.lastStoppedAt = new Date().toISOString(); this.lastStoppedAt = new Date().toISOString();
const wasStopping = this.runtimeStatus.processState === "stopping"; const wasStopping = this.runtimeStatus.processState === "stopping";
this.child = undefined; this.child = undefined;
this.managedChildPid = undefined;
if (!wasStopping && code !== 0) { if (!wasStopping && code !== 0) {
this.lastError = `Bundled runtime exited unexpectedly with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}.`; this.lastError = `Bundled runtime exited unexpectedly with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}.`;
this.appendLog("error", this.lastError); this.appendLog("error", this.lastError);
...@@ -494,34 +774,55 @@ export class RuntimeManager extends EventEmitter { ...@@ -494,34 +774,55 @@ export class RuntimeManager extends EventEmitter {
this.refreshStatus("stopped"); this.refreshStatus("stopped");
}); });
await delay(1500); this.appendLog("info", `Bundled runtime process started with pid ${this.child.pid ?? "unknown"}.`);
if (!this.child || this.child.exitCode !== null || this.child.killed) { this.appendLog("info", `Waiting for bundled Gateway readiness at ${this.gatewayConnection.url ?? "unknown"}.`);
this.lastError = this.lastError ?? "Bundled runtime exited before startup could complete.";
this.appendLog("error", this.lastError); const readiness = await this.waitForGatewayReady();
if (!readiness.ready) {
const startupError = readiness.lastError
?? `Timed out while waiting for bundled Gateway readiness at ${this.gatewayConnection.url ?? "unknown"}.`;
this.appendLog("error", startupError);
await this.stop();
this.lastError = startupError;
this.refreshStatus("error"); this.refreshStatus("error");
return this.status(); return this.status();
} }
this.appendLog("info", `Bundled runtime process started with pid ${this.child.pid ?? "unknown"}.`); this.lastError = undefined;
this.refreshStatus("running"); this.refreshStatus("running");
return this.status(); return this.status();
} }
async stop(): Promise<RuntimeStatus> { async stop(): Promise<RuntimeStatus> {
if (!this.child || this.child.exitCode !== null || this.child.killed) { if ((!this.child || this.child.exitCode !== null || this.child.killed) && !this.managedChildPid) {
this.lastError = undefined; this.lastError = undefined;
this.refreshStatus("stopped"); this.refreshStatus("stopped");
return this.status(); return this.status();
} }
const child = this.child; const child = this.child;
this.appendLog("info", `Stopping bundled runtime process ${child.pid ?? "unknown"}.`); const managedPid = this.managedChildPid;
this.appendLog("info", `Stopping bundled runtime process ${managedPid ?? child?.pid ?? "unknown"}.`);
this.refreshStatus("stopping"); this.refreshStatus("stopping");
try { try {
if (process.platform === "win32" && child.pid) { if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(child.pid), "/T", "/F"]); const pids = [...new Set([managedPid, child?.pid].filter((value): value is number => typeof value === "number" && value > 0))];
} else { if (pids.length === 0) {
throw new Error("No managed runtime pid is available to stop.");
}
const failures: string[] = [];
for (const pid of pids) {
try {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"]);
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
}
}
if (failures.length === pids.length) {
throw new Error(failures.join(" | "));
}
} else if (child) {
child.kill("SIGTERM"); child.kill("SIGTERM");
} }
} catch (error) { } catch (error) {
...@@ -602,6 +903,49 @@ export class RuntimeManager extends EventEmitter { ...@@ -602,6 +903,49 @@ export class RuntimeManager extends EventEmitter {
return this.logs.slice(-limit); return this.logs.slice(-limit);
} }
private async waitForGatewayReady(): Promise<GatewayProbeResult> {
if (!this.gatewayConnection.url) {
return {
ready: false,
checkedAt: new Date().toISOString(),
lastError: "Managed bundled runtime config did not define a Gateway URL."
};
}
const deadline = Date.now() + GATEWAY_READY_TIMEOUT_MS;
let lastProbeError: string | undefined;
let lastLoggedProbeError: string | undefined;
while (Date.now() < deadline) {
if (!this.child || this.child.exitCode !== null || this.child.killed) {
return {
ready: false,
checkedAt: new Date().toISOString(),
lastError: this.lastError ?? "Bundled runtime exited before Gateway became ready."
};
}
const probe = await probeGatewayReadiness(this.gatewayConnection.url, this.gatewayConnection.token);
if (probe.ready) {
return probe;
}
lastProbeError = probe.lastError;
if (lastProbeError && lastProbeError !== lastLoggedProbeError) {
this.appendLog("warn", `Bundled Gateway is not ready yet: ${lastProbeError}`);
lastLoggedProbeError = lastProbeError;
}
await delay(GATEWAY_READY_POLL_INTERVAL_MS);
}
return {
ready: false,
checkedAt: new Date().toISOString(),
lastError: lastProbeError ?? `Timed out while waiting for bundled Gateway readiness at ${this.gatewayConnection.url}.`
};
}
private async readGatewayConnection(configPath: string): Promise<RuntimeGatewayConnection> { private async readGatewayConnection(configPath: string): Promise<RuntimeGatewayConnection> {
try { try {
const raw = await readFile(configPath, "utf8"); const raw = await readFile(configPath, "utf8");
...@@ -663,7 +1007,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -663,7 +1007,7 @@ export class RuntimeManager extends EventEmitter {
private refreshStatus(processState?: RuntimeProcessState): void { private refreshStatus(processState?: RuntimeProcessState): void {
const paths = this.resolveBundledPaths(); const paths = this.resolveBundledPaths();
const currentProcessState = processState ?? this.inferProcessState(); const currentProcessState = processState ?? this.inferProcessState();
const decision = decideSelectedMode(this.requestedMode, this.payloadState); const decision = decideSelectedMode(this.requestedMode, this.payloadState, this.payloadIssue);
const selectedMode = decision.selectedMode; const selectedMode = decision.selectedMode;
const activeMode = selectedMode === "bundled-runtime" && (currentProcessState === "starting" || currentProcessState === "running" || currentProcessState === "stopping") const activeMode = selectedMode === "bundled-runtime" && (currentProcessState === "starting" || currentProcessState === "running" || currentProcessState === "stopping")
? "bundled-runtime" ? "bundled-runtime"
...@@ -676,8 +1020,8 @@ export class RuntimeManager extends EventEmitter { ...@@ -676,8 +1020,8 @@ export class RuntimeManager extends EventEmitter {
message = "Starting managed bundled runtime process."; message = "Starting managed bundled runtime process.";
modeReason = "Electron Main is launching the bundled runtime process. Gateway readiness is still pending."; modeReason = "Electron Main is launching the bundled runtime process. Gateway readiness is still pending.";
} else if (currentProcessState === "running") { } else if (currentProcessState === "running") {
message = "Managed bundled runtime process is running."; message = "Managed bundled runtime process is running and Gateway is ready.";
modeReason = "Bundled runtime is selected and currently managed by Electron Main."; modeReason = "Bundled runtime is selected, the managed process is alive, and Gateway readiness has completed.";
} else if (currentProcessState === "stopping") { } else if (currentProcessState === "stopping") {
message = "Stopping managed bundled runtime process."; message = "Stopping managed bundled runtime process.";
modeReason = "Electron Main is shutting down the bundled runtime process."; modeReason = "Electron Main is shutting down the bundled runtime process.";
...@@ -707,7 +1051,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -707,7 +1051,7 @@ export class RuntimeManager extends EventEmitter {
logFilePath: paths.logFilePath, logFilePath: paths.logFilePath,
gatewayUrl: this.gatewayConnection.url, gatewayUrl: this.gatewayConnection.url,
gatewayTokenConfigured: Boolean(this.gatewayConnection.token), gatewayTokenConfigured: Boolean(this.gatewayConnection.token),
pid: this.child?.pid, pid: this.managedChildPid ?? this.child?.pid,
startedAt: this.lastStartedAt, startedAt: this.lastStartedAt,
stoppedAt: this.lastStoppedAt, stoppedAt: this.lastStoppedAt,
lastExitCode: this.lastExitCode, lastExitCode: this.lastExitCode,
...@@ -736,7 +1080,115 @@ export class RuntimeManager extends EventEmitter { ...@@ -736,7 +1080,115 @@ export class RuntimeManager extends EventEmitter {
return "stopped"; return "stopped";
} }
private appendChunk(level: LogEntry["level"], chunk: Buffer): void { private buildManagedChildEnv(paths: RuntimeResolvedPaths, managedConfigPath: string): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = {};
const inheritedKeys = [
"ALLUSERSPROFILE",
"APPDATA",
"COMMONPROGRAMFILES",
"COMMONPROGRAMFILES(X86)",
"COMMONPROGRAMW6432",
"COMPUTERNAME",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"NUMBER_OF_PROCESSORS",
"OS",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROCESSOR_IDENTIFIER",
"PROCESSOR_LEVEL",
"PROCESSOR_REVISION",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"PUBLIC",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERNAME",
"USERDOMAIN",
"USERDOMAIN_ROAMINGPROFILE",
"WINDIR"
] as const;
for (const key of inheritedKeys) {
const value = process.env[key];
if (typeof value === "string" && value.length > 0) {
childEnv[key] = value;
}
}
childEnv.HOME = this.runtimeDataDir;
childEnv.USERPROFILE = this.runtimeDataDir;
childEnv.OPENCLAW_HOME = this.runtimeDataDir;
childEnv.OPENCLAW_STATE_DIR = paths.runtimeStateDir;
childEnv.OPENCLAW_CONFIG_PATH = managedConfigPath;
childEnv.PYTHONUTF8 = "1";
childEnv.PYTHONIOENCODING = "utf-8";
childEnv.PATH = [
path.join(paths.runtimeDir, "python", "Scripts"),
path.dirname(paths.pythonExecutable),
process.env.PATH ?? ""
].filter(Boolean).join(path.delimiter);
return childEnv;
}
private buildWindowsChildWrapperScript(
paths: RuntimeResolvedPaths,
childArgs: string[],
_childStdoutLogPath: string,
_childStderrLogPath: string,
childEnv: NodeJS.ProcessEnv
): string {
const envAssignments = Object.entries(childEnv)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `Set-Item -Path 'Env:${escapePowerShellSingleQuoted(key)}' -Value '${escapePowerShellSingleQuoted(value)}'`)
.join("; ");
const argumentList = childArgs
.map((value) => `'${escapePowerShellSingleQuoted(value)}'`)
.join(", ");
return [
envAssignments,
`Set-Location -LiteralPath '${escapePowerShellSingleQuoted(paths.runtimeDir)}'`,
`& '${escapePowerShellSingleQuoted(paths.nodeExecutable)}' @(${argumentList})`,
"$exitCode = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 }",
"exit $exitCode"
].join("; ");
}
private captureManagedChildPid(chunk: Buffer): void {
const text = chunk.toString("utf8");
const match = text.match(/__QJC_MANAGED_CHILD_PID__=(\d+)/);
if (!match) {
return;
}
const nextPid = Number(match[1]);
if (!Number.isFinite(nextPid) || nextPid <= 0 || nextPid === this.managedChildPid) {
return;
}
this.managedChildPid = nextPid;
this.appendLog("info", `Bundled runtime child pid resolved to ${nextPid}.`);
this.refreshStatus();
}
private appendChunk(level: LogEntry["level"], chunk: Buffer, processLogPath?: string): void {
if (processLogPath) {
try {
mkdirSync(path.dirname(processLogPath), { recursive: true });
appendFileSync(processLogPath, chunk);
} catch {
// Ignore child log persistence failures to avoid affecting runtime startup/shutdown.
}
}
const text = chunk.toString("utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const text = chunk.toString("utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
for (const line of text) { for (const line of text) {
this.appendLog(level, line); this.appendLog(level, line);
...@@ -751,6 +1203,15 @@ export class RuntimeManager extends EventEmitter { ...@@ -751,6 +1203,15 @@ export class RuntimeManager extends EventEmitter {
message message
}; };
this.logs.push(entry); this.logs.push(entry);
try {
mkdirSync(path.dirname(this.logFilePath), { recursive: true });
appendFileSync(this.logFilePath, `[${entry.ts}] [${entry.level}] ${entry.message}\n`, "utf8");
} catch {
// Ignore log persistence failures to avoid affecting runtime startup/shutdown.
}
this.emit("log", entry); this.emit("log", entry);
} }
} }
...@@ -10,6 +10,10 @@ importers: ...@@ -10,6 +10,10 @@ importers:
apps/desktop: apps/desktop:
dependencies: dependencies:
keytar:
specifier: ^7.9.0
version: 7.9.0
devDependencies:
'@qjclaw/gateway-client': '@qjclaw/gateway-client':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/gateway-client version: link:../../packages/gateway-client
...@@ -19,10 +23,6 @@ importers: ...@@ -19,10 +23,6 @@ importers:
'@qjclaw/shared-types': '@qjclaw/shared-types':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared-types version: link:../../packages/shared-types
keytar:
specifier: ^7.9.0
version: 7.9.0
devDependencies:
'@types/node': '@types/node':
specifier: ^22.10.2 specifier: ^22.10.2
version: 22.19.15 version: 22.19.15
...@@ -112,10 +112,16 @@ importers: ...@@ -112,10 +112,16 @@ importers:
'@qjclaw/shared-types': '@qjclaw/shared-types':
specifier: workspace:* specifier: workspace:*
version: link:../shared-types version: link:../shared-types
ws:
specifier: ^8.18.3
version: 8.19.0
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.10.2 specifier: ^22.10.2
version: 22.19.15 version: 22.19.15
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
rimraf: rimraf:
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.1.3 version: 6.1.3
......
# Bundled Runtime Payload # Bundled Runtime Payload
Immutable packaged payload under `vendor/openclaw-runtime/` now includes: Immutable packaged payload under endor/openclaw-runtime/ includes:
- `node/node.exe` -
- `openclaw/index.js` ode/node.exe
- `config/openclaw.json` - openclaw/index.js
- `python/Scripts/python.exe` - openclaw/package/openclaw.mjs
- `python/python-manifest.json` - config/openclaw.json
- `python/runtime-requirements.lock.txt` - python/python.exe
- python/python-manifest.json
- python/runtime-requirements.lock.txt
Mutable runtime data lives outside the installer payload and should be created under Electron `userData/runtime/`: Mutable runtime data lives outside the installer payload and should be created under Electron userData/runtime/.
- `runtime/logs/` The payload is considered ready only when the Node entry, OpenClaw package, Python executable, Python manifest, and locked Python imports all validate successfully on the target machine.
- `runtime/state/` \ No newline at end of file
- `runtime/workspace/`
- future writable caches and lock files
Current payload is generated locally for packaging and smoke validation by `build/scripts/materialize-runtime-payload.ps1`.
The Python layer is part of the bundled-runtime contract. A payload is not considered ready unless:
- the Node/OpenClaw files exist
- the Python executable and manifest exist
- the locked Python dependencies can all be imported successfully
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment