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({
......
This diff is collapsed.
......@@ -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
}
......@@ -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
This diff is collapsed.
......@@ -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