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

fix bundled runtime startup and installer packaging

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