Commit 3815629f authored by AI-甘富林's avatar AI-甘富林

首次提交qjclaw

parents
node_modules/
dist/
.pnpm-store/
.turbo/
.corepack/
.tmp/
.tmp-gateway-probe/
.claude/
.DS_Store
Thumbs.db
*.log
.env
.env.local
coverage/
apps/*/node_modules/
apps/desktop/dist/
apps/ui/dist/
docs/.claude/
vendor/openclaw-runtime/runtime/
vendor/openclaw-runtime/node/
vendor/openclaw-runtime/openclaw/
vendor/openclaw-runtime/config/openclaw.json
vendor/openclaw-runtime/runtime-manifest.json
vendor/openclaw-runtime/python/
skills/**/__pycache__/
skills/**/browser_data/
skills/**/*.pyc
skills/**/api_key.txt
# QianjiangClaw
Windows-first Electron desktop shell for a bundled OpenClaw runtime.
## Workspace
- `apps/desktop`: Electron main process and preload bridge
- `apps/ui`: React renderer
- `packages/runtime-manager`: local runtime lifecycle abstraction
- `packages/gateway-client`: Gateway protocol abstraction
- `packages/shared-types`: IPC contracts and shared DTOs
- `vendor/openclaw-runtime`: pinned runtime payload placeholder
## Notes
- This repo is scaffolded only. Dependencies are not installed yet.
- The current runtime and gateway implementations are placeholders with the right boundaries, not full OpenClaw integration.
- UI talks to Electron through preload only. Renderer does not touch secrets, runtime files, or child processes directly.
## Next Steps
1. Run `pnpm install`
2. Put the pinned OpenClaw runtime payload under `vendor/openclaw-runtime`
3. Replace placeholder runtime start/stop logic with real child process management
4. Replace placeholder secret manager with `keytar`
5. Replace mocked gateway client calls with the actual WebSocket protocol
appId: com.qianjiangclaw.desktop
productName: QianjiangClaw
compression: store
asar: true
asarUnpack:
- node_modules/keytar/build/Release/*.node
directories:
output: ../../dist/installer
artifactName: ${productName}-Setup-${version}.${ext}
files:
- dist/**/*
- package.json
extraResources:
- from: ../../vendor/openclaw-runtime
to: vendor/openclaw-runtime
win:
signAndEditExecutable: false
target:
- nsis
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
{
"name": "@qjclaw/desktop",
"version": "0.1.0",
"private": true,
"description": "QianjiangClaw desktop client",
"author": "QianjiangClaw",
"main": "dist/main/index.js",
"scripts": {
"build": "tsup --config tsup.config.ts",
"clean": "rimraf dist/main dist/preload",
"dev": "concurrently -k \"corepack pnpm run dev:build\" \"corepack pnpm run dev:start\"",
"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",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"keytar": "^7.9.0"
},
"devDependencies": {
"@qjclaw/gateway-client": "workspace:*",
"@qjclaw/runtime-manager": "workspace:*",
"@qjclaw/shared-types": "workspace:*",
"@types/node": "^22.10.2",
"concurrently": "^9.1.0",
"electron": "^34.0.2",
"electron-builder": "^25.1.8",
"electronmon": "^2.0.2",
"rimraf": "^6.0.1",
"tsup": "^8.3.5",
"typescript": "^5.7.3",
"wait-on": "^8.0.2"
}
}
import { BrowserWindow, app } from "electron";
import path from "node:path";
function resolveRendererEntry(): string {
if (!app.isPackaged) {
return process.env.QJCLAW_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL ?? "http://127.0.0.1:5173";
}
return path.join(app.getAppPath(), "dist", "renderer", "index.html");
}
export function createMainWindow(smokeEnabled = false): BrowserWindow {
const preloadPath = path.join(__dirname, "..", "preload", "index.js");
const window = new BrowserWindow({
width: 1400,
height: 920,
minWidth: 1080,
minHeight: 720,
backgroundColor: "#0f172a",
webPreferences: {
additionalArguments: smokeEnabled ? ["--qjc-smoke"] : [],
contextIsolation: true,
sandbox: false,
nodeIntegration: false,
preload: preloadPath
}
});
const rendererEntry = resolveRendererEntry();
if (rendererEntry.startsWith("http://") || rendererEntry.startsWith("https://")) {
void window.loadURL(rendererEntry);
} else {
void window.loadFile(rendererEntry);
}
return window;
}
This diff is collapsed.
This diff is collapsed.
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AppConfig, RuntimeModePreference, SaveConfigInput } from "@qjclaw/shared-types";
const CONFIG_DIR = "config";
const CONFIG_FILE = "app-config.json";
const DEFAULT_RUNTIME_CLOUD_API_BASE_URL = "https://xuphfkscoptnjoaecbvn.supabase.co/functions/v1";
const UI_ROUTE_NAMES = new Set([
"chat",
"control",
"settings",
"logs",
"config",
"agent",
"cron",
"skills",
"nodes",
"usage",
"sessions",
"overview",
"instances",
"channels"
]);
interface LegacyConfig {
provider?: string;
baseUrl?: string;
apiKeyConfigured?: boolean;
defaultModel?: string;
workspacePath?: string;
gatewayPort?: number;
gatewayUrl?: string;
gatewayTokenConfigured?: boolean;
authTokenConfigured?: boolean;
cloudApiBaseUrl?: string;
runtimeCloudApiBaseUrl?: string;
runtimeMode?: RuntimeModePreference;
}
function normalizeGatewayUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "ws://127.0.0.1:18789";
}
try {
const url = new URL(trimmed);
if (url.protocol === "ws:" || url.protocol === "wss:") {
url.hash = "";
url.search = "";
return url.toString().replace(/\/$/, "");
}
if (url.protocol === "http:" || url.protocol === "https:") {
const route = url.pathname.split("/").filter(Boolean)[0] ?? "";
const basePath = UI_ROUTE_NAMES.has(route)
? "/"
: url.pathname.endsWith("index.html")
? url.pathname.slice(0, -"index.html".length)
: url.pathname;
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = basePath || "/";
url.search = "";
url.hash = "";
return url.toString().replace(/\/$/, "");
}
} catch {
return trimmed;
}
return trimmed;
}
function normalizeCloudApiBaseUrl(raw: string): string {
return raw.trim().replace(/\/$/, "");
}
function normalizeRuntimeMode(raw?: string): RuntimeModePreference {
return raw === "bundled-runtime" || raw === "external-gateway" ? raw : "bundled-runtime";
}
function resolveRuntimeCloudApiBaseUrl(raw?: string): string {
const normalized = normalizeCloudApiBaseUrl(raw ?? "");
if (normalized) {
return normalized;
}
const envValue = normalizeCloudApiBaseUrl(process.env.QJCLAW_RUNTIME_CLOUD_API_BASE_URL ?? "");
return envValue || DEFAULT_RUNTIME_CLOUD_API_BASE_URL;
}
export class AppConfigService {
private readonly userDataPath: string;
constructor(userDataPath: string) {
this.userDataPath = userDataPath;
}
async load(): Promise<AppConfig> {
const filePath = this.getConfigPath();
await mkdir(path.dirname(filePath), { recursive: true });
try {
const raw = await readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as LegacyConfig;
const config = this.normalizeConfig(parsed);
await writeFile(filePath, JSON.stringify(config, null, 2), "utf8");
return config;
} catch {
const defaults = this.createDefaultConfig();
await writeFile(filePath, JSON.stringify(defaults, null, 2), "utf8");
return defaults;
}
}
async save(input: SaveConfigInput): Promise<AppConfig> {
const current = await this.load();
const config: AppConfig = {
provider: input.provider,
baseUrl: input.baseUrl,
apiKeyConfigured: Boolean(input.apiKey) || current.apiKeyConfigured,
gatewayTokenConfigured: Boolean(input.gatewayToken) || current.gatewayTokenConfigured,
authTokenConfigured: typeof input.authToken === "string" ? Boolean(input.authToken) : current.authTokenConfigured,
defaultModel: input.defaultModel,
workspacePath: input.workspacePath,
gatewayUrl: normalizeGatewayUrl(input.gatewayUrl),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl),
runtimeCloudApiBaseUrl: resolveRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(input.runtimeMode)
};
const filePath = this.getConfigPath();
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(config, null, 2), "utf8");
return config;
}
private getConfigPath(): string {
return path.join(this.userDataPath, CONFIG_DIR, CONFIG_FILE);
}
private createDefaultConfig(): AppConfig {
return {
provider: "openai",
baseUrl: "https://api.openai.com/v1",
apiKeyConfigured: false,
gatewayTokenConfigured: false,
authTokenConfigured: false,
defaultModel: "gpt-5.4-mini",
workspacePath: this.userDataPath,
gatewayUrl: "ws://127.0.0.1:18789",
cloudApiBaseUrl: normalizeCloudApiBaseUrl(process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: resolveRuntimeCloudApiBaseUrl(undefined),
runtimeMode: normalizeRuntimeMode(process.env.QJCLAW_RUNTIME_MODE)
};
}
private normalizeConfig(config: LegacyConfig): AppConfig {
return {
provider: config.provider ?? "openai",
baseUrl: config.baseUrl ?? "https://api.openai.com/v1",
apiKeyConfigured: Boolean(config.apiKeyConfigured),
gatewayTokenConfigured: Boolean(config.gatewayTokenConfigured),
authTokenConfigured: Boolean(config.authTokenConfigured),
defaultModel: config.defaultModel ?? "gpt-5.4-mini",
workspacePath: config.workspacePath ?? this.userDataPath,
gatewayUrl: normalizeGatewayUrl(config.gatewayUrl ?? `ws://127.0.0.1:${config.gatewayPort ?? 18789}`),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(config.cloudApiBaseUrl ?? process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: resolveRuntimeCloudApiBaseUrl(config.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE)
};
}
}
This diff is collapsed.
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
interface StoredDeviceIdentity {
deviceId: string;
publicKey: string;
privateKeyPem: string;
}
export interface SignedDeviceProof {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string;
}
interface SignChallengeInput {
clientId: string;
clientMode: string;
role: string;
scopes: string[];
token?: string;
nonce: string;
}
function decodeBase64Url(value: string): Buffer {
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
return Buffer.from((value + pad).replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
export class DeviceIdentityService {
private readonly userDataPath: string;
private identity?: StoredDeviceIdentity;
constructor(userDataPath: string) {
this.userDataPath = userDataPath;
}
async load(): Promise<StoredDeviceIdentity> {
if (this.identity) {
return this.identity;
}
const filePath = this.getFilePath();
await mkdir(path.dirname(filePath), { recursive: true });
try {
const raw = await readFile(filePath, "utf8");
this.identity = JSON.parse(raw) as StoredDeviceIdentity;
return this.identity;
} catch {
const created = this.createIdentity();
this.identity = created;
await writeFile(filePath, JSON.stringify(created, null, 2), "utf8");
return created;
}
}
async signConnectChallenge(input: SignChallengeInput): Promise<SignedDeviceProof> {
const identity = await this.load();
const signedAt = Date.now();
const payload = [
"v2",
identity.deviceId,
input.clientId,
input.clientMode,
input.role,
input.scopes.join(","),
String(signedAt),
input.token ?? "",
input.nonce
].join("|");
const signature = sign(null, Buffer.from(payload), identity.privateKeyPem).toString("base64url");
return {
id: identity.deviceId,
publicKey: identity.publicKey,
signature,
signedAt,
nonce: input.nonce
};
}
private createIdentity(): StoredDeviceIdentity {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
if (!jwk.x) {
throw new Error("Failed to export raw Ed25519 public key.");
}
const rawPublicKey = decodeBase64Url(jwk.x);
const deviceId = createHash("sha256").update(rawPublicKey).digest("hex");
return {
deviceId,
publicKey: Buffer.from(rawPublicKey).toString("base64url"),
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString()
};
}
private getFilePath(): string {
return path.join(this.userDataPath, "config", "device-identity.json");
}
}
\ No newline at end of file
import { mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
AppConfig,
AuthSessionSummary,
CreditSummary,
DiagnosticsExportResult,
GatewayHealth,
GatewayStatus,
LogEntry,
ModelConfigSummary,
RuntimeCloudStatus,
RuntimeStatus,
RuntimeTelemetryStatus,
SkillSummary,
SystemSummary,
UserProfileSummary
} from "@qjclaw/shared-types";
import type { LocalOpenClawGatewayConfig } from "./openclaw-local-config.js";
interface DiagnosticsSnapshotInput {
config: AppConfig;
gatewayStatus: GatewayStatus;
gatewayHealth: GatewayHealth;
logs: LogEntry[];
appVersion: string;
localOpenClawConfig?: LocalOpenClawGatewayConfig | null;
secretBackend: string;
authSession?: AuthSessionSummary;
profile?: UserProfileSummary | null;
credits?: CreditSummary | null;
skills?: SkillSummary[];
modelConfig?: ModelConfigSummary | null;
cloudApiError?: string;
systemSummary: SystemSummary;
runtimeStatus?: RuntimeStatus;
runtimeLogs?: LogEntry[];
runtimeCloudStatus?: RuntimeCloudStatus;
runtimeTelemetryStatus?: RuntimeTelemetryStatus;
}
function toSafeStamp(value: string): string {
return value.replace(/[:.]/g, "-");
}
export class DiagnosticsService {
private readonly userDataPath: string;
constructor(userDataPath: string) {
this.userDataPath = userDataPath;
}
async exportSnapshot(input: DiagnosticsSnapshotInput): Promise<DiagnosticsExportResult> {
const createdAt = new Date().toISOString();
const diagnosticsDir = path.join(this.userDataPath, "diagnostics");
const filePath = path.join(diagnosticsDir, `snapshot-${toSafeStamp(createdAt)}.json`);
const payload = {
createdAt,
app: {
name: input.systemSummary.appName,
version: input.appVersion,
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
electronVersion: process.versions.electron ?? "unknown",
hostname: os.hostname(),
isPackaged: input.systemSummary.isPackaged
},
paths: {
appPath: input.systemSummary.appPath,
resourcesPath: input.systemSummary.resourcesPath,
userData: input.systemSummary.userDataPath,
logs: input.systemSummary.logsPath,
cwd: process.cwd(),
homedir: os.homedir()
},
configSummary: {
provider: input.config.provider,
baseUrl: input.config.baseUrl,
defaultModel: input.config.defaultModel,
workspacePath: input.config.workspacePath,
gatewayUrl: input.config.gatewayUrl,
cloudApiBaseUrl: input.config.cloudApiBaseUrl,
runtimeCloudApiBaseUrl: input.config.runtimeCloudApiBaseUrl,
runtimeMode: input.config.runtimeMode,
apiKeyConfigured: input.config.apiKeyConfigured,
gatewayTokenConfigured: input.config.gatewayTokenConfigured,
authTokenConfigured: input.config.authTokenConfigured,
secretBackend: input.secretBackend
},
cloud: {
authSession: input.authSession,
profile: input.profile,
credits: input.credits,
skillCount: input.skills?.length ?? 0,
skills: input.skills ?? [],
modelConfig: input.modelConfig,
cloudApiError: input.cloudApiError
},
runtimeCloud: input.runtimeCloudStatus
? {
state: input.runtimeCloudStatus.state,
baseUrl: input.runtimeCloudStatus.baseUrl,
apiKeyConfigured: input.runtimeCloudStatus.apiKeyConfigured,
lastFetchedAt: input.runtimeCloudStatus.lastFetchedAt,
lastError: input.runtimeCloudStatus.lastError,
config: input.runtimeCloudStatus.config
}
: null,
runtimeTelemetry: input.runtimeTelemetryStatus ?? null,
runtime: {
status: input.runtimeStatus,
recentLogs: input.runtimeLogs ?? []
},
gateway: {
status: input.gatewayStatus,
health: input.gatewayHealth,
recentLogs: input.logs
},
localOpenClaw: input.localOpenClawConfig
? {
detected: true,
sourcePath: input.localOpenClawConfig.sourcePath,
gatewayUrl: input.localOpenClawConfig.gatewayUrl,
gatewayTokenConfigured: Boolean(input.localOpenClawConfig.gatewayToken)
}
: {
detected: false
}
};
await mkdir(diagnosticsDir, { recursive: true });
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
return {
filePath,
createdAt
};
}
}
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
interface OpenClawGatewayConfigShape {
auth?: {
token?: string;
};
bind?: string;
host?: string;
port?: number;
}
interface OpenClawConfigShape {
gateway?: OpenClawGatewayConfigShape;
}
export interface LocalOpenClawGatewayConfig {
sourcePath: string;
gatewayUrl?: string;
gatewayToken?: string;
}
export async function loadLocalOpenClawGatewayConfig(): Promise<LocalOpenClawGatewayConfig | null> {
const sourcePath = path.join(os.homedir(), ".openclaw", "openclaw.json");
try {
const raw = await readFile(sourcePath, "utf8");
const parsed = JSON.parse(raw) as OpenClawConfigShape;
const gateway = parsed.gateway;
if (!gateway) {
return null;
}
return {
sourcePath,
gatewayUrl: buildGatewayUrl(gateway),
gatewayToken: typeof gateway.auth?.token === "string" && gateway.auth.token.trim()
? gateway.auth.token
: undefined
};
} catch {
return null;
}
}
export function resolveEffectiveGatewayUrl(configuredUrl: string, discoveredUrl?: string): string {
if (!discoveredUrl) {
return configuredUrl;
}
if (!configuredUrl || configuredUrl === DEFAULT_GATEWAY_URL) {
return discoveredUrl;
}
return configuredUrl;
}
function buildGatewayUrl(config: OpenClawGatewayConfigShape): string {
const port = typeof config.port === "number" ? config.port : 18789;
const host = resolveHost(config);
return `ws://${host}:${port}`;
}
function resolveHost(config: OpenClawGatewayConfigShape): string {
if (typeof config.host === "string" && config.host.trim()) {
return config.host;
}
if (config.bind === "loopback") {
return "127.0.0.1";
}
return "127.0.0.1";
}
This diff is collapsed.
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
interface SecretRecord {
note: string;
apiKey?: string;
gatewayToken?: string;
deviceToken?: string;
authToken?: string;
}
interface SecretAccessor {
get(secretName: SecretName): Promise<string | undefined>;
set(secretName: SecretName, value?: string): Promise<void>;
}
type SecretName = "apiKey" | "gatewayToken" | "deviceToken" | "authToken";
type KeytarModule = typeof import("keytar");
const KEYTAR_SERVICE = "QianjiangClaw";
const KEYTAR_ACCOUNT_MAP: Record<SecretName, string> = {
apiKey: "provider-api-key",
gatewayToken: "gateway-token",
deviceToken: "gateway-device-token",
authToken: "cloud-auth-token"
};
class FileSecretStore implements SecretAccessor {
private readonly userDataPath: string;
private secrets: SecretRecord = {
note: "Development fallback only. Replace this file-based secret store with keytar before shipping."
};
constructor(userDataPath: string) {
this.userDataPath = userDataPath;
}
async load(): Promise<void> {
const filePath = this.getFilePath();
await mkdir(path.dirname(filePath), { recursive: true });
try {
const raw = await readFile(filePath, "utf8");
this.secrets = JSON.parse(raw) as SecretRecord;
} catch {
await this.persist();
}
}
async get(secretName: SecretName): Promise<string | undefined> {
return this.secrets[secretName];
}
async set(secretName: SecretName, value?: string): Promise<void> {
if (!value) {
delete this.secrets[secretName];
await this.persist();
return;
}
this.secrets[secretName] = value;
await this.persist();
}
private getFilePath(): string {
return path.join(this.userDataPath, "config", "secrets.dev.json");
}
private async persist(): Promise<void> {
const filePath = this.getFilePath();
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(this.secrets, null, 2), "utf8");
}
}
class KeytarSecretStore implements SecretAccessor {
private readonly keytar: KeytarModule;
constructor(keytar: KeytarModule) {
this.keytar = keytar;
}
async get(secretName: SecretName): Promise<string | undefined> {
const value = await this.keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT_MAP[secretName]);
return value ?? undefined;
}
async set(secretName: SecretName, value?: string): Promise<void> {
if (!value) {
await this.keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT_MAP[secretName]);
return;
}
await this.keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT_MAP[secretName], value);
}
}
export class SecretManager {
private readonly fallbackStore: FileSecretStore;
private store: SecretAccessor;
private backend = "file-fallback";
constructor(userDataPath: string) {
this.fallbackStore = new FileSecretStore(userDataPath);
this.store = this.fallbackStore;
}
async load(): Promise<void> {
await this.fallbackStore.load();
const keytar = await this.tryLoadKeytar();
if (!keytar) {
this.backend = "file-fallback";
this.store = this.fallbackStore;
return;
}
this.store = new KeytarSecretStore(keytar);
this.backend = "keytar";
await this.migrateFallbackSecrets();
}
describeBackend(): string {
return this.backend;
}
async setApiKey(apiKey?: string): Promise<void> {
await this.store.set("apiKey", apiKey);
}
async getApiKey(): Promise<string | undefined> {
return this.store.get("apiKey");
}
async setGatewayToken(gatewayToken?: string): Promise<void> {
await this.store.set("gatewayToken", gatewayToken);
}
async getGatewayToken(): Promise<string | undefined> {
return this.store.get("gatewayToken");
}
async setDeviceToken(deviceToken?: string): Promise<void> {
await this.store.set("deviceToken", deviceToken);
}
async getDeviceToken(): Promise<string | undefined> {
return this.store.get("deviceToken");
}
async setAuthToken(authToken?: string): Promise<void> {
await this.store.set("authToken", authToken);
}
async getAuthToken(): Promise<string | undefined> {
return this.store.get("authToken");
}
private async tryLoadKeytar(): Promise<KeytarModule | null> {
try {
const imported = await import("keytar");
return ((imported as { default?: KeytarModule }).default ?? imported) as KeytarModule;
} catch {
return null;
}
}
private async migrateFallbackSecrets(): Promise<void> {
for (const secretName of ["apiKey", "gatewayToken", "deviceToken", "authToken"] as const) {
const existing = await this.store.get(secretName);
if (existing) {
continue;
}
const fallbackValue = await this.fallbackStore.get(secretName);
if (fallbackValue) {
await this.store.set(secretName, fallbackValue);
}
}
}
}
import http from "node:http";
export async function startSmokeCloudApiServer(baseUrl: string, token: string, runtimeApiKey = "smoke-runtime-api-key"): Promise<() => Promise<void>> {
const url = new URL(baseUrl);
const hostname = url.hostname;
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
const server = http.createServer((req, res) => {
const requestUrl = new URL(req.url || "/", `${url.protocol}//${url.host}`);
const authHeader = req.headers.authorization || "";
const bearerToken = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : "";
const sendJson = (status: number, payload: unknown) => {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
};
const readJsonBody = async (): Promise<Record<string, unknown>> => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
if (chunks.length === 0) {
return {};
}
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
};
const handleRequest = async () => {
if (req.method === "POST" && requestUrl.pathname === "/openclaw-employee-config") {
const body = await readJsonBody();
const apiKey = typeof body.api_key === "string" ? body.api_key : "";
const action = body.action === "init" ? "init" : "sync";
const configVersion = typeof body.config_version === "string" ? body.config_version : undefined;
if (apiKey !== runtimeApiKey) {
sendJson(401, { message: "Invalid api_key or employee not found" });
return;
}
const currentVersion = "2026-03-23T20:00:00.000Z";
if (action === "sync" && configVersion === currentVersion) {
sendJson(200, { changed: false, config_version: currentVersion });
return;
}
sendJson(200, {
changed: true,
employee_id: "employee-smoke",
name: "Smoke Lobster",
status: "running",
deployment_type: "local",
persona_prompt: "你是前台验证用的小龙虾员工,请直接响应用户。",
welcome_message: "你好,我已经接入真实运行时配置。",
work_hours: {
start: null,
end: null,
is_24h: true
},
auto_reply_rules: [],
resource_quota: {
max_concurrency: 3
},
llm: {
model_id: "gpt-5.4-mini",
temperature: 0.2,
max_tokens: 2048,
display_name: "GPT-5.4 Mini",
tier: "standard",
credit_multiplier: 1,
max_context_length: 200000,
provider: {
name: "Smoke OpenAI Compatible",
base_url: "http://127.0.0.1:11434/v1",
api_key: "runtime-provider-token",
provider_type: "openai_compatible"
}
},
skills: [
{
binding_id: "binding-legal-research",
skill_id: "legal-research",
skill_config: {},
skill: {
id: "legal-research",
title: "Legal Research",
description: "Finds statutes and cases.",
category: "research",
file_name: "legal-research.skill.json",
file_size: 1024,
download_url: "https://example.invalid/legal-research.skill.json"
}
}
],
channels: [
{
id: "channel-web",
type: "web",
name: "Smoke Web",
config: {
enabled: true
}
}
],
endpoints: {
heartbeat: `${baseUrl}/openclaw-heartbeat`,
events: `${baseUrl}/openclaw-employee-events`,
config: `${baseUrl}/openclaw-employee-config`
},
config_version: currentVersion
});
return;
}
if (req.method === "POST" && requestUrl.pathname === "/openclaw-heartbeat") {
const body = await readJsonBody();
const apiKey = typeof body.api_key === "string" ? body.api_key : "";
if (apiKey !== runtimeApiKey) {
sendJson(401, { message: "Invalid api_key or employee not found" });
return;
}
sendJson(200, {
ok: true,
employee_id: "employee-smoke",
name: "Smoke Lobster",
status: "running",
heartbeat_at: new Date().toISOString(),
billing: {
success: true,
token_delta: 0,
credits_deducted: 0,
balance_before: 88,
balance_after: 88
}
});
return;
}
if (req.method === "POST" && requestUrl.pathname === "/openclaw-employee-events") {
const body = await readJsonBody();
const apiKey = typeof body.api_key === "string" ? body.api_key : "";
if (apiKey !== runtimeApiKey) {
sendJson(401, { message: "Invalid api_key or employee not found" });
return;
}
const events = Array.isArray(body.events) ? body.events : [];
sendJson(200, { ok: true, accepted: events.length, failed: 0 });
return;
}
if (bearerToken !== token) {
sendJson(401, { message: "Invalid cloud access token." });
return;
}
if (req.method === "GET" && requestUrl.pathname === "/v1/auth/session") {
sendJson(200, {
user: { displayName: "Smoke User", email: "smoke.user@qianjiangclaw.local" },
organization: { name: "Smoke Org" },
expiresAt: new Date(Date.now() + 1000 * 60 * 60).toISOString()
});
return;
}
if (req.method === "GET" && requestUrl.pathname === "/v1/me/profile") {
sendJson(200, {
id: "user-smoke",
displayName: "Smoke User",
email: "smoke.user@qianjiangclaw.local",
organizationName: "Smoke Org",
title: "Installer MVP Tester"
});
return;
}
if (req.method === "GET" && requestUrl.pathname === "/v1/me/credits") {
sendJson(200, {
balance: 88,
granted: 120,
used: 32,
updatedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14).toISOString()
});
return;
}
if (req.method === "GET" && requestUrl.pathname === "/v1/skills") {
sendJson(200, {
items: [
{ id: "legal-research", name: "Legal Research", description: "Finds statutes and cases.", category: "research", enabled: true, requiresCredits: 4 },
{ id: "contract-review", name: "Contract Review", description: "Runs clause review workflows.", category: "analysis", enabled: true, requiresCredits: 10 }
]
});
return;
}
if (req.method === "GET" && requestUrl.pathname === "/v1/model-config") {
sendJson(200, {
version: "smoke-model-policy-2026-03-23",
updatedAt: new Date().toISOString(),
routingMode: "skill-bound",
fallbackMode: "last-known-cache",
defaults: {
chatModelId: "gpt-5.4-mini",
chatModelLabel: "GPT-5.4 Mini",
skillModelId: "gpt-5.4",
skillModelLabel: "GPT-5.4"
},
items: [
{
id: "gpt-5.4-mini",
label: "GPT-5.4 Mini",
provider: "openai",
family: "gpt-5.4",
description: "Fast default desktop chat model.",
enabled: true,
contextWindow: 200000,
maxOutputTokens: 32000,
capabilities: ["chat", "reasoning", "tools"],
recommendedFor: ["default", "chat"]
},
{
id: "gpt-5.4",
label: "GPT-5.4",
provider: "openai",
family: "gpt-5.4",
description: "Higher-depth model reserved for curated Skills.",
enabled: true,
contextWindow: 200000,
maxOutputTokens: 32000,
capabilities: ["chat", "reasoning", "tools"],
recommendedFor: ["skills", "analysis"]
}
],
skillBindings: [
{
skillId: "legal-research",
skillName: "Legal Research",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
routingMode: "forced"
},
{
skillId: "contract-review",
skillName: "Contract Review",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
routingMode: "forced"
}
]
});
return;
}
sendJson(404, { message: `Unknown endpoint: ${req.method} ${requestUrl.pathname}` });
};
void handleRequest().catch((error) => {
sendJson(500, { message: error instanceof Error ? error.message : String(error) });
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, hostname, () => {
server.off("error", reject);
resolve();
});
});
return async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
};
}
import { contextBridge, ipcRenderer } from "electron";
import { IPC_CHANNELS, type DesktopApi, type RuntimeCloudFetchAction, type SaveConfigInput, type SignInInput } from "@qjclaw/shared-types";
const desktopApi: DesktopApi = {
workspace: {
getSummary: () => ipcRenderer.invoke(IPC_CHANNELS.workspaceGetSummary)
},
gateway: {
status: () => ipcRenderer.invoke(IPC_CHANNELS.gatewayStatus),
connect: () => ipcRenderer.invoke(IPC_CHANNELS.gatewayConnect),
disconnect: () => ipcRenderer.invoke(IPC_CHANNELS.gatewayDisconnect),
reconnect: () => ipcRenderer.invoke(IPC_CHANNELS.gatewayReconnect),
health: () => ipcRenderer.invoke(IPC_CHANNELS.gatewayHealth),
tailLogs: (limit?: number) => ipcRenderer.invoke(IPC_CHANNELS.gatewayTailLogs, limit)
},
runtime: {
getStatus: () => ipcRenderer.invoke(IPC_CHANNELS.runtimeGetStatus),
tailLogs: (limit?: number) => ipcRenderer.invoke(IPC_CHANNELS.runtimeTailLogs, limit),
start: () => ipcRenderer.invoke(IPC_CHANNELS.runtimeStart),
stop: () => ipcRenderer.invoke(IPC_CHANNELS.runtimeStop),
restart: () => ipcRenderer.invoke(IPC_CHANNELS.runtimeRestart),
health: () => ipcRenderer.invoke(IPC_CHANNELS.runtimeHealth)
},
runtimeCloud: {
getStatus: () => ipcRenderer.invoke(IPC_CHANNELS.runtimeCloudGetStatus),
fetchConfig: (action?: RuntimeCloudFetchAction) => ipcRenderer.invoke(IPC_CHANNELS.runtimeCloudFetchConfig, action)
},
runtimeTelemetry: {
getStatus: () => ipcRenderer.invoke(IPC_CHANNELS.runtimeTelemetryGetStatus)
},
config: {
load: () => ipcRenderer.invoke(IPC_CHANNELS.configLoad),
save: (input: SaveConfigInput) => ipcRenderer.invoke(IPC_CHANNELS.configSave, input)
},
auth: {
getSessionSummary: () => ipcRenderer.invoke(IPC_CHANNELS.authGetSession),
signIn: (input: SignInInput) => ipcRenderer.invoke(IPC_CHANNELS.authSignIn, input),
signOut: () => ipcRenderer.invoke(IPC_CHANNELS.authSignOut)
},
profile: {
getSummary: () => ipcRenderer.invoke(IPC_CHANNELS.profileGetSummary)
},
credits: {
getSummary: () => ipcRenderer.invoke(IPC_CHANNELS.creditsGetSummary)
},
skills: {
list: () => ipcRenderer.invoke(IPC_CHANNELS.skillsList)
},
modelConfig: {
getSummary: () => ipcRenderer.invoke(IPC_CHANNELS.modelConfigGetSummary)
},
system: {
getSummary: () => ipcRenderer.invoke(IPC_CHANNELS.systemGetSummary)
},
chat: {
listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions),
listMessages: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListMessages, sessionId),
sendPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId)
},
diagnostics: {
openControlUi: () => ipcRenderer.invoke(IPC_CHANNELS.diagnosticsOpenControlUi),
exportSnapshot: () => ipcRenderer.invoke(IPC_CHANNELS.diagnosticsExportSnapshot)
}
};
const smokeEnabled = process.argv.includes("--qjc-smoke");
contextBridge.exposeInMainWorld("qjcDesktop", desktopApi);
contextBridge.exposeInMainWorld("qjcSmokeEnabled", smokeEnabled);
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"types": ["node", "electron"]
},
"include": ["src", "tsup.config.ts"]
}
import { defineConfig } from "tsup";
const bundledWorkspaceDeps = ["@qjclaw/gateway-client", "@qjclaw/runtime-manager", "@qjclaw/shared-types"];
export default defineConfig([
{
clean: true,
dts: false,
entry: {
index: "src/main/index.ts"
},
external: ["electron"],
noExternal: bundledWorkspaceDeps,
format: ["cjs"],
outDir: "dist/main",
platform: "node",
target: "node20"
},
{
clean: false,
dts: false,
entry: {
index: "src/preload/index.ts"
},
external: ["electron"],
noExternal: bundledWorkspaceDeps,
format: ["cjs"],
outDir: "dist/preload",
platform: "node",
target: "node20"
}
]);
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QianjiangClaw</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
{
"name": "@qjclaw/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"clean": "rimraf dist",
"dev": "vite",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@qjclaw/shared-types": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"rimraf": "^6.0.1",
"typescript": "^5.7.3",
"vite": "^6.0.5"
}
}
This diff is collapsed.
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
:root {
color: #182236;
background:
radial-gradient(circle at top left, rgba(0, 110, 255, 0.08), transparent 30%),
radial-gradient(circle at bottom right, rgba(14, 184, 166, 0.08), transparent 26%),
#f4f7fb;
font-family: "Microsoft YaHei UI", "PingFang SC", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body, #root { margin: 0; min-height: 100%; }
body { min-height: 100vh; }
button, input, textarea, select { font: inherit; }
button {
border: 0;
border-radius: 12px;
padding: 10px 14px;
background: linear-gradient(135deg, #0f7bff, #0c60d8);
color: #fff;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: #edf3fb;
color: #2d3955;
box-shadow: inset 0 0 0 1px #d8e1ef;
}
button:disabled { opacity: 0.55; cursor: not-allowed; }
input, textarea, select {
width: 100%;
border: 1px solid #d9e2ef;
border-radius: 12px;
padding: 12px 14px;
background: #fff;
color: #182236;
}
textarea { min-height: 150px; resize: vertical; }
label { display: grid; gap: 8px; color: #53637f; font-size: 13px; }
p, h1, h2, h3, strong, span { margin: 0; }
strong { font-weight: 600; }
.shell {
min-height: 100vh;
display: grid;
grid-template-columns: 208px minmax(0, 1fr);
}
.sidebar {
padding: 22px 16px;
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
background: linear-gradient(180deg, #f8fbff, #edf3fa);
border-right: 1px solid #dee6f1;
}
.brand-block, .nav-list, .page-stack, .content-area, .message-list, .catalog-list, .form-grid, .skill-picker, .chat-panel, .settings-panel, .bind-block {
display: grid;
gap: 12px;
}
.brand-kicker {
display: inline-flex;
width: fit-content;
padding: 5px 10px;
border-radius: 999px;
background: rgba(15, 123, 255, 0.1);
color: #0f7bff;
font-size: 12px;
letter-spacing: 0.06em;
}
.brand-block h1 { font-size: 28px; }
.brand-block p, .page-header p, .section-head p, .catalog-item p, .notice, .empty-state, .mini-info span, .inline-hint {
color: #667794;
line-height: 1.6;
font-size: 13px;
}
.nav-list { gap: 8px; }
.nav-item {
height: 42px;
padding: 0 12px;
border-radius: 12px;
text-align: left;
background: transparent;
color: #20304b;
display: flex;
align-items: center;
font-size: 14px;
}
.nav-item.active {
background: #fff;
color: #0f7bff;
box-shadow: inset 3px 0 0 #0f7bff, 0 8px 18px rgba(15, 123, 255, 0.1);
}
.main-shell {
padding: 22px;
display: grid;
gap: 14px;
}
.panel, .notice, .empty-state, .message-card, .catalog-item {
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #dfe7f2;
box-shadow: 0 18px 40px rgba(35, 52, 82, 0.06);
}
.panel { padding: 20px; }
.page-header {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.header-actions, .section-head, .button-row, .mini-info, .bind-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.section-head.compact { align-items: flex-start; }
.notice, .empty-state, .catalog-item { padding: 14px; }
.notice { background: rgba(15, 123, 255, 0.08); color: #28507f; }
.notice.error { background: rgba(239, 68, 68, 0.08); color: #972f2f; }
.empty-state { background: #f8fbff; border-style: dashed; }
.chat-topbar {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(220px, 0.9fr);
gap: 14px;
align-items: start;
}
.bind-row input { flex: 1 1 auto; }
.bind-row button { flex: 0 0 auto; min-width: 88px; }
.inline-hint {
padding: 10px 12px;
border-radius: 12px;
background: #f8fbff;
border: 1px solid #e3ebf5;
}
.inline-hint.error {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.18);
color: #972f2f;
}
.field-label { color: #53637f; font-size: 13px; }
.message-list {
min-height: 280px;
max-height: 480px;
overflow: auto;
padding-right: 4px;
}
.message-card { padding: 16px; }
.message-card.user { background: #eef5ff; }
.message-card.assistant { background: #eefbf7; }
.message-card p { white-space: pre-wrap; line-height: 1.7; }
.catalog-item {
text-align: left;
display: grid;
gap: 8px;
}
.catalog-item.static { cursor: default; }
.catalog-item:hover { transform: translateY(-1px); }
.catalog-item.static:hover { transform: none; }
.form-grid.single { grid-template-columns: 1fr; }
.mini-info {
padding: 14px 16px;
border-radius: 14px;
background: #f8fbff;
border: 1px solid #e3ebf5;
}
.mini-info strong {
max-width: 68%;
text-align: right;
line-height: 1.6;
}
.status-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.status-chip.positive { background: rgba(16, 185, 129, 0.12); color: #0f7f59; }
.status-chip.warning { background: rgba(245, 158, 11, 0.14); color: #b46f0a; }
@media (max-width: 960px) {
.shell { grid-template-columns: 1fr; }
.sidebar {
border-right: 0;
border-bottom: 1px solid #dee6f1;
}
.nav-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.chat-topbar { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.main-shell, .sidebar { padding: 16px; }
.page-header, .header-actions, .button-row, .mini-info, .bind-row { align-items: stretch; flex-direction: column; }
.nav-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.nav-item { justify-content: center; }
}
/// <reference types="vite/client" />
import type { DesktopApi } from "@qjclaw/shared-types";
declare global {
interface Window {
qjcDesktop?: DesktopApi;
}
}
export {};
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts", "index.html"]
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
base: "./",
plugins: [react()],
build: {
outDir: path.resolve(__dirname, "../desktop/dist/renderer"),
emptyOutDir: true
},
server: {
host: "127.0.0.1",
port: 5173
}
});
import http from "node:http";
const port = Number(process.env.QJCLAW_CLOUD_API_PORT || 4318);
const token = process.env.QJCLAW_SMOKE_AUTH_TOKEN || "smoke-token";
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function readAuth(req) {
const header = req.headers.authorization || "";
return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
}
const server = http.createServer((req, res) => {
const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
if (readAuth(req) !== token) {
sendJson(res, 401, { message: "Invalid cloud access token." });
return;
}
if (req.method === "GET" && url.pathname === "/v1/auth/session") {
sendJson(res, 200, {
user: { displayName: "Smoke User", email: "smoke.user@qianjiangclaw.local" },
organization: { name: "Smoke Org" },
expiresAt: new Date(Date.now() + 1000 * 60 * 60).toISOString()
});
return;
}
if (req.method === "GET" && url.pathname === "/v1/me/profile") {
sendJson(res, 200, {
id: "user-smoke",
displayName: "Smoke User",
email: "smoke.user@qianjiangclaw.local",
organizationName: "Smoke Org",
title: "Installer MVP Tester"
});
return;
}
if (req.method === "GET" && url.pathname === "/v1/me/credits") {
sendJson(res, 200, {
balance: 88,
granted: 120,
used: 32,
updatedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14).toISOString()
});
return;
}
if (req.method === "GET" && url.pathname === "/v1/skills") {
sendJson(res, 200, {
items: [
{ id: "legal-research", name: "Legal Research", description: "Finds statutes and cases.", category: "research", enabled: true, requiresCredits: 4 },
{ id: "contract-review", name: "Contract Review", description: "Runs clause review workflows.", category: "analysis", enabled: true, requiresCredits: 10 }
]
});
return;
}
sendJson(res, 404, { message: `Unknown endpoint: ${req.method} ${url.pathname}` });
});
server.listen(port, "127.0.0.1", () => {
process.stdout.write(`QJCLAW cloud API smoke server listening on http://127.0.0.1:${port}\n`);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
server.close(() => process.exit(0));
});
}
# Core bundled-runtime Python dependencies for built-in Skills.
openpyxl==3.1.5
pandas==2.2.3
requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.3.0
pypdf==5.4.0
python-docx==1.1.2
charset-normalizer==3.4.1
pyyaml==6.0.2
\ No newline at end of file
# Core bundled-runtime Python dependencies for built-in Skills.
openpyxl==3.1.5
pandas==2.2.3
requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.3.0
pypdf==5.4.0
python-docx==1.1.2
charset-normalizer==3.4.1
pyyaml==6.0.2
\ No newline at end of file
# Build Notes
- `apps/ui` emits its production bundle into `apps/desktop/dist/renderer`
- `apps/desktop` packages the final EXE
- `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
- `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
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[string]$SmokeOutput,
[string]$UserDataPath,
[string]$LogsPath,
[int]$TimeoutSeconds = 90
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $SmokeOutput) {
$SmokeOutput = Join-Path $repoRoot '.tmp\bundled-runtime-smoke\result.json'
}
if (-not $UserDataPath) {
$UserDataPath = Join-Path $repoRoot '.tmp\bundled-runtime-smoke\user-data'
}
if (-not $LogsPath) {
$LogsPath = Join-Path $repoRoot '.tmp\bundled-runtime-smoke\logs'
}
Write-Host "Materializing bundled runtime payload on port $GatewayPort"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -GatewayPort $GatewayPort -GatewayToken $GatewayToken
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') -SmokeOutput $SmokeOutput -UserDataPath $UserDataPath -LogsPath $LogsPath -RuntimeMode 'bundled-runtime' -ExpectBundledRuntime -TimeoutSeconds $TimeoutSeconds
exit $LASTEXITCODE
param(
[string]$SmokeOutput,
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$UserDataPath,
[string]$LogsPath,
[string]$RuntimeMode = 'auto',
[switch]$ExpectBundledRuntime,
[int]$TimeoutSeconds = 60
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$electron = Join-Path $repoRoot 'node_modules\.pnpm\electron@34.5.8\node_modules\electron\dist\electron.exe'
$desktopApp = Join-Path $repoRoot 'apps\desktop'
$rendererUrl = Join-Path $repoRoot 'apps\desktop\dist\renderer\index.html'
if (-not (Test-Path $electron)) {
throw "Electron executable not found at $electron"
}
if (-not $SmokeOutput) {
$SmokeOutput = Join-Path $repoRoot '.tmp\electron-smoke-execution-policy.json'
}
if (-not $UserDataPath) {
$UserDataPath = Join-Path $repoRoot '.tmp\smoke-user-data'
}
if (-not $LogsPath) {
$LogsPath = Join-Path $repoRoot '.tmp\smoke-logs'
}
$SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput)
$UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
$LogsPath = [System.IO.Path]::GetFullPath($LogsPath)
foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) {
$parent = Split-Path $pathValue -Parent
if ($parent) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
}
if (Test-Path $SmokeOutput) {
Remove-Item $SmokeOutput -Force
}
if (Test-Path $UserDataPath) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path $LogsPath) {
Remove-Item $LogsPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $UserDataPath, $LogsPath | Out-Null
$env:QJCLAW_RENDERER_URL = $rendererUrl
$env:QJCLAW_SMOKE_OUTPUT = $SmokeOutput
$env:QJCLAW_SMOKE_CLOUD_API_BASE_URL = "http://127.0.0.1:$SmokePort"
$env:QJCLAW_SMOKE_AUTH_TOKEN = $SmokeToken
$env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY = 'smoke-runtime-api-key'
$env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS = 1000
$env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS = 1500
$env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS = 800
$env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE = 3
$env:QJCLAW_USER_DATA_PATH = $UserDataPath
$env:QJCLAW_LOGS_PATH = $LogsPath
if ($RuntimeMode) {
$env:QJCLAW_RUNTIME_MODE = $RuntimeMode
}
try {
Write-Host "Running Electron smoke with isolated userData at $UserDataPath"
$process = Start-Process -FilePath $electron -ArgumentList $desktopApp -PassThru
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-Path $SmokeOutput) {
break
}
Start-Sleep -Milliseconds 500
}
$alive = Get-Process -Id $process.Id -ErrorAction SilentlyContinue
if ($alive) {
Wait-Process -Id $process.Id -Timeout 10 -ErrorAction SilentlyContinue
$alive = Get-Process -Id $process.Id -ErrorAction SilentlyContinue
}
if ($alive) {
Stop-Process -Id $process.Id -Force
throw "Electron smoke process did not exit within $TimeoutSeconds seconds."
}
if (-not (Test-Path $SmokeOutput)) {
throw "Smoke output file was not created: $SmokeOutput"
}
$expectBundledValue = if ($ExpectBundledRuntime) { 'true' } else { 'false' }
$validator = @"
const fs = require('fs');
const [smokeOutput, expectedUserData, expectedLogs, runtimeMode, expectBundled] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) {
const message = result.error || 'Unknown smoke failure.';
throw new Error('Electron smoke failed: ' + message);
}
const sendResult = result.sendResult || {};
const replyPolicy = sendResult.reply && sendResult.reply.executionPolicy;
if (!replyPolicy) {
throw new Error('Execution policy was not returned from chat.sendPrompt.');
}
if (replyPolicy.source !== 'cloud-skill-binding') {
throw new Error('Unexpected execution policy source: ' + replyPolicy.source);
}
if (!sendResult.selectedSkillId) {
throw new Error('Smoke did not select a Skill before sendPrompt.');
}
if (replyPolicy.skillId !== sendResult.selectedSkillId) {
throw new Error('Execution policy skillId does not match selectedSkillId.');
}
if (!sendResult.modelConfig || sendResult.modelConfig.routingMode !== 'skill-bound') {
throw new Error('Unexpected model routing mode: ' + (sendResult.modelConfig && sendResult.modelConfig.routingMode));
}
if (String(sendResult.system && sendResult.system.userDataPath) !== expectedUserData) {
throw new Error('Smoke ran against an unexpected userData path: ' + (sendResult.system && sendResult.system.userDataPath));
}
if (String(sendResult.system && sendResult.system.logsPath) !== expectedLogs) {
throw new Error('Smoke ran against an unexpected logs path: ' + (sendResult.system && sendResult.system.logsPath));
}
const diagnosticsPath = String(sendResult.diagnostics && sendResult.diagnostics.filePath || '');
if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) {
throw new Error('Diagnostics snapshot was not produced by smoke.');
}
const diagnostics = JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'));
const runtimeTelemetry = sendResult.runtimeTelemetryAfterWait || sendResult.runtimeTelemetryBeforeWait || {};
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
throw new Error('Runtime cloud config fetch did not succeed.');
}
if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful heartbeat.');
}
if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount);
}
if (Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful config sync.');
}
if (!diagnostics.runtimeTelemetry) {
throw new Error('Diagnostics snapshot did not include runtimeTelemetry.');
}
if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {};
const runtimeHealth = sendResult.runtimeHealthAfterProbe || {};
if (runtimeStatus.activeMode !== 'bundled-runtime') {
throw new Error('Bundled runtime did not become active. Active mode: ' + runtimeStatus.activeMode);
}
if (runtimeStatus.processState !== 'running') {
throw new Error('Bundled runtime did not stay running. Process state: ' + runtimeStatus.processState);
}
if (!runtimeHealth.ok) {
throw new Error('Bundled runtime health check did not report ok after startup.');
}
if (!runtimeStatus.pythonReady) {
throw new Error('Bundled runtime did not report a ready Python payload.');
}
if (!Array.isArray(runtimeStatus.installedPythonPackages) || runtimeStatus.installedPythonPackages.length < 9) {
throw new Error('Bundled runtime did not report the expected Python package set.');
}
if (!sendResult.status || sendResult.status.state !== 'connected') {
throw new Error('Gateway did not reconnect after bundled runtime startup: ' + (sendResult.status && sendResult.status.state));
}
}
const summary = {
ok: true,
smokeOutput,
runtimeMode,
userDataPath: expectedUserData,
logsPath: expectedLogs,
selectedSkillId: String(sendResult.selectedSkillId),
executionPolicySource: String(replyPolicy.source),
executionPolicyModel: String(replyPolicy.modelLabel),
executionPolicyRouting: String(replyPolicy.routingMode),
runtimeActiveMode: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.activeMode || ''),
runtimeProcessState: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.processState || ''),
runtimeGatewayUrl: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.gatewayUrl || ''),
runtimePythonReady: Boolean(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.pythonReady),
runtimePythonVersion: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.pythonVersion || ''),
runtimePythonPackages: sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.installedPythonPackages || [],
messageCount: Number(sendResult.messageCount || 0),
heartbeatSuccessCount: Number(runtimeTelemetry.heartbeatSuccessCount || 0),
configSyncSuccessCount: Number(runtimeTelemetry.configSyncSuccessCount || 0),
totalAcceptedEventCount: Number(runtimeTelemetry.totalAcceptedEventCount || 0),
diagnosticsPath,
};
console.log(JSON.stringify(summary, null, 2));
"@
$summary = & node -e $validator $SmokeOutput $UserDataPath $LogsPath $RuntimeMode $expectBundledValue
if ($LASTEXITCODE -ne 0) {
throw 'Electron smoke validation failed.'
}
Write-Output $summary
}
finally {
Remove-Item Env:QJCLAW_RENDERER_URL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_OUTPUT -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_CLOUD_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_AUTH_TOKEN -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_USER_DATA_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_LOGS_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_MODE -ErrorAction SilentlyContinue
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
# QianjiangClaw Architecture
Updated: 2026-03-23
## 1. Stable Desktop Boundary
The stable boundary remains:
- Renderer
- UI only
- does not own raw runtime credentials
- does not directly start runtime processes
- preload
- exposes controlled desktop APIs to Renderer
- Electron Main
- owns secret access
- owns runtime cloud fetch
- owns managed runtime config generation
- owns bundled runtime lifecycle
- GatewayClient / RuntimeManager / cloud clients
## 2. Current Runtime-Cloud Design
Stage D step 1 uses a runtime-first cloud integration model.
The current key rule is:
- the user must first bind an OpenClaw employee `api_key`
- Electron Main uses that key to call `POST /openclaw-employee-config`
- the returned payload becomes the source of truth for runtime business configuration
This `api_key` is not the LLM vendor key. It is the OpenClaw employee identity credential.
## 3. Required Runtime-Side Clients
Main should include or add these runtime-side pieces:
- `OpenClawConfigClient`
- calls `POST /openclaw-employee-config`
- fetches runtime payload for startup
- later:
- heartbeat client for `POST /openclaw-heartbeat`
- event client for `POST /openclaw-employee-events`
## 4. Startup Flow for Stage D Step 1
Installer startup should follow this order:
1. Desktop app loads local settings.
2. Desktop app reads stored employee `api_key` from secret storage.
3. If missing, runtime startup is blocked and UI asks the user to bind the key.
4. If present, Main calls `POST /openclaw-employee-config` with:
- `api_key`
- `action = "init"`
5. Main validates returned runtime payload.
6. Main merges cloud payload with local machine-specific fields.
7. Main writes local managed runtime config.
8. `RuntimeManager.start()` launches bundled runtime using that managed config.
9. Gateway becomes available for the desktop product.
## 5. Runtime Config Responsibility Split
Cloud payload should provide runtime business configuration such as:
- `persona_prompt`
- `welcome_message`
- `work_hours`
- `auto_reply_rules`
- `resource_quota`
- `llm.model_id`
- `llm.temperature`
- `llm.max_tokens`
- `llm.provider.name`
- `llm.provider.base_url`
- `llm.provider.api_key`
- `skills`
- `channels`
- `config_version`
- runtime reporting endpoints
Desktop Main should still provide local host-specific fields such as:
- workspace path
- runtime state dir
- runtime logs dir
- managed config output path
- bundled Gateway bind and port
- generated gateway auth token for local control path
## 6. Security Rules
- Employee `api_key` lives in `SecretManager/keytar`.
- Renderer never receives raw `api_key`.
- Managed runtime config may contain effective runtime credentials, but diagnostics must not export raw sensitive values.
- If cloud config fetch fails, bundled runtime must remain unavailable instead of fake-ready.
## 7. Current State
Already in place:
- installer packaging
- bundled runtime payload delivery
- runtime detect/start/stop/restart/health/tailLogs
- managed config generation path exists locally
- diagnostics and UI pages already exist
Still missing for Stage D step 1:
- user-bound employee `api_key` flow
- `OpenClawConfigClient` wired to `POST /openclaw-employee-config`
- managed config generation from cloud payload
- real installer usability based on cloud-driven startup
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# QianjiangClaw Product Roadmap
Updated: 2026-03-23
## 1. Current Delivery Order
Current delivery order should be treated as:
- Stage A: complete
- Stage B: complete
- Stage C technical PoC: complete
- Stage D step 1: make the installer truly usable through runtime cloud config integration
- later Stage D work: heartbeat, events, business cloud APIs, and full commercial UX
## 2. Stage D Step 1 Goal
Stage D step 1 goal is now fixed as:
- the installer becomes truly usable
- bundled OpenClaw starts from real cloud-fetched runtime config
- user first binds an OpenClaw employee `api_key`
- app automatically pulls runtime config and starts bundled runtime
This means the current top priority is not full business API integration.
The top priority is runtime usability for the installer.
## 3. Must-Integrate Runtime APIs for Step 1
Current must-integrate runtime API for this first slice:
- `POST /openclaw-employee-config`
Later runtime APIs:
- `POST /openclaw-heartbeat`
- `POST /openclaw-employee-events`
## 4. Step 1 Implementation Order
Implementation should happen in this order:
1. add user-facing binding flow for OpenClaw employee `api_key`
2. store that key in `SecretManager/keytar`
3. implement `OpenClawConfigClient` for `POST /openclaw-employee-config`
4. fetch config with `{ api_key, action: "init" }`
5. transform returned payload into managed local runtime config
6. start bundled runtime from that generated config
7. verify installer startup and usable state
## 5. What Comes After Step 1
Only after installer usability is proven, continue with:
- `POST /openclaw-heartbeat`
- `POST /openclaw-employee-events`
- product-side login/profile/credits/Skills real production integration
- entitlement and billing UX
- clean-machine validation and formal release polish
## 6. Acceptance Gates for Step 1
Stage D step 1 is complete only when:
- user can bind an OpenClaw employee `api_key`
- missing key prevents runtime startup with a clear UI message
- `POST /openclaw-employee-config` succeeds with a real key
- managed runtime config is generated from returned payload
- bundled runtime starts from that config
- installer is usable without manual provider/model/base_url/vendor api_key setup
## 7. Key Product Rule
The product rule is now:
- users still should not manually configure provider, model, base_url, or vendor model api_key
- but users must first bind the OpenClaw employee `api_key` that activates their runtime instance
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"declaration": true
},
"include": ["src", "tsup.config.ts"]
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
packages:
- apps/*
- packages/*
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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