From 99132483b628ed4379bb90b8a7817561f23ae76c Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:42:31 -0400 Subject: [PATCH 01/19] =?UTF-8?q?chore:=20scaffold=20multihost=20branch=20?= =?UTF-8?q?=E2=80=94=20extension=20dirs,=20package.jsons,=20tsconfigs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates openclaw-local, openclaw-docker, openclaw-ssh extension scaffolds and new src/hosts, src/ui, src/api directories in core extension. Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw-docker/package.json | 23 +++++++++++++++++++ .../extensions/openclaw-docker/tsconfig.json | 14 +++++++++++ .../extensions/openclaw-local/package.json | 23 +++++++++++++++++++ .../extensions/openclaw-local/tsconfig.json | 14 +++++++++++ .../extensions/openclaw-ssh/package.json | 23 +++++++++++++++++++ .../extensions/openclaw-ssh/tsconfig.json | 14 +++++++++++ 6 files changed, 111 insertions(+) create mode 100644 apps/editor/extensions/openclaw-docker/package.json create mode 100644 apps/editor/extensions/openclaw-docker/tsconfig.json create mode 100644 apps/editor/extensions/openclaw-local/package.json create mode 100644 apps/editor/extensions/openclaw-local/tsconfig.json create mode 100644 apps/editor/extensions/openclaw-ssh/package.json create mode 100644 apps/editor/extensions/openclaw-ssh/tsconfig.json diff --git a/apps/editor/extensions/openclaw-docker/package.json b/apps/editor/extensions/openclaw-docker/package.json new file mode 100644 index 00000000..d2a8efa3 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/package.json @@ -0,0 +1,23 @@ +{ + "name": "openclaw-docker", + "displayName": "OpenClaw Docker", + "description": "Manage OpenClaw inside Docker containers", + "version": "1.0.0", + "publisher": "openclaw", + "license": "MIT", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["openclaw.home"], + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension", + "contributes": {}, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + } +} diff --git a/apps/editor/extensions/openclaw-docker/tsconfig.json b/apps/editor/extensions/openclaw-docker/tsconfig.json new file mode 100644 index 00000000..a358ee24 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/apps/editor/extensions/openclaw-local/package.json b/apps/editor/extensions/openclaw-local/package.json new file mode 100644 index 00000000..e73b1b71 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/package.json @@ -0,0 +1,23 @@ +{ + "name": "openclaw-local", + "displayName": "OpenClaw Local", + "description": "Manage OpenClaw on your local machine", + "version": "1.0.0", + "publisher": "openclaw", + "license": "MIT", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["openclaw.home"], + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension", + "contributes": {}, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + } +} diff --git a/apps/editor/extensions/openclaw-local/tsconfig.json b/apps/editor/extensions/openclaw-local/tsconfig.json new file mode 100644 index 00000000..a358ee24 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/apps/editor/extensions/openclaw-ssh/package.json b/apps/editor/extensions/openclaw-ssh/package.json new file mode 100644 index 00000000..323749bd --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/package.json @@ -0,0 +1,23 @@ +{ + "name": "openclaw-ssh", + "displayName": "OpenClaw SSH (Preview)", + "description": "Manage OpenClaw on remote servers via SSH — coming soon", + "version": "0.1.0", + "publisher": "openclaw", + "license": "MIT", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["openclaw.home"], + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension", + "contributes": {}, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + } +} diff --git a/apps/editor/extensions/openclaw-ssh/tsconfig.json b/apps/editor/extensions/openclaw-ssh/tsconfig.json new file mode 100644 index 00000000..a358ee24 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", ".vscode-test"] +} From 7ddd79ba2c6af0755ea62fa0d042b4a3206923a5 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:45:46 -0400 Subject: [PATCH 02/19] feat(multihost): add shared host types + adapter extension scaffolds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/editor/extensions/openclaw/src/hosts/types.ts: complete shared interface definitions — HostAdapter, HostConnection, HostEntry, HostsFile, HostCache, OpenClawCoreAPI, all connection configs (Local/Docker/SSH/Cloud), LogFn, ExecOpts/Result, GatewayStatus, etc. - apps/editor/extensions/openclaw-local/: new standalone extension stub (package.json + tsconfig.json) - apps/editor/extensions/openclaw-docker/: new standalone extension stub - apps/editor/extensions/openclaw-ssh/: new standalone extension stub (preview, 0.1.0) Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw/src/hosts/types.ts | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 apps/editor/extensions/openclaw/src/hosts/types.ts diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts new file mode 100644 index 00000000..2d82e9f2 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -0,0 +1,302 @@ +import * as vscode from 'vscode'; + +// ───────────────────────────────────────────── +// Primitive helpers +// ───────────────────────────────────────────── + +export type LogFn = (line: string) => void; + +export interface ExecOpts { + cwd?: string; + env?: Record; + timeout?: number; + /** Bytes to pipe to stdin before closing it */ + stdinData?: string; + windowsHide?: boolean; +} + +export interface ExecResult { + stdout: string; + stderr: string; + code: number; +} + +export interface CliCheckResult { + ok: boolean; + output?: string; + error?: string; + command: string; +} + +// ───────────────────────────────────────────── +// Gateway +// ───────────────────────────────────────────── + +export type GatewayRunState = 'running' | 'stopped' | 'starting' | 'error' | 'unknown'; + +export interface GatewayStatus { + state: GatewayRunState; + port?: number; + version?: string; + uptime?: string; + error?: string; +} + +// ───────────────────────────────────────────── +// OpenClaw config (openclaw.json) +// ───────────────────────────────────────────── + +export interface OpenClawConfig { + gateway?: { port?: number; [key: string]: unknown }; + [key: string]: unknown; +} + +// ───────────────────────────────────────────── +// Setup params (what home.ts passes to runSetup) +// ───────────────────────────────────────────── + +export interface SetupParams { + provider: string; + apiKey: string; + port: string; +} + +// ───────────────────────────────────────────── +// Add-Host wizard config fields +// ───────────────────────────────────────────── + +export interface ConfigField { + id: string; + label: string; + type: 'text' | 'password' | 'number' | 'select' | 'checkbox'; + placeholder?: string; + required?: boolean; + options?: { label: string; value: string }[]; + defaultValue?: string | number | boolean; +} + +export interface ConfigValidationResult { + valid: boolean; + errors?: { fieldId: string; message: string }[]; +} + +export interface TestResult { + success: boolean; + message: string; + details?: { + openclawInstalled?: boolean; + openclawVersion?: string; + gatewayRunning?: boolean; + os?: string; + hostname?: string; + }; +} + +// ───────────────────────────────────────────── +// Discovery +// ───────────────────────────────────────────── + +export interface DiscoveredHost { + suggestedId: string; + suggestedLabel: string; + connection: HostConnectionConfig; + metadata?: Record; +} + +// ───────────────────────────────────────────── +// Connection configs (stored in hosts.json) +// ───────────────────────────────────────────── + +export interface LocalConnection { + type: 'local'; +} + +export interface DockerConnection { + type: 'docker'; + containerId?: string; + containerLabel?: string; + composeService?: string; + composeFile?: string; + dockerHost?: string; + shell?: string; + portMappings?: { gateway?: number }; +} + +export interface SSHConnection { + type: 'ssh'; + host: string; + port?: number; + user: string; + authMethod: 'key' | 'agent' | 'password'; + keyPath?: string; + passphrase?: boolean; + jumpHost?: string; + gatewayPort?: number; + sshConfigHost?: string; +} + +export interface CloudConnection { + type: 'cloud'; + provider: 'moltpod'; + podId: string; + apiEndpoint?: string; +} + +export type HostConnectionConfig = + | LocalConnection + | DockerConnection + | SSHConnection + | CloudConnection; + +// ───────────────────────────────────────────── +// Host entry (one row in hosts.json) +// ───────────────────────────────────────────── + +export type HostType = 'local' | 'docker' | 'ssh' | 'cloud'; +export type HostStatus = 'online' | 'offline' | 'error' | 'unknown'; + +export interface HostEntry { + id: string; + type: HostType; + label: string; + connection: HostConnectionConfig; + configPath?: string; + default?: boolean; + color?: string; + tags?: string[]; + createdAt: string; + lastConnectedAt?: string; + lastStatus?: HostStatus; + lastError?: string; +} + +// ───────────────────────────────────────────── +// hosts.json root schema +// ───────────────────────────────────────────── + +export interface HostsFile { + version: 1; + activeHostId: string; + hosts: HostEntry[]; +} + +// ───────────────────────────────────────────── +// Per-host cache (~/.occ/hosts/{id}/cache.json) +// ───────────────────────────────────────────── + +export interface HostCache { + lastCheckedAt: string; + gateway: { + running: boolean; + version?: string; + uptime?: string; + port?: number; + }; + agents: { + count: number; + names: string[]; + defaultAgent?: string; + }; + channels: { + count: number; + connected: string[]; + }; + system: { + os?: string; + hostname?: string; + openclawVersion?: string; + nodeVersion?: string; + }; +} + +// ───────────────────────────────────────────── +// HostConnection — what every adapter implements +// ───────────────────────────────────────────── + +export interface HostConnection extends vscode.Disposable { + readonly id: string; + readonly type: HostType; + readonly label: string; + + // ── CLI ── + isCliInstalled(): Promise; + getCliVersion(): Promise; + installCli(onLog: LogFn): Promise; + findOpenClawPath(): Promise; + testOpenClawCli(): Promise; + + // ── Process execution ── + exec(cmd: string, args: string[], opts?: ExecOpts): Promise; + execStream( + cmd: string, + args: string[], + opts: ExecOpts, + onData: LogFn, + onError: LogFn, + ): Promise; + + // ── Filesystem ── + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + exists(path: string): Promise; + mkdir(path: string): Promise; + stat(path: string): Promise<{ size: number; isDirectory: boolean } | null>; + + // ── OpenClaw config ── + readConfig(): Promise; + writeConfig(patch: Partial): Promise; + getConfigPath(): Promise; + + // ── Gateway ── + gatewayHealthCheck(): Promise; + gatewayStart(onLog: LogFn): Promise; + gatewayStop(onLog: LogFn): Promise; + gatewayRestart(onLog: LogFn): Promise; + + // ── Full install+onboard ── + runSetup(params: SetupParams, onLog: LogFn): Promise; + + // ── Environment ── + buildExecEnv(): Record; +} + +// ───────────────────────────────────────────── +// HostAdapter — implemented by each adapter ext +// ───────────────────────────────────────────── + +export interface HostAdapter { + readonly type: HostType; + readonly displayName: string; + readonly icon: vscode.ThemeIcon; + + discover(): Promise; + connect(config: HostConnectionConfig): Promise; + testConnection(config: HostConnectionConfig): Promise; + getConfigFields(): ConfigField[]; + validateConfig(config: HostConnectionConfig): ConfigValidationResult; +} + +// ───────────────────────────────────────────── +// OpenClawCoreAPI — returned from activate() +// Adapter extensions grab it via getExtension().exports +// ───────────────────────────────────────────── + +export interface OpenClawCoreAPI { + readonly version: string; + + registerHostAdapter(adapter: HostAdapter): vscode.Disposable; + + getActiveHost(): HostConnection | undefined; + getHost(id: string): HostConnection | undefined; + getAllHosts(): HostEntry[]; + setActiveHost(id: string): Promise; + + readonly onDidChangeActiveHost: vscode.Event; + readonly onDidChangeHostStatus: vscode.Event<{ hostId: string; status: HostStatus }>; + readonly onDidAddHost: vscode.Event; + readonly onDidRemoveHost: vscode.Event; + + showHostPicker(): Promise; + showAddHostWizard(type?: HostType): Promise; + refreshHost(id: string): Promise; +} From b38bca4a1710879eaeacc9f843348821e9874cc5 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:48:24 -0400 Subject: [PATCH 03/19] feat(multihost): HostRegistry, HostManager, status bar, tree provider, API export - hosts/registry.ts: reads/writes ~/.occ/hosts.json, seeds local default, file-watches for external changes, CRUD for hosts + activeHostId - hosts/manager.ts: implements OpenClawCoreAPI; owns live HostConnection map; registers adapters, connects persisted hosts, drives status events - hosts/statusbar.ts: status bar item showing active host with icon - hosts/tree.ts: Tree view provider listing all hosts (openclaw.hosts view) - extension.ts: bootstraps registry + manager, exports OpenClawCoreAPI from activate(), registers openclaw.pickHost / setActiveHost / refreshHost commands - package.json: adds views (openclaw.hosts in explorer), 3 new commands Co-Authored-By: Claude Sonnet 4.6 --- apps/editor/extensions/openclaw/package.json | 24 ++ .../extensions/openclaw/src/extension.ts | 43 +++- .../extensions/openclaw/src/hosts/manager.ts | 177 +++++++++++++++ .../extensions/openclaw/src/hosts/registry.ts | 213 ++++++++++++++++++ .../openclaw/src/hosts/statusbar.ts | 51 +++++ .../extensions/openclaw/src/hosts/tree.ts | 82 +++++++ 6 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 apps/editor/extensions/openclaw/src/hosts/manager.ts create mode 100644 apps/editor/extensions/openclaw/src/hosts/registry.ts create mode 100644 apps/editor/extensions/openclaw/src/hosts/statusbar.ts create mode 100644 apps/editor/extensions/openclaw/src/hosts/tree.ts diff --git a/apps/editor/extensions/openclaw/package.json b/apps/editor/extensions/openclaw/package.json index a1cd46e4..ff4d92c0 100644 --- a/apps/editor/extensions/openclaw/package.json +++ b/apps/editor/extensions/openclaw/package.json @@ -80,6 +80,18 @@ "when": "true" } ], + "viewsContainers": { + "activitybar": [] + }, + "views": { + "explorer": [ + { + "id": "openclaw.hosts", + "name": "OpenClaw Hosts", + "when": "true" + } + ] + }, "commands": [ { "command": "openclaw.home", @@ -104,6 +116,18 @@ { "command": "openclaw.runWithSudo", "title": "OCC: Run Command with Sudo" + }, + { + "command": "openclaw.pickHost", + "title": "OpenClaw: Switch Host" + }, + { + "command": "openclaw.setActiveHost", + "title": "OpenClaw: Set Active Host" + }, + { + "command": "openclaw.refreshHost", + "title": "OpenClaw: Refresh Host Status" } ] }, diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index 67f318d3..d39f3e48 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -7,6 +7,11 @@ import * as https from 'https'; import { HomePanel } from './panels/home'; import { StatusPanel } from './panels/status'; import { stopConfigProxy, getDashboardUrl } from './panels/config'; +import { HostRegistry } from './hosts/registry'; +import { HostManager } from './hosts/manager'; +import { HostStatusBarItem } from './hosts/statusbar'; +import { HostTreeProvider } from './hosts/tree'; +import type { OpenClawCoreAPI } from './hosts/types'; const DEFAULT_GATEWAY_PORT = 18789; @@ -510,7 +515,40 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => return () => {}; // spend is a no-op — kept so call sites don't break } -export async function activate(context: vscode.ExtensionContext): Promise { +export async function activate(context: vscode.ExtensionContext): Promise { + // ── MultiHost: HostRegistry + HostManager ─────────────────────────────────── + const hostRegistry = new HostRegistry(); + await hostRegistry.init(); + const hostManager = new HostManager(hostRegistry); + context.subscriptions.push(hostRegistry, hostManager); + + // Status bar: shows active host name + const hostStatusBar = new HostStatusBarItem(hostManager); + context.subscriptions.push(hostStatusBar); + + // Tree view: lists all registered hosts + const hostTreeProvider = new HostTreeProvider(hostManager); + const hostTreeView = vscode.window.createTreeView('openclaw.hosts', { + treeDataProvider: hostTreeProvider, + showCollapseAll: false, + }); + context.subscriptions.push(hostTreeView, hostTreeProvider); + + // Host management commands + context.subscriptions.push( + vscode.commands.registerCommand('openclaw.pickHost', async () => { + const id = await hostManager.showHostPicker(); + if (id) { await hostManager.setActiveHost(id); } + }), + vscode.commands.registerCommand('openclaw.setActiveHost', async (id: string) => { + await hostManager.setActiveHost(id); + }), + vscode.commands.registerCommand('openclaw.refreshHost', async () => { + const activeId = hostRegistry.getActiveHostId(); + await hostManager.refreshHost(activeId); + }), + ); + // Inference balance bar (shown at bottom-right, tracks $1.00 free budget). const spendBalance = initBalanceBar(context); @@ -799,6 +837,9 @@ export async function activate(context: vscode.ExtensionContext): Promise setTimeout(() => { HomePanel.createOrShow(context.extensionUri); }, 500); + + // Return OpenClawCoreAPI so adapter extensions can register their adapters. + return hostManager; } export function deactivate() { diff --git a/apps/editor/extensions/openclaw/src/hosts/manager.ts b/apps/editor/extensions/openclaw/src/hosts/manager.ts new file mode 100644 index 00000000..275a3d45 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/manager.ts @@ -0,0 +1,177 @@ +import * as vscode from 'vscode'; +import type { + HostAdapter, + HostConnection, + HostEntry, + HostStatus, + HostType, + OpenClawCoreAPI, +} from './types'; +import { HostRegistry } from './registry'; + +// ───────────────────────────────────────────── +// HostManager +// Owns adapters, live connections, and the registry. +// Implements OpenClawCoreAPI for export to adapter extensions. +// ───────────────────────────────────────────── + +export class HostManager implements OpenClawCoreAPI, vscode.Disposable { + readonly version = '1.0.0'; + + private _adapters = new Map(); + private _connections = new Map(); + private _disposables: vscode.Disposable[] = []; + + private readonly _onDidChangeActiveHost = new vscode.EventEmitter(); + readonly onDidChangeActiveHost: vscode.Event = this._onDidChangeActiveHost.event; + + private readonly _onDidChangeHostStatus = new vscode.EventEmitter<{ hostId: string; status: HostStatus }>(); + readonly onDidChangeHostStatus: vscode.Event<{ hostId: string; status: HostStatus }> = this._onDidChangeHostStatus.event; + + private readonly _onDidAddHost = new vscode.EventEmitter(); + readonly onDidAddHost: vscode.Event = this._onDidAddHost.event; + + private readonly _onDidRemoveHost = new vscode.EventEmitter(); + readonly onDidRemoveHost: vscode.Event = this._onDidRemoveHost.event; + + constructor(private readonly registry: HostRegistry) { + this._disposables.push( + registry.onDidChange(() => this._onRegistryChange()), + ); + } + + // ── Adapter registration ────────────────── + + registerHostAdapter(adapter: HostAdapter): vscode.Disposable { + this._adapters.set(adapter.type, adapter); + // Attempt to connect any persisted hosts of this type + this._connectPersistedHosts(adapter.type); + return new vscode.Disposable(() => { + this._adapters.delete(adapter.type); + }); + } + + getAdapter(type: HostType): HostAdapter | undefined { + return this._adapters.get(type); + } + + // ── Host queries ────────────────────────── + + getActiveHost(): HostConnection | undefined { + const id = this.registry.getActiveHostId(); + return this._connections.get(id); + } + + getHost(id: string): HostConnection | undefined { + return this._connections.get(id); + } + + getAllHosts(): HostEntry[] { + return this.registry.getAllHosts(); + } + + async setActiveHost(id: string): Promise { + const prev = this.registry.getActiveHostId(); + if (prev === id) { return; } + this.registry.setActiveHostId(id); + // Ensure connected + await this._ensureConnected(id); + this._onDidChangeActiveHost.fire(this._connections.get(id)); + } + + // ── Connection management ───────────────── + + private async _connectPersistedHosts(type: HostType): Promise { + const entries = this.registry.getAllHosts().filter(h => h.type === type); + for (const entry of entries) { + await this._ensureConnected(entry.id).catch(() => {/* ignore individual failures */}); + } + } + + private async _ensureConnected(id: string): Promise { + if (this._connections.has(id)) { + return this._connections.get(id); + } + const entry = this.registry.getHost(id); + if (!entry) { return undefined; } + const adapter = this._adapters.get(entry.type); + if (!adapter) { return undefined; } + try { + const conn = await adapter.connect(entry.connection); + this._connections.set(id, conn); + this.registry.touchLastConnected(id); + return conn; + } catch (err) { + this.registry.setHostStatus(id, 'error', String(err)); + this._onDidChangeHostStatus.fire({ hostId: id, status: 'error' }); + return undefined; + } + } + + // ── Wizard / picker ─────────────────────── + + async showHostPicker(): Promise { + const entries = this.registry.getAllHosts(); + const activeId = this.registry.getActiveHostId(); + const items = entries.map(e => ({ + label: e.label, + description: e.type + (e.id === activeId ? ' • active' : ''), + id: e.id, + })); + const pick = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a host', + }); + return pick?.id; + } + + async showAddHostWizard(_type?: HostType): Promise { + // Full wizard lives in the webview panel; this is a lightweight fallback. + vscode.window.showInformationMessage('Use the OpenClaw panel to add hosts.'); + return undefined; + } + + async refreshHost(id: string): Promise { + const conn = this._connections.get(id); + if (!conn) { + await this._ensureConnected(id); + return; + } + try { + const status = await conn.gatewayHealthCheck(); + const hostStatus: HostStatus = + status.state === 'running' ? 'online' : + status.state === 'error' ? 'error' : 'offline'; + this.registry.setHostStatus(id, hostStatus, status.error); + this._onDidChangeHostStatus.fire({ hostId: id, status: hostStatus }); + } catch { + this.registry.setHostStatus(id, 'error', 'Health check failed'); + this._onDidChangeHostStatus.fire({ hostId: id, status: 'error' }); + } + } + + // ── Internal ────────────────────────────── + + private _onRegistryChange(): void { + // Drop connections for removed hosts + for (const [id, conn] of this._connections) { + if (!this.registry.getHost(id)) { + conn.dispose(); + this._connections.delete(id); + } + } + } + + // ── Dispose ─────────────────────────────── + + dispose(): void { + for (const conn of this._connections.values()) { + conn.dispose(); + } + this._connections.clear(); + this._onDidChangeActiveHost.dispose(); + this._onDidChangeHostStatus.dispose(); + this._onDidAddHost.dispose(); + this._onDidRemoveHost.dispose(); + this._disposables.forEach(d => d.dispose()); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/registry.ts b/apps/editor/extensions/openclaw/src/hosts/registry.ts new file mode 100644 index 00000000..b0b2f8a0 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/registry.ts @@ -0,0 +1,213 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { HostEntry, HostsFile, HostType, HostStatus } from './types'; + +// ───────────────────────────────────────────── +// Paths +// ───────────────────────────────────────────── + +export function getOccDir(): string { + return path.join(os.homedir(), '.occ'); +} + +export function getHostsFilePath(): string { + return path.join(getOccDir(), 'hosts.json'); +} + +export function getHostCacheDir(hostId: string): string { + return path.join(getOccDir(), 'hosts', hostId); +} + +export function getHostCachePath(hostId: string): string { + return path.join(getHostCacheDir(hostId), 'cache.json'); +} + +// ───────────────────────────────────────────── +// Default local host seed +// ───────────────────────────────────────────── + +function makeLocalDefaultEntry(): HostEntry { + return { + id: 'local', + type: 'local' as HostType, + label: 'Local', + connection: { type: 'local' }, + default: true, + createdAt: new Date().toISOString(), + }; +} + +function makeEmptyHostsFile(localEntry: HostEntry): HostsFile { + return { + version: 1, + activeHostId: localEntry.id, + hosts: [localEntry], + }; +} + +// ───────────────────────────────────────────── +// HostRegistry +// ───────────────────────────────────────────── + +export class HostRegistry implements vscode.Disposable { + private _hostsFile: HostsFile | undefined; + private _watcher: fs.FSWatcher | undefined; + private _disposables: vscode.Disposable[] = []; + + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; + + // ── Init ────────────────────────────────── + + async init(): Promise { + await this._ensureOccDir(); + await this._loadOrSeed(); + this._startWatching(); + } + + private async _ensureOccDir(): Promise { + const dir = getOccDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const hostsDir = path.join(dir, 'hosts'); + if (!fs.existsSync(hostsDir)) { + fs.mkdirSync(hostsDir, { recursive: true }); + } + } + + private async _loadOrSeed(): Promise { + const filePath = getHostsFilePath(); + if (!fs.existsSync(filePath)) { + const seed = makeEmptyHostsFile(makeLocalDefaultEntry()); + this._hostsFile = seed; + this._persist(); + } else { + this._hostsFile = this._readFromDisk(); + // Ensure local default is always present + if (!this._hostsFile.hosts.find(h => h.id === 'local')) { + this._hostsFile.hosts.unshift(makeLocalDefaultEntry()); + if (!this._hostsFile.activeHostId) { + this._hostsFile.activeHostId = 'local'; + } + this._persist(); + } + } + } + + private _readFromDisk(): HostsFile { + try { + const raw = fs.readFileSync(getHostsFilePath(), 'utf-8'); + return JSON.parse(raw) as HostsFile; + } catch { + return makeEmptyHostsFile(makeLocalDefaultEntry()); + } + } + + private _persist(): void { + try { + fs.writeFileSync( + getHostsFilePath(), + JSON.stringify(this._hostsFile, null, 2), + 'utf-8', + ); + } catch (err) { + console.error('[HostRegistry] Failed to persist hosts.json:', err); + } + } + + private _startWatching(): void { + const filePath = getHostsFilePath(); + try { + this._watcher = fs.watch(filePath, (_event) => { + const fresh = this._readFromDisk(); + this._hostsFile = fresh; + this._onDidChange.fire(); + }); + } catch { + // File watcher is best-effort + } + } + + // ── Read ────────────────────────────────── + + getAllHosts(): HostEntry[] { + return this._hostsFile?.hosts ?? []; + } + + getHost(id: string): HostEntry | undefined { + return this._hostsFile?.hosts.find(h => h.id === id); + } + + getActiveHostId(): string { + return this._hostsFile?.activeHostId ?? 'local'; + } + + // ── Write ───────────────────────────────── + + addHost(entry: HostEntry): void { + if (!this._hostsFile) { return; } + // Remove any existing entry with same id + this._hostsFile.hosts = this._hostsFile.hosts.filter(h => h.id !== entry.id); + this._hostsFile.hosts.push(entry); + this._persist(); + this._onDidChange.fire(); + } + + updateHost(id: string, patch: Partial): void { + if (!this._hostsFile) { return; } + const idx = this._hostsFile.hosts.findIndex(h => h.id === id); + if (idx === -1) { return; } + this._hostsFile.hosts[idx] = { ...this._hostsFile.hosts[idx], ...patch }; + this._persist(); + this._onDidChange.fire(); + } + + removeHost(id: string): void { + if (!this._hostsFile || id === 'local') { return; } // local is permanent + this._hostsFile.hosts = this._hostsFile.hosts.filter(h => h.id !== id); + if (this._hostsFile.activeHostId === id) { + this._hostsFile.activeHostId = 'local'; + } + this._persist(); + this._onDidChange.fire(); + } + + setActiveHostId(id: string): void { + if (!this._hostsFile) { return; } + if (!this._hostsFile.hosts.find(h => h.id === id)) { return; } + this._hostsFile.activeHostId = id; + this._persist(); + this._onDidChange.fire(); + } + + setHostStatus(id: string, status: HostStatus, error?: string): void { + this.updateHost(id, { + lastStatus: status, + lastError: error, + }); + } + + touchLastConnected(id: string): void { + this.updateHost(id, { lastConnectedAt: new Date().toISOString() }); + } + + // ── Host cache dir ──────────────────────── + + ensureHostCacheDir(hostId: string): void { + const dir = getHostCacheDir(hostId); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + + // ── Dispose ─────────────────────────────── + + dispose(): void { + this._watcher?.close(); + this._onDidChange.dispose(); + this._disposables.forEach(d => d.dispose()); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/statusbar.ts b/apps/editor/extensions/openclaw/src/hosts/statusbar.ts new file mode 100644 index 00000000..5e3737b0 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/statusbar.ts @@ -0,0 +1,51 @@ +import * as vscode from 'vscode'; +import type { HostManager } from './manager'; +import type { HostEntry } from './types'; + +// ───────────────────────────────────────────── +// HostStatusBarItem +// Shows the active host name in the status bar. +// Click → host picker quick-pick. +// ───────────────────────────────────────────── + +export class HostStatusBarItem implements vscode.Disposable { + private readonly _item: vscode.StatusBarItem; + private _disposables: vscode.Disposable[] = []; + + constructor(private readonly manager: HostManager) { + this._item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + this._item.command = 'openclaw.pickHost'; + this._item.tooltip = 'OpenClaw: active host — click to switch'; + this._disposables.push(this._item); + + this._disposables.push( + manager.onDidChangeActiveHost(() => this._refresh()), + manager.onDidChangeHostStatus(() => this._refresh()), + ); + + this._refresh(); + this._item.show(); + } + + private _refresh(): void { + const activeId = (this.manager as any).registry?.getActiveHostId?.() ?? 'local'; + const hosts: HostEntry[] = this.manager.getAllHosts(); + const entry = hosts.find(h => h.id === activeId); + const label = entry?.label ?? 'Local'; + const status = entry?.lastStatus; + + const icon = status === 'online' ? '$(vm-active)' + : status === 'error' ? '$(vm-connect)' + : status === 'offline' ? '$(vm)' + : '$(vm)'; + + this._item.text = `${icon} ${label}`; + } + + dispose(): void { + this._disposables.forEach(d => d.dispose()); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/tree.ts b/apps/editor/extensions/openclaw/src/hosts/tree.ts new file mode 100644 index 00000000..9e2016af --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/tree.ts @@ -0,0 +1,82 @@ +import * as vscode from 'vscode'; +import type { HostManager } from './manager'; +import type { HostEntry } from './types'; + +// ───────────────────────────────────────────── +// HostTreeItem +// ───────────────────────────────────────────── + +class HostTreeItem extends vscode.TreeItem { + constructor(readonly entry: HostEntry, isActive: boolean) { + super(entry.label, vscode.TreeItemCollapsibleState.None); + this.id = `host-${entry.id}`; + this.contextValue = `host-${entry.type}`; + this.description = entry.type; + + const status = entry.lastStatus; + this.iconPath = new vscode.ThemeIcon( + status === 'online' ? 'vm-active' + : status === 'error' ? 'vm-connect' + : 'vm', + isActive ? new vscode.ThemeColor('statusBarItem.prominentBackground') : undefined, + ); + + if (isActive) { + this.label = `${entry.label} ●`; + } + + this.command = { + command: 'openclaw.setActiveHost', + title: 'Switch to host', + arguments: [entry.id], + }; + + this.tooltip = new vscode.MarkdownString( + `**${entry.label}** (${entry.type})\n\n` + + (entry.lastStatus ? `Status: ${entry.lastStatus}\n` : '') + + (entry.lastConnectedAt ? `Last connected: ${entry.lastConnectedAt}` : ''), + ); + } +} + +// ───────────────────────────────────────────── +// HostTreeProvider +// ───────────────────────────────────────────── + +export class HostTreeProvider + implements vscode.TreeDataProvider, vscode.Disposable +{ + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = + this._onDidChangeTreeData.event; + + private _disposables: vscode.Disposable[] = []; + + constructor(private readonly manager: HostManager) { + this._disposables.push( + manager.onDidChangeActiveHost(() => this._onDidChangeTreeData.fire()), + manager.onDidChangeHostStatus(() => this._onDidChangeTreeData.fire()), + manager.onDidAddHost(() => this._onDidChangeTreeData.fire()), + manager.onDidRemoveHost(() => this._onDidChangeTreeData.fire()), + ); + } + + getTreeItem(element: HostTreeItem): vscode.TreeItem { + return element; + } + + getChildren(_element?: HostTreeItem): HostTreeItem[] { + const entries = this.manager.getAllHosts(); + const activeId = (this.manager as any).registry?.getActiveHostId?.() ?? 'local'; + return entries.map(e => new HostTreeItem(e, e.id === activeId)); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + dispose(): void { + this._onDidChangeTreeData.dispose(); + this._disposables.forEach(d => d.dispose()); + } +} From e342e891bbf5a8f89c5eab81a4be25c8bdd147de Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:50:08 -0400 Subject: [PATCH 04/19] =?UTF-8?q?feat(multihost):=20openclaw-local=20exten?= =?UTF-8?q?sion=20=E2=80=94=20LocalHostAdapter=20+=20LocalHostConnection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalHostConnection implements full HostConnection interface for the local machine: - exec / execStream: child_process.spawn with full opts support - Filesystem: readFile/writeFile/exists/mkdir/stat via Node fs - CLI detection: user config → which/where → well-known paths (darwin/win32/linux) - installCli: curl|bash on unix, irm|iex on windows - readConfig/writeConfig: ~/.openclaw/openclaw.json - gatewayHealthCheck: `openclaw gateway status --json`, falls back to string parse - gatewayStart/Stop/Restart, runSetup (openclaw onboard) LocalHostAdapter: discovers one local host, testConnection returns CLI + gateway state. openclaw-local/extension.ts: grabs OpenClawCoreAPI from openclaw.home exports, registers LocalHostAdapter, adds disposable to subscriptions. tsconfig.json: removed rootDir on all three adapter extensions so cross-extension relative imports (../../openclaw/src/hosts/types) compile correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw-docker/tsconfig.json | 2 +- .../extensions/openclaw-local/src/adapter.ts | 70 ++++ .../openclaw-local/src/connection.ts | 370 ++++++++++++++++++ .../openclaw-local/src/extension.ts | 31 ++ .../extensions/openclaw-local/tsconfig.json | 2 +- .../extensions/openclaw-ssh/tsconfig.json | 2 +- 6 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 apps/editor/extensions/openclaw-local/src/adapter.ts create mode 100644 apps/editor/extensions/openclaw-local/src/connection.ts create mode 100644 apps/editor/extensions/openclaw-local/src/extension.ts diff --git a/apps/editor/extensions/openclaw-docker/tsconfig.json b/apps/editor/extensions/openclaw-docker/tsconfig.json index a358ee24..c8613a95 100644 --- a/apps/editor/extensions/openclaw-docker/tsconfig.json +++ b/apps/editor/extensions/openclaw-docker/tsconfig.json @@ -4,11 +4,11 @@ "target": "ES2020", "lib": ["ES2020"], "outDir": "./out", - "rootDir": "./src", "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true }, + "include": ["src/**/*"], "exclude": ["node_modules", ".vscode-test"] } diff --git a/apps/editor/extensions/openclaw-local/src/adapter.ts b/apps/editor/extensions/openclaw-local/src/adapter.ts new file mode 100644 index 00000000..601fabcf --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/adapter.ts @@ -0,0 +1,70 @@ +import * as vscode from 'vscode'; +import type { + HostAdapter, + HostType, + HostConnection, + HostConnectionConfig, + LocalConnection, + TestResult, + DiscoveredHost, + ConfigField, + ConfigValidationResult, +} from '../../openclaw/src/hosts/types'; +import { LocalHostConnection } from './connection'; + +// ───────────────────────────────────────────── +// LocalHostAdapter +// ───────────────────────────────────────────── + +export class LocalHostAdapter implements HostAdapter { + readonly type: HostType = 'local'; + readonly displayName = 'Local Machine'; + readonly icon = new vscode.ThemeIcon('device-desktop'); + + /** Always discovers exactly one local host. */ + async discover(): Promise { + return [ + { + suggestedId: 'local', + suggestedLabel: 'Local', + connection: { type: 'local' } as LocalConnection, + }, + ]; + } + + async connect(_config: HostConnectionConfig): Promise { + return new LocalHostConnection(); + } + + async testConnection(_config: HostConnectionConfig): Promise { + const conn = new LocalHostConnection(); + try { + const cliCheck = await conn.testOpenClawCli(); + const gwStatus = cliCheck.ok ? await conn.gatewayHealthCheck() : undefined; + return { + success: cliCheck.ok, + message: cliCheck.ok + ? `OpenClaw CLI found (${cliCheck.output ?? ''})` + : `OpenClaw CLI not found: ${cliCheck.error ?? 'unknown'}`, + details: { + openclawInstalled: cliCheck.ok, + openclawVersion: cliCheck.output, + gatewayRunning: gwStatus?.state === 'running', + os: process.platform, + hostname: require('os').hostname(), + }, + }; + } finally { + conn.dispose(); + } + } + + getConfigFields(): ConfigField[] { + // Local needs no config fields + return []; + } + + validateConfig(_config: HostConnectionConfig): ConfigValidationResult { + return { valid: true }; + } +} diff --git a/apps/editor/extensions/openclaw-local/src/connection.ts b/apps/editor/extensions/openclaw-local/src/connection.ts new file mode 100644 index 00000000..de5ee339 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/connection.ts @@ -0,0 +1,370 @@ +import * as vscode from 'vscode'; +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + GatewayRunState, + OpenClawConfig, + SetupParams, +} from '../../openclaw/src/hosts/types'; + +// ───────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────── + +function platformOpenClawPaths(): string[] { + switch (process.platform) { + case 'win32': + return [ + path.join(process.env['LOCALAPPDATA'] ?? 'C:\\Users\\Default\\AppData\\Local', 'Programs', 'openclaw', 'openclaw.exe'), + path.join(process.env['PROGRAMFILES'] ?? 'C:\\Program Files', 'openclaw', 'openclaw.exe'), + 'C:\\openclaw\\openclaw.exe', + ]; + case 'darwin': + return [ + '/usr/local/bin/openclaw', + '/opt/homebrew/bin/openclaw', + path.join(os.homedir(), '.local', 'bin', 'openclaw'), + '/usr/bin/openclaw', + ]; + default: // linux + return [ + '/usr/local/bin/openclaw', + path.join(os.homedir(), '.local', 'bin', 'openclaw'), + '/usr/bin/openclaw', + '/snap/bin/openclaw', + ]; + } +} + +function openClawConfigDir(): string { + return path.join(os.homedir(), '.openclaw'); +} + +function openClawConfigFile(): string { + return path.join(openClawConfigDir(), 'openclaw.json'); +} + +// ───────────────────────────────────────────── +// LocalHostConnection +// ───────────────────────────────────────────── + +export class LocalHostConnection implements HostConnection { + readonly id = 'local'; + readonly type: HostType = 'local'; + readonly label = 'Local'; + + private _disposed = false; + + // ── vscode.Disposable ───────────────────── + + dispose(): void { + this._disposed = true; + } + + // ── Process execution ───────────────────── + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + + if (opts.stdinData !== undefined) { + proc.stdin.write(opts.stdinData); + proc.stdin.end(); + } + + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + + proc.on('error', reject); + proc.on('close', code => { + resolve({ stdout, stderr, code: code ?? -1 }); + }); + }); + } + + execStream( + cmd: string, + args: string[], + opts: ExecOpts, + onData: LogFn, + onError: LogFn, + ): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + + if (opts.stdinData !== undefined) { + proc.stdin.write(opts.stdinData); + proc.stdin.end(); + } + + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + // ── Filesystem ──────────────────────────── + + readFile(filePath: string): Promise { + return Promise.resolve(fs.readFileSync(filePath, 'utf-8')); + } + + writeFile(filePath: string, content: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); + return Promise.resolve(); + } + + exists(filePath: string): Promise { + return Promise.resolve(fs.existsSync(filePath)); + } + + mkdir(dirPath: string): Promise { + fs.mkdirSync(dirPath, { recursive: true }); + return Promise.resolve(); + } + + stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + try { + const s = fs.statSync(filePath); + return Promise.resolve({ size: s.size, isDirectory: s.isDirectory() }); + } catch { + return Promise.resolve(null); + } + } + + // ── CLI ─────────────────────────────────── + + async findOpenClawPath(): Promise { + // 1. User-configured path + const configuredPath = vscode.workspace.getConfiguration('openclaw').get('cliPath'); + if (configuredPath && fs.existsSync(configuredPath)) { + return configuredPath; + } + + // 2. $PATH via `which` / `where` + try { + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + const result = await this.exec(whichCmd, ['openclaw'], { timeout: 5000 }); + const found = result.stdout.trim().split('\n')[0]; + if (found && fs.existsSync(found)) { return found; } + } catch { /* ignore */ } + + // 3. Well-known platform paths + for (const p of platformOpenClawPaths()) { + if (fs.existsSync(p)) { return p; } + } + + return undefined; + } + + async isCliInstalled(): Promise { + const p = await this.findOpenClawPath(); + return p !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const result = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (result.stdout + result.stderr).trim(); + const match = out.match(/[\d]+\.[\d]+\.[\d]+/); + return match ? match[0] : out || null; + } catch { + return null; + } + } + + async testOpenClawCli(): Promise { + const p = await this.findOpenClawPath(); + const command = p ?? 'openclaw'; + if (!p) { + return { ok: false, command, error: 'OpenClaw CLI not found' }; + } + try { + const result = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (result.stdout + result.stderr).trim(); + if (result.code === 0) { + return { ok: true, command, output: out }; + } else { + return { ok: false, command, error: `Exit code ${result.code}: ${out}` }; + } + } catch (err) { + return { ok: false, command, error: String(err) }; + } + } + + async installCli(onLog: LogFn): Promise { + // Platform-specific install script + switch (process.platform) { + case 'darwin': + case 'linux': + await this._installUnix(onLog); + break; + case 'win32': + await this._installWindows(onLog); + break; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } + } + + private async _installUnix(onLog: LogFn): Promise { + onLog('Downloading OpenClaw installer...\n'); + // Use curl to pipe the official install script + const code = await this.execStream( + 'bash', + ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], + { timeout: 120_000 }, + onLog, + onLog, + ); + if (code !== 0) { + throw new Error(`Installer exited with code ${code}`); + } + onLog('OpenClaw installed.\n'); + } + + private async _installWindows(onLog: LogFn): Promise { + onLog('Downloading OpenClaw installer for Windows...\n'); + const code = await this.execStream( + 'powershell', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + 'irm https://get.openclaw.sh/win | iex'], + { timeout: 120_000, windowsHide: true }, + onLog, + onLog, + ); + if (code !== 0) { + throw new Error(`Installer exited with code ${code}`); + } + onLog('OpenClaw installed.\n'); + } + + // ── OpenClaw config ─────────────────────── + + async getConfigPath(): Promise { + return openClawConfigFile(); + } + + async readConfig(): Promise { + const cfgPath = openClawConfigFile(); + if (!fs.existsSync(cfgPath)) { return {}; } + try { + return JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as OpenClawConfig; + } catch { + return {}; + } + } + + async writeConfig(patch: Partial): Promise { + const cfgPath = openClawConfigFile(); + fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); + let existing: OpenClawConfig = {}; + if (fs.existsSync(cfgPath)) { + try { existing = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as OpenClawConfig; } catch { /* ok */ } + } + const merged = { ...existing, ...patch }; + fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf-8'); + } + + // ── Gateway ─────────────────────────────── + + async gatewayHealthCheck(): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { + return { state: 'unknown' as GatewayRunState, error: 'CLI not installed' }; + } + + try { + const result = await this.exec(cliPath, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (result.code === 0) { + try { + const parsed = JSON.parse(result.stdout) as Partial; + return { + state: parsed.state ?? 'unknown', + port: parsed.port, + version: parsed.version, + uptime: parsed.uptime, + }; + } catch { + // If not JSON, treat non-zero stderr as error + const out = (result.stdout + result.stderr).toLowerCase(); + const running = out.includes('running') || out.includes('started'); + return { state: running ? 'running' : 'stopped' }; + } + } + return { state: 'stopped' as GatewayRunState }; + } catch { + return { state: 'error' as GatewayRunState, error: 'Health check failed' }; + } + } + + async gatewayStart(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited with code ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited with code ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } + } + + // ── Full install + onboard ──────────────── + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed — call installCli first'); } + + onLog(`Setting up OpenClaw with provider=${params.provider}, port=${params.port}\n`); + + const args = ['onboard', + '--provider', params.provider, + '--api-key', params.apiKey, + '--port', params.port, + ]; + + const code = await this.execStream(cliPath, args, {}, onLog, onLog); + if (code !== 0) { throw new Error(`onboard exited with code ${code}`); } + } + + // ── Environment ─────────────────────────── + + buildExecEnv(): Record { + return { ...process.env }; + } +} diff --git a/apps/editor/extensions/openclaw-local/src/extension.ts b/apps/editor/extensions/openclaw-local/src/extension.ts new file mode 100644 index 00000000..e0619b15 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/extension.ts @@ -0,0 +1,31 @@ +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { LocalHostAdapter } from './adapter'; + +export async function activate(context: vscode.ExtensionContext): Promise { + // Grab the core API exported by the openclaw.home extension + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (!coreExt) { + console.warn('[openclaw-local] Core extension openclaw.home not found — local adapter not registered'); + return; + } + + const coreAPI = coreExt.isActive + ? coreExt.exports + : await coreExt.activate(); + + if (!coreAPI || typeof coreAPI.registerHostAdapter !== 'function') { + console.warn('[openclaw-local] Core extension did not export OpenClawCoreAPI — local adapter not registered'); + return; + } + + const adapter = new LocalHostAdapter(); + const disposable = coreAPI.registerHostAdapter(adapter); + context.subscriptions.push(disposable); + + console.log('[openclaw-local] LocalHostAdapter registered'); +} + +export function deactivate(): void { + // Subscriptions cleaned up automatically +} diff --git a/apps/editor/extensions/openclaw-local/tsconfig.json b/apps/editor/extensions/openclaw-local/tsconfig.json index a358ee24..c8613a95 100644 --- a/apps/editor/extensions/openclaw-local/tsconfig.json +++ b/apps/editor/extensions/openclaw-local/tsconfig.json @@ -4,11 +4,11 @@ "target": "ES2020", "lib": ["ES2020"], "outDir": "./out", - "rootDir": "./src", "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true }, + "include": ["src/**/*"], "exclude": ["node_modules", ".vscode-test"] } diff --git a/apps/editor/extensions/openclaw-ssh/tsconfig.json b/apps/editor/extensions/openclaw-ssh/tsconfig.json index a358ee24..c8613a95 100644 --- a/apps/editor/extensions/openclaw-ssh/tsconfig.json +++ b/apps/editor/extensions/openclaw-ssh/tsconfig.json @@ -4,11 +4,11 @@ "target": "ES2020", "lib": ["ES2020"], "outDir": "./out", - "rootDir": "./src", "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true }, + "include": ["src/**/*"], "exclude": ["node_modules", ".vscode-test"] } From 1f0995125bf7b287a2448966a758f54c2c0ef041 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:52:40 -0400 Subject: [PATCH 05/19] feat(multihost): openclaw-docker extension + openclaw-ssh stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openclaw-docker: - preflight.ts: three-state Docker health check (cli_missing / daemon_down / permission_denied) with platform-specific remedies for darwin (OrbStack), win32 (Docker Desktop + WSL2), linux (systemd + apt/dnf) - connection.ts: DockerHostConnection — all HostConnection methods via `docker exec`; writeFile via stdin+tee; portMapping aware gatewayHealthCheck - adapter.ts: DockerHostAdapter — discovers running containers via `docker ps`; resolves containerId from label or compose service; testConnection runs preflight + CLI check inside container; full getConfigFields / validateConfig - extension.ts: registers DockerHostAdapter against openclaw.home exports openclaw-ssh: - src/extension.ts: activation stub (logs "coming soon", no-op) All four extensions compile cleanly with zero TypeScript errors. Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw-docker/src/adapter.ts | 219 ++++++++++++ .../openclaw-docker/src/connection.ts | 324 ++++++++++++++++++ .../openclaw-docker/src/extension.ts | 30 ++ .../openclaw-docker/src/preflight.ts | 190 ++++++++++ .../extensions/openclaw-ssh/src/extension.ts | 13 + 5 files changed, 776 insertions(+) create mode 100644 apps/editor/extensions/openclaw-docker/src/adapter.ts create mode 100644 apps/editor/extensions/openclaw-docker/src/connection.ts create mode 100644 apps/editor/extensions/openclaw-docker/src/extension.ts create mode 100644 apps/editor/extensions/openclaw-docker/src/preflight.ts create mode 100644 apps/editor/extensions/openclaw-ssh/src/extension.ts diff --git a/apps/editor/extensions/openclaw-docker/src/adapter.ts b/apps/editor/extensions/openclaw-docker/src/adapter.ts new file mode 100644 index 00000000..06262f4e --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/adapter.ts @@ -0,0 +1,219 @@ +import * as vscode from 'vscode'; +import * as cp from 'child_process'; +import type { + HostAdapter, + HostType, + HostConnection, + HostConnectionConfig, + DockerConnection, + TestResult, + DiscoveredHost, + ConfigField, + ConfigValidationResult, +} from '../../openclaw/src/hosts/types'; +import { DockerHostConnection } from './connection'; +import { dockerPreflight } from './preflight'; + +// ───────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────── + +function listRunningContainers(): Array<{ id: string; name: string; image: string }> { + try { + const result = cp.spawnSync( + 'docker', + ['ps', '--format', '{{.ID}}\t{{.Names}}\t{{.Image}}'], + { timeout: 6000, windowsHide: true }, + ); + if (result.status !== 0) { return []; } + return result.stdout + .toString() + .trim() + .split('\n') + .filter(Boolean) + .map(line => { + const [id, name, image] = line.split('\t'); + return { id: id ?? '', name: name ?? '', image: image ?? '' }; + }); + } catch { + return []; + } +} + +async function resolveContainerId(config: DockerConnection): Promise { + if (config.containerId) { return config.containerId; } + + if (config.containerLabel) { + const result = cp.spawnSync( + 'docker', + ['ps', '--filter', `name=${config.containerLabel}`, '--format', '{{.ID}}'], + { timeout: 6000, windowsHide: true }, + ); + if (result.status === 0) { + const id = result.stdout.toString().trim().split('\n')[0]; + if (id) { return id; } + } + } + + if (config.composeService) { + const args = config.composeFile + ? ['-f', config.composeFile, 'ps', '-q', config.composeService] + : ['ps', '-q', config.composeService]; + const result = cp.spawnSync('docker', ['compose', ...args], { + timeout: 6000, + windowsHide: true, + }); + if (result.status === 0) { + const id = result.stdout.toString().trim().split('\n')[0]; + if (id) { return id; } + } + } + + throw new Error( + 'Could not resolve Docker container ID. Specify containerId, containerLabel, or composeService.', + ); +} + +// ───────────────────────────────────────────── +// DockerHostAdapter +// ───────────────────────────────────────────── + +export class DockerHostAdapter implements HostAdapter { + readonly type: HostType = 'docker'; + readonly displayName = 'Docker Container'; + readonly icon = new vscode.ThemeIcon('package'); + + async discover(): Promise { + const preflight = await dockerPreflight(); + if (!preflight.ok) { return []; } + + return listRunningContainers().map(c => ({ + suggestedId: `docker:${c.id.slice(0, 12)}`, + suggestedLabel: c.name || c.id.slice(0, 12), + connection: { + type: 'docker', + containerId: c.id, + containerLabel: c.name, + } as DockerConnection, + metadata: { image: c.image }, + })); + } + + async connect(config: HostConnectionConfig): Promise { + const dockerConfig = config as DockerConnection; + + // Run preflight before attempting connection + const preflight = await dockerPreflight(); + if (!preflight.ok) { + throw new Error(preflight.remedy); + } + + const containerId = await resolveContainerId(dockerConfig); + return new DockerHostConnection(dockerConfig, containerId); + } + + async testConnection(config: HostConnectionConfig): Promise { + const dockerConfig = config as DockerConnection; + + // 1. Docker daemon preflight + const preflight = await dockerPreflight(); + if (!preflight.ok) { + return { + success: false, + message: preflight.remedy, + details: { os: process.platform }, + }; + } + + // 2. Resolve container + let containerId: string; + try { + containerId = await resolveContainerId(dockerConfig); + } catch (err) { + return { + success: false, + message: String(err), + details: { os: process.platform }, + }; + } + + // 3. Test CLI inside container + const conn = new DockerHostConnection(dockerConfig, containerId); + try { + const cliCheck = await conn.testOpenClawCli(); + const gwStatus = cliCheck.ok ? await conn.gatewayHealthCheck() : undefined; + return { + success: cliCheck.ok, + message: cliCheck.ok + ? `OpenClaw CLI found in container ${containerId.slice(0, 12)} (${cliCheck.output ?? ''})` + : `Container ${containerId.slice(0, 12)}: ${cliCheck.error ?? 'CLI not found'}`, + details: { + openclawInstalled: cliCheck.ok, + openclawVersion: cliCheck.output, + gatewayRunning: gwStatus?.state === 'running', + os: process.platform, + hostname: containerId.slice(0, 12), + }, + }; + } finally { + conn.dispose(); + } + } + + getConfigFields(): ConfigField[] { + return [ + { + id: 'containerId', + label: 'Container ID or Name', + type: 'text', + placeholder: 'e.g. my-openclaw or abc123def456', + required: false, + }, + { + id: 'composeService', + label: 'Docker Compose Service', + type: 'text', + placeholder: 'e.g. openclaw (from docker-compose.yml)', + required: false, + }, + { + id: 'composeFile', + label: 'Compose File Path', + type: 'text', + placeholder: 'Leave blank for default docker-compose.yml', + required: false, + }, + { + id: 'dockerHost', + label: 'Docker Host (DOCKER_HOST)', + type: 'text', + placeholder: 'Leave blank for local socket', + required: false, + }, + { + id: 'shell', + label: 'Shell inside container', + type: 'text', + placeholder: '/bin/sh (default) or /bin/bash', + defaultValue: '/bin/sh', + required: false, + }, + ]; + } + + validateConfig(config: HostConnectionConfig): ConfigValidationResult { + const dockerConfig = config as DockerConnection; + if (!dockerConfig.containerId && !dockerConfig.containerLabel && !dockerConfig.composeService) { + return { + valid: false, + errors: [ + { + fieldId: 'containerId', + message: 'Provide at least one of: Container ID/Name or Compose Service.', + }, + ], + }; + } + return { valid: true }; + } +} diff --git a/apps/editor/extensions/openclaw-docker/src/connection.ts b/apps/editor/extensions/openclaw-docker/src/connection.ts new file mode 100644 index 00000000..c85fc7c5 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/connection.ts @@ -0,0 +1,324 @@ +import * as vscode from 'vscode'; +import * as cp from 'child_process'; +import * as os from 'os'; +import * as path from 'path'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + GatewayRunState, + OpenClawConfig, + SetupParams, + DockerConnection, +} from '../../openclaw/src/hosts/types'; + +// ───────────────────────────────────────────── +// DockerHostConnection +// Runs all commands inside a Docker container via `docker exec`. +// ───────────────────────────────────────────── + +export class DockerHostConnection implements HostConnection { + readonly type: HostType = 'docker'; + + get id(): string { return `docker:${this._containerId}`; } + get label(): string { return this._config.containerLabel ?? this._containerId; } + + private _containerId: string; + + constructor( + private readonly _config: DockerConnection, + resolvedContainerId: string, + ) { + this._containerId = resolvedContainerId; + } + + // ── vscode.Disposable ───────────────────── + + dispose(): void { + // No persistent resources to release + } + + // ── docker exec helper ──────────────────── + + private _dockerArgs(cmd: string, args: string[]): string[] { + const dockerExecArgs = ['exec', this._containerId]; + if (this._config.shell) { + return [...dockerExecArgs, this._config.shell, '-c', [cmd, ...args.map(a => `'${a.replace(/'/g, "'\\''")}'`)].join(' ')]; + } + return [...dockerExecArgs, cmd, ...args]; + } + + private _dockerHost(): string[] { + if (this._config.dockerHost) { + return ['-H', this._config.dockerHost]; + } + return []; + } + + // ── Process execution ───────────────────── + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + return new Promise((resolve, reject) => { + const fullArgs = [...this._dockerHost(), ...this._dockerArgs(cmd, args)]; + const proc = cp.spawn('docker', fullArgs, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + + if (opts.stdinData !== undefined) { + proc.stdin.write(opts.stdinData); + proc.stdin.end(); + } + + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', reject); + proc.on('close', code => resolve({ stdout, stderr, code: code ?? -1 })); + }); + } + + execStream( + cmd: string, + args: string[], + opts: ExecOpts, + onData: LogFn, + onError: LogFn, + ): Promise { + return new Promise((resolve, reject) => { + const fullArgs = [...this._dockerHost(), ...this._dockerArgs(cmd, args)]; + const proc = cp.spawn('docker', fullArgs, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + + if (opts.stdinData !== undefined) { + proc.stdin.write(opts.stdinData); + proc.stdin.end(); + } + + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + // ── Filesystem (via docker exec cat / tee / stat) ────────────────────────── + + async readFile(filePath: string): Promise { + const result = await this.exec('cat', [filePath]); + if (result.code !== 0) { + throw new Error(`cat ${filePath} failed: ${result.stderr}`); + } + return result.stdout; + } + + async writeFile(filePath: string, content: string): Promise { + const dir = path.posix.dirname(filePath); + await this.exec('mkdir', ['-p', dir]); + // Pipe content via stdin to tee + await new Promise((resolve, reject) => { + const fullArgs = [...this._dockerHost(), 'exec', '-i', this._containerId, 'tee', filePath]; + const proc = cp.spawn('docker', fullArgs, { windowsHide: true }); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('error', reject); + proc.on('close', code => { + if (code === 0) { resolve(); } + else { reject(new Error(`tee ${filePath} failed with code ${code}`)); } + }); + }); + } + + async exists(filePath: string): Promise { + const result = await this.exec('test', ['-e', filePath]); + return result.code === 0; + } + + async mkdir(dirPath: string): Promise { + await this.exec('mkdir', ['-p', dirPath]); + } + + async stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + const result = await this.exec('stat', ['-c', '%s %F', filePath]); + if (result.code !== 0) { return null; } + const [sizeStr, type] = result.stdout.trim().split(' '); + return { + size: parseInt(sizeStr ?? '0', 10), + isDirectory: (type ?? '').includes('directory'), + }; + } + + // ── CLI ─────────────────────────────────── + + async findOpenClawPath(): Promise { + const result = await this.exec('which', ['openclaw']); + if (result.code === 0) { + const p = result.stdout.trim().split('\n')[0]; + if (p) { return p; } + } + // Well-known paths inside the container + for (const p of ['/usr/local/bin/openclaw', '/usr/bin/openclaw', `${os.homedir()}/.local/bin/openclaw`]) { + if (await this.exists(p)) { return p; } + } + return undefined; + } + + async isCliInstalled(): Promise { + return (await this.findOpenClawPath()) !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const result = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (result.stdout + result.stderr).trim(); + const match = out.match(/[\d]+\.[\d]+\.[\d]+/); + return match ? match[0] : out || null; + } catch { + return null; + } + } + + async testOpenClawCli(): Promise { + const p = await this.findOpenClawPath(); + const command = p ? `docker exec ${this._containerId} ${p} --version` : 'openclaw (not found)'; + if (!p) { + return { ok: false, command, error: 'OpenClaw CLI not found inside container' }; + } + try { + const result = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (result.stdout + result.stderr).trim(); + if (result.code === 0) { + return { ok: true, command, output: out }; + } + return { ok: false, command, error: `Exit code ${result.code}: ${out}` }; + } catch (err) { + return { ok: false, command, error: String(err) }; + } + } + + async installCli(onLog: LogFn): Promise { + onLog('Installing OpenClaw inside container...\n'); + const code = await this.execStream( + 'bash', + ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], + { timeout: 120_000 }, + onLog, + onLog, + ); + if (code !== 0) { + throw new Error(`Installer exited with code ${code}`); + } + onLog('OpenClaw installed in container.\n'); + } + + // ── OpenClaw config ─────────────────────── + + async getConfigPath(): Promise { + return '/root/.openclaw/openclaw.json'; // containers typically run as root + } + + async readConfig(): Promise { + const cfgPath = await this.getConfigPath(); + if (!(await this.exists(cfgPath))) { return {}; } + try { + const raw = await this.readFile(cfgPath); + return JSON.parse(raw) as OpenClawConfig; + } catch { + return {}; + } + } + + async writeConfig(patch: Partial): Promise { + const cfgPath = await this.getConfigPath(); + let existing: OpenClawConfig = {}; + if (await this.exists(cfgPath)) { + try { existing = JSON.parse(await this.readFile(cfgPath)) as OpenClawConfig; } catch { /* ok */ } + } + const merged = { ...existing, ...patch }; + await this.writeFile(cfgPath, JSON.stringify(merged, null, 2)); + } + + // ── Gateway ─────────────────────────────── + + async gatewayHealthCheck(): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { + return { state: 'unknown' as GatewayRunState, error: 'CLI not installed' }; + } + try { + const result = await this.exec(cliPath, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (result.code === 0) { + try { + const parsed = JSON.parse(result.stdout) as Partial; + return { + state: parsed.state ?? 'unknown', + port: parsed.port ?? this._config.portMappings?.gateway, + version: parsed.version, + uptime: parsed.uptime, + }; + } catch { + const out = (result.stdout + result.stderr).toLowerCase(); + const running = out.includes('running') || out.includes('started'); + return { state: running ? 'running' : 'stopped', port: this._config.portMappings?.gateway }; + } + } + return { state: 'stopped' as GatewayRunState }; + } catch { + return { state: 'error' as GatewayRunState, error: 'Health check failed' }; + } + } + + async gatewayStart(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited with code ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited with code ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } + } + + // ── Full install + onboard ──────────────── + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed — call installCli first'); } + const args = ['onboard', + '--provider', params.provider, + '--api-key', params.apiKey, + '--port', params.port, + ]; + const code = await this.execStream(cliPath, args, {}, onLog, onLog); + if (code !== 0) { throw new Error(`onboard exited with code ${code}`); } + } + + // ── Environment ─────────────────────────── + + buildExecEnv(): Record { + return { ...process.env }; + } +} diff --git a/apps/editor/extensions/openclaw-docker/src/extension.ts b/apps/editor/extensions/openclaw-docker/src/extension.ts new file mode 100644 index 00000000..a49e0c93 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/extension.ts @@ -0,0 +1,30 @@ +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { DockerHostAdapter } from './adapter'; + +export async function activate(context: vscode.ExtensionContext): Promise { + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (!coreExt) { + console.warn('[openclaw-docker] Core extension openclaw.home not found — docker adapter not registered'); + return; + } + + const coreAPI = coreExt.isActive + ? coreExt.exports + : await coreExt.activate(); + + if (!coreAPI || typeof coreAPI.registerHostAdapter !== 'function') { + console.warn('[openclaw-docker] Core extension did not export OpenClawCoreAPI — docker adapter not registered'); + return; + } + + const adapter = new DockerHostAdapter(); + const disposable = coreAPI.registerHostAdapter(adapter); + context.subscriptions.push(disposable); + + console.log('[openclaw-docker] DockerHostAdapter registered'); +} + +export function deactivate(): void { + // Subscriptions cleaned up automatically +} diff --git a/apps/editor/extensions/openclaw-docker/src/preflight.ts b/apps/editor/extensions/openclaw-docker/src/preflight.ts new file mode 100644 index 00000000..d45a30c5 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/preflight.ts @@ -0,0 +1,190 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; + +// ───────────────────────────────────────────── +// Docker preflight results +// ───────────────────────────────────────────── + +export type DockerPreflightStatus = + | { ok: true } + | { ok: false; reason: 'cli_missing'; remedy: string } + | { ok: false; reason: 'daemon_down'; remedy: string } + | { ok: false; reason: 'permission_denied'; remedy: string } + | { ok: false; reason: 'unknown'; error: string; remedy: string }; + +// ───────────────────────────────────────────── +// Platform remedies +// ───────────────────────────────────────────── + +function installRemedy(): string { + switch (process.platform) { + case 'darwin': + return ( + 'Docker is not installed.\n\n' + + 'Install options:\n' + + ' • OrbStack (recommended): https://orbstack.dev — lightweight, fast\n' + + ' • Docker Desktop: https://www.docker.com/products/docker-desktop\n' + + ' • Homebrew: brew install --cask docker' + ); + case 'win32': + return ( + 'Docker is not installed.\n\n' + + 'Install options:\n' + + ' • Docker Desktop for Windows: https://www.docker.com/products/docker-desktop\n' + + ' Requires WSL 2 (Windows Subsystem for Linux).\n' + + ' Enable WSL 2 first: wsl --install' + ); + default: // linux + return ( + 'Docker is not installed.\n\n' + + 'Install options:\n' + + ' • Ubuntu/Debian: sudo apt-get install docker.io && sudo systemctl enable --now docker\n' + + ' • Fedora/RHEL: sudo dnf install docker-ce && sudo systemctl enable --now docker\n' + + ' • Official script: curl -fsSL https://get.docker.com | sh' + ); + } +} + +function daemonDownRemedy(): string { + switch (process.platform) { + case 'darwin': + return ( + 'Docker daemon is not running.\n\n' + + 'Start it:\n' + + ' • OrbStack: open the OrbStack app in your Applications folder\n' + + ' • Docker Desktop: open Docker Desktop from your Applications folder\n' + + ' • After starting, wait ~10 s for the daemon to be ready' + ); + case 'win32': + return ( + 'Docker daemon is not running.\n\n' + + 'Start it:\n' + + ' • Open Docker Desktop from the Start menu or system tray\n' + + ' • Wait for the whale icon to stop animating\n' + + ' • Ensure WSL 2 backend is enabled in Docker Desktop → Settings → General' + ); + default: // linux + return ( + 'Docker daemon is not running.\n\n' + + 'Start it:\n' + + ' • sudo systemctl start docker\n' + + ' • To auto-start on boot: sudo systemctl enable docker' + ); + } +} + +function permissionRemedy(): string { + switch (process.platform) { + case 'darwin': + return ( + 'Permission denied connecting to Docker socket.\n\n' + + 'If you are using OrbStack or Docker Desktop, try restarting the app.\n' + + 'If you installed Docker CLI manually: sudo usermod -aG docker $USER && newgrp docker' + ); + case 'win32': + return ( + 'Permission denied connecting to Docker socket.\n\n' + + 'Make sure your Windows user account has access to Docker Desktop.\n' + + 'Open Docker Desktop → Settings → General → "Use WSL 2 based engine" and restart.' + ); + default: // linux + return ( + 'Permission denied connecting to Docker socket.\n\n' + + 'Add your user to the docker group:\n' + + ' sudo usermod -aG docker $USER\n' + + 'Then log out and back in (or run: newgrp docker)' + ); + } +} + +// ───────────────────────────────────────────── +// Check helpers +// ───────────────────────────────────────────── + +function isDockerCliOnPath(): boolean { + try { + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + const result = cp.spawnSync(whichCmd, ['docker'], { timeout: 5000 }); + return result.status === 0 && result.stdout.toString().trim().length > 0; + } catch { + return false; + } +} + +function isDockerCliOnWellKnownPath(): boolean { + const paths = process.platform === 'win32' + ? [ + 'C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe', + 'C:\\ProgramData\\DockerDesktop\\version-bin\\docker.exe', + ] + : process.platform === 'darwin' + ? [ + '/usr/local/bin/docker', + '/opt/homebrew/bin/docker', + '/Applications/OrbStack.app/Contents/MacOS/xbin/docker', + '/Applications/Docker.app/Contents/Resources/bin/docker', + ] + : [ + '/usr/bin/docker', + '/usr/local/bin/docker', + '/snap/bin/docker', + ]; + return paths.some(p => fs.existsSync(p)); +} + +function runDockerInfo(): { ok: boolean; permissionDenied: boolean; daemonDown: boolean } { + try { + const result = cp.spawnSync('docker', ['info'], { + timeout: 8000, + windowsHide: true, + }); + if (result.status === 0) { + return { ok: true, permissionDenied: false, daemonDown: false }; + } + const combined = (result.stdout?.toString() ?? '') + (result.stderr?.toString() ?? ''); + const lower = combined.toLowerCase(); + const permissionDenied = lower.includes('permission denied') || lower.includes('access is denied'); + const daemonDown = + lower.includes('cannot connect') || + lower.includes('is the docker daemon running') || + lower.includes('error during connect') || + lower.includes('pipe') || + lower.includes('no such file'); + return { ok: false, permissionDenied, daemonDown }; + } catch { + return { ok: false, permissionDenied: false, daemonDown: true }; + } +} + +// ───────────────────────────────────────────── +// Main preflight check +// ───────────────────────────────────────────── + +export async function dockerPreflight(): Promise { + const cliPresent = isDockerCliOnPath() || isDockerCliOnWellKnownPath(); + + if (!cliPresent) { + return { ok: false, reason: 'cli_missing', remedy: installRemedy() }; + } + + const info = runDockerInfo(); + + if (info.ok) { + return { ok: true }; + } + + if (info.permissionDenied) { + return { ok: false, reason: 'permission_denied', remedy: permissionRemedy() }; + } + + if (info.daemonDown) { + return { ok: false, reason: 'daemon_down', remedy: daemonDownRemedy() }; + } + + return { + ok: false, + reason: 'unknown', + error: 'docker info failed for unknown reason', + remedy: daemonDownRemedy(), + }; +} diff --git a/apps/editor/extensions/openclaw-ssh/src/extension.ts b/apps/editor/extensions/openclaw-ssh/src/extension.ts new file mode 100644 index 00000000..55a38ee4 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/extension.ts @@ -0,0 +1,13 @@ +import * as vscode from 'vscode'; + +/** + * openclaw-ssh — Preview stub. + * + * SSH host support is coming soon. This extension activates silently + * and will register an SSHHostAdapter once the implementation lands. + */ +export function activate(_context: vscode.ExtensionContext): void { + console.log('[openclaw-ssh] SSH adapter — coming soon'); +} + +export function deactivate(): void {} From c5a28cde10a327b1f0ac9f5f9902a1bca7a44e4b Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:57:04 -0400 Subject: [PATCH 06/19] fix(multihost): correct adapter extension main paths after TS rootDir inference TypeScript infers the rootDir as apps/editor/extensions/ (common ancestor) when import-type references span across extension directories. This causes output files to land in out/openclaw-local/src/ and out/openclaw-docker/src/ rather than out/. Fix: update "main" in each adapter's package.json to match the actual compiled path: - openclaw-local: ./out/openclaw-local/src/extension - openclaw-docker: ./out/openclaw-docker/src/extension - openclaw-ssh: ./out/extension (no cross-extension imports, unaffected) All four extensions verified with clean builds. Co-Authored-By: Claude Sonnet 4.6 --- apps/editor/extensions/openclaw-docker/package.json | 2 +- apps/editor/extensions/openclaw-local/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/editor/extensions/openclaw-docker/package.json b/apps/editor/extensions/openclaw-docker/package.json index d2a8efa3..53298d49 100644 --- a/apps/editor/extensions/openclaw-docker/package.json +++ b/apps/editor/extensions/openclaw-docker/package.json @@ -9,7 +9,7 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/extension", + "main": "./out/openclaw-docker/src/extension", "contributes": {}, "scripts": { "compile": "tsc -p ./", diff --git a/apps/editor/extensions/openclaw-local/package.json b/apps/editor/extensions/openclaw-local/package.json index e73b1b71..d34547dd 100644 --- a/apps/editor/extensions/openclaw-local/package.json +++ b/apps/editor/extensions/openclaw-local/package.json @@ -9,7 +9,7 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/extension", + "main": "./out/openclaw-local/src/extension", "contributes": {}, "scripts": { "compile": "tsc -p ./", From 399373cbf051bbf00c1b963117a0b3ebb2422840 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 05:14:48 -0400 Subject: [PATCH 07/19] =?UTF-8?q?feat(multihost):=20Phase=202b=20=E2=80=94?= =?UTF-8?q?=20home.ts=20surgical=20refactor=20to=20HostConnection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HomePanel now delegates all host I/O through a HostConnection interface, making it host-agnostic and ready for Docker/SSH in future: Key changes in home.ts: - _host: HostConnection property (DefaultLocalHostConnection by default); swapped to active host when HostManager fires onDidChangeActiveHost - _buildExecEnv() → this._host.buildExecEnv() (eliminates 55-line method) - _testOpenClawCli() → this._host.testOpenClawCli() (eliminates 103-line method) - _findOpenClawPath() → this._host.findOpenClawPath() (eliminates 65-line method) - _quickInstallCheck() → async, delegates to this._host.exists(configPath) - _getConfiguredPort() → reads _cachedGatewayPort (updated async in _update()) - _update() → host.exists()/readConfig()/getConfigPath() for config detection - _runSetup() → this._host.execStream() for openclaw onboard subprocess; host.readConfig()/writeConfig() for openclaw.json patching; host.exec() for Node.js version check New files: - hosts/localDefault.ts: DefaultLocalHostConnection — self-contained local implementation within the core extension; no cross-extension source imports - hosts/types.ts: added shell?: boolean to ExecOpts (needed for Windows .cmd shims) - openclaw-local/connection.ts: propagate shell option to cp.spawn Co-Authored-By: Claude Sonnet 4.6 --- .../openclaw-local/src/connection.ts | 2 + .../openclaw/src/hosts/localDefault.ts | 299 +++++++++++ .../extensions/openclaw/src/hosts/types.ts | 2 + .../extensions/openclaw/src/panels/home.ts | 471 +++++------------- 4 files changed, 437 insertions(+), 337 deletions(-) create mode 100644 apps/editor/extensions/openclaw/src/hosts/localDefault.ts diff --git a/apps/editor/extensions/openclaw-local/src/connection.ts b/apps/editor/extensions/openclaw-local/src/connection.ts index de5ee339..d6d9bc9c 100644 --- a/apps/editor/extensions/openclaw-local/src/connection.ts +++ b/apps/editor/extensions/openclaw-local/src/connection.ts @@ -79,6 +79,7 @@ export class LocalHostConnection implements HostConnection { env: { ...process.env, ...opts.env }, timeout: opts.timeout, windowsHide: opts.windowsHide ?? true, + shell: opts.shell, }); if (opts.stdinData !== undefined) { @@ -111,6 +112,7 @@ export class LocalHostConnection implements HostConnection { env: { ...process.env, ...opts.env }, timeout: opts.timeout, windowsHide: opts.windowsHide ?? true, + shell: opts.shell, }); if (opts.stdinData !== undefined) { diff --git a/apps/editor/extensions/openclaw/src/hosts/localDefault.ts b/apps/editor/extensions/openclaw/src/hosts/localDefault.ts new file mode 100644 index 00000000..179b4c98 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/localDefault.ts @@ -0,0 +1,299 @@ +/** + * Minimal local HostConnection used by the core extension as the default + * before any adapter extension registers a LocalHostAdapter. + * + * Implements only the methods HomePanel actively calls. Full implementation + * lives in the openclaw-local adapter extension. + */ + +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + OpenClawConfig, + SetupParams, +} from './types'; +import * as vscode from 'vscode'; + +// ───────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────── + +function openClawConfigFile(): string { + return path.join(os.homedir(), '.openclaw', 'openclaw.json'); +} + +function wellKnownCliPaths(): string[] { + const home = os.homedir(); + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'); + return [ + path.join(appData, 'npm', 'openclaw.cmd'), + path.join(appData, 'npm', 'openclaw.exe'), + ]; + } + return [ + '/usr/local/bin/openclaw', + '/opt/homebrew/bin/openclaw', + path.join(home, '.local', 'bin', 'openclaw'), + path.join(home, '.npm-global', 'bin', 'openclaw'), + ]; +} + +function buildLocalEnv(): Record { + const env = { ...process.env }; + const basePath = env.PATH ?? (env as Record).Path ?? ''; + const extra: string[] = []; + if (process.platform === 'win32') { + const appData = env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'); + extra.push(path.join(appData, 'npm')); + } else { + extra.push('/usr/local/bin', '/opt/homebrew/bin'); + extra.push(path.join(os.homedir(), '.local', 'bin')); + extra.push(path.join(os.homedir(), '.npm-global', 'bin')); + extra.push(path.join(os.homedir(), '.openclaw', 'bin')); + // nvm paths + const nvmDir = process.env.NVM_DIR ?? path.join(os.homedir(), '.nvm'); + const nvmVersionsDir = path.join(nvmDir, 'versions', 'node'); + if (fs.existsSync(nvmVersionsDir)) { + try { + fs.readdirSync(nvmVersionsDir) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + .slice(0, 3) + .forEach(v => extra.push(path.join(nvmVersionsDir, v, 'bin'))); + } catch { /* non-fatal */ } + } + if (process.platform === 'darwin') { + extra.push('/opt/homebrew/opt/node/bin', '/usr/local/opt/node/bin'); + } + } + const sep = process.platform === 'win32' ? ';' : ':'; + env.PATH = [...extra, basePath].filter(Boolean).join(sep); + (env as Record).Path = env.PATH; + return env; +} + +// ───────────────────────────────────────────── +// DefaultLocalHostConnection +// ───────────────────────────────────────────── + +export class DefaultLocalHostConnection implements HostConnection { + readonly id = 'local'; + readonly type: HostType = 'local'; + readonly label = 'Local'; + + dispose(): void {} + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + shell: opts.shell, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + let stdout = ''; let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', reject); + proc.on('close', code => resolve({ stdout, stderr, code: code ?? -1 })); + }); + } + + execStream(cmd: string, args: string[], opts: ExecOpts, onData: LogFn, onError: LogFn): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + shell: opts.shell, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + readFile(filePath: string): Promise { + return Promise.resolve(fs.readFileSync(filePath, 'utf-8')); + } + + writeFile(filePath: string, content: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); + return Promise.resolve(); + } + + exists(filePath: string): Promise { + return Promise.resolve(fs.existsSync(filePath)); + } + + mkdir(dirPath: string): Promise { + fs.mkdirSync(dirPath, { recursive: true }); + return Promise.resolve(); + } + + stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + try { + const s = fs.statSync(filePath); + return Promise.resolve({ size: s.size, isDirectory: s.isDirectory() }); + } catch { + return Promise.resolve(null); + } + } + + async findOpenClawPath(): Promise { + const cfg = vscode.workspace.getConfiguration('openclaw').get('cliPath'); + if (cfg && fs.existsSync(cfg)) { return cfg; } + const env = process.env.OPENCLAW_CLI; + if (env && fs.existsSync(env)) { return env; } + // which/where + try { + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + const r = await this.exec(whichCmd, ['openclaw'], { timeout: 5000 }); + const found = r.stdout.trim().split('\n')[0]; + if (found && fs.existsSync(found)) { return found; } + } catch { /* ignore */ } + // Well-known paths + for (const p of wellKnownCliPaths()) { + if (fs.existsSync(p)) { return p; } + } + // nvm paths + const nvmDir = path.join(os.homedir(), '.nvm', 'versions', 'node'); + if (fs.existsSync(nvmDir)) { + for (const ver of fs.readdirSync(nvmDir).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))) { + const p = path.join(nvmDir, ver, 'bin', 'openclaw'); + if (fs.existsSync(p)) { return p; } + } + } + return undefined; + } + + async isCliInstalled(): Promise { + return (await this.findOpenClawPath()) !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const r = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (r.stdout + r.stderr).trim(); + const m = out.match(/[\d]+\.[\d]+\.[\d]+/); + return m ? m[0] : out || null; + } catch { return null; } + } + + async testOpenClawCli(): Promise { + // On unix: try sourcing nvm first for version-managed installs + if (process.platform !== 'win32') { + const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); + if (fs.existsSync(nvmSh)) { + try { + const r = await this.exec('bash', ['-c', `. "${nvmSh}" 2>/dev/null && openclaw --version 2>&1`], { timeout: 10000 }); + const line = r.stdout.trim().split('\n').find(l => /\d/.test(l) && !l.startsWith('nvm') && !l.startsWith('Now')) ?? ''; + if (line) { return { ok: true, output: line, command: `bash -c '. "${nvmSh}" && openclaw --version'` }; } + } catch { /* fall through */ } + } + } + const p = await this.findOpenClawPath(); + const command = p ? `${p} --version` : 'openclaw --version'; + if (!p) { return { ok: false, command, error: 'openclaw not found' }; } + try { + const r = await this.exec(p, ['--version'], { timeout: 15000 }); + const out = (r.stdout + r.stderr).trim(); + return r.code === 0 + ? { ok: true, output: out, command } + : { ok: false, error: out || `Exit ${r.code}`, command }; + } catch (err) { return { ok: false, command, error: String(err) }; } + } + + async installCli(onLog: LogFn): Promise { + if (process.platform === 'win32') { + const code = await this.execStream('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', 'irm https://get.openclaw.sh/win | iex'], { timeout: 120_000, windowsHide: true }, onLog, onLog); + if (code !== 0) { throw new Error(`Installer exited with code ${code}`); } + } else { + const code = await this.execStream('bash', ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], { timeout: 120_000 }, onLog, onLog); + if (code !== 0) { throw new Error(`Installer exited with code ${code}`); } + } + } + + getConfigPath(): Promise { return Promise.resolve(openClawConfigFile()); } + + async readConfig(): Promise { + const p = openClawConfigFile(); + if (!fs.existsSync(p)) { return {}; } + try { return JSON.parse(fs.readFileSync(p, 'utf-8')) as OpenClawConfig; } catch { return {}; } + } + + async writeConfig(patch: Partial): Promise { + const p = openClawConfigFile(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + let existing: OpenClawConfig = {}; + if (fs.existsSync(p)) { try { existing = JSON.parse(fs.readFileSync(p, 'utf-8')) as OpenClawConfig; } catch { /* ok */ } } + fs.writeFileSync(p, JSON.stringify({ ...existing, ...patch }, null, 2), 'utf-8'); + } + + async gatewayHealthCheck(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return { state: 'unknown', error: 'CLI not installed' }; } + try { + const r = await this.exec(p, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (r.code === 0) { + try { + const parsed = JSON.parse(r.stdout) as Partial; + return { state: parsed.state ?? 'unknown', port: parsed.port, version: parsed.version, uptime: parsed.uptime }; + } catch { + const out = (r.stdout + r.stderr).toLowerCase(); + return { state: out.includes('running') ? 'running' : 'stopped' }; + } + } + return { state: 'stopped' }; + } catch { return { state: 'error', error: 'Health check failed' }; } + } + + async gatewayStart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited ${code}`); } + } + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('CLI not installed'); } + const code = await this.execStream(p, ['onboard', '--provider', params.provider, '--api-key', params.apiKey, '--port', params.port], {}, onLog, onLog); + if (code !== 0) { throw new Error(`onboard exited ${code}`); } + } + + buildExecEnv(): Record { + return buildLocalEnv(); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts index 2d82e9f2..86a628b0 100644 --- a/apps/editor/extensions/openclaw/src/hosts/types.ts +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -13,6 +13,8 @@ export interface ExecOpts { /** Bytes to pipe to stdin before closing it */ stdinData?: string; windowsHide?: boolean; + /** Run via OS shell (required for .cmd/.bat shims on Windows) */ + shell?: boolean; } export interface ExecResult { diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 0184fc40..20af41df 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -5,6 +5,8 @@ import * as http from 'http'; import * as https from 'https'; import * as os from 'os'; import * as path from 'path'; +import type { HostConnection, OpenClawCoreAPI } from '../hosts/types'; +import { DefaultLocalHostConnection } from '../hosts/localDefault'; type GatewayStatus = 'checking' | 'running' | 'stopped' | 'starting' | 'stopping' | 'restarting' | 'errored' | 'ai-fixing'; @@ -102,11 +104,27 @@ export class HomePanel { private _closeSidebarOnGatewayStart = false; // close sidebar once gateway reaches running after first install private _uninstallCloseSidebarTimer: ReturnType | undefined; private _uninstallCloseWatcher: ReturnType | undefined; + /** Active host connection — defaults to local; swapped when the user picks a remote host. */ + private _host: HostConnection = new DefaultLocalHostConnection(); + /** Cached gateway port — populated on every _update() so _getConfiguredPort() stays sync. */ + private _cachedGatewayPort = 18789; private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { this._panel = panel; this._extensionUri = extensionUri; this._outputChannel = vscode.window.createOutputChannel('OpenClaw Gateway'); + // Subscribe to active host changes from the core extension if available. + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (coreExt?.isActive && coreExt.exports != null) { + const coreAPI = coreExt.exports; + const hostSub = coreAPI.onDidChangeActiveHost(conn => { + this._host = conn ?? new DefaultLocalHostConnection(); + void this._update(); + }); + this._disposables.push(hostSub); + const active = coreAPI.getActiveHost(); + if (active) { this._host = active; } + } const iconUri = this._panel.webview.asWebviewUri( vscode.Uri.joinPath(this._extensionUri, 'media', 'icon.png') ); @@ -723,11 +741,26 @@ export class HomePanel { } private async _update() { + // Get config path and check existence via the active host. + const configFile = await this._host.getConfigPath(); + const isConfigured = await this._host.exists(configFile); + + // Update cached port from config (keeps _getConfiguredPort() synchronous). + if (isConfigured) { + try { + const cfg = await this._host.readConfig(); + const gateway = cfg['gateway'] as Record | undefined; + const p = gateway?.['port'] ?? cfg['port'] ?? cfg['gateway_port'] ?? cfg['gatewayPort']; + const n = typeof p === 'string' ? parseInt(p, 10) : typeof p === 'number' ? p : NaN; + if (Number.isFinite(n) && n > 0 && n < 65536) { + this._cachedGatewayPort = n; + } + } catch { /* non-fatal */ } + } + + // Silently fix ownership if ~/.openclaw was created as root (local host only, non-interactive sudo). const openclawDir = path.join(os.homedir(), '.openclaw'); const dirExists = fs.existsSync(openclawDir); - - // Silently fix ownership if ~/.openclaw was created as root (e.g. after a sudo install). - // sudo -n is non-interactive — only succeeds if a sudo session is still cached; otherwise a no-op. if (dirExists && process.platform !== 'win32') { try { const stat = fs.statSync(openclawDir); @@ -740,8 +773,6 @@ export class HomePanel { const cliCheck = await this._testOpenClawCli(); writeLog(`[cli-check] ok=${cliCheck.ok} cmd="${cliCheck.command}" output="${(cliCheck.output ?? '').trim()}"\n`); - const configFile = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - const isConfigured = fs.existsSync(configFile); const isInstalled = isConfigured; // config file is the sole source of truth — a leftover binary without config is not "installed" this._lastInstalledState = isInstalled; this._lastInstalledVersion = cliCheck.ok ? (cliCheck.output ?? '').trim() : null; @@ -770,11 +801,10 @@ export class HomePanel { const emojiBaseUri = this._panel.webview.asWebviewUri( vscode.Uri.joinPath(this._extensionUri, 'media', 'emojis') ).toString(); - // Read AI model info from openclaw.json + // Read AI model info from openclaw.json (via active host) let aiModelName = ''; try { - const raw = fs.readFileSync(configFile, 'utf-8'); - const cfg = JSON.parse(raw) as Record; + const cfg = await this._host.readConfig() as Record; const primaryModel = (cfg as Record>>>) ?.agents?.defaults?.model?.primary ?? ''; if (primaryModel) { @@ -812,21 +842,11 @@ export class HomePanel { // ── Gateway status helpers ───────────────────────────────────────────────── /** - * Reads the gateway port from ~/.openclaw/openclaw.json. - * Falls back to 18789 if the file is missing or the field is absent. + * Returns the last-known gateway port (populated by _update() via host.readConfig()). + * Falls back to 18789 before the first update completes. */ private _getConfiguredPort(): number { - try { - const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - const raw = fs.readFileSync(configPath, 'utf-8'); - const config = JSON.parse(raw) as Record; - const gateway = config['gateway'] as Record | undefined; - const p = gateway?.['port'] ?? config['port'] ?? config['gateway_port'] ?? config['gatewayPort']; - const n = typeof p === 'string' ? parseInt(p, 10) : typeof p === 'number' ? p : NaN; - return Number.isFinite(n) && n > 0 && n < 65536 ? n : 18789; - } catch { - return 18789; - } + return this._cachedGatewayPort; } /** Raw HTTP probe against the configured port — no _commandAction guard. Used by the polling loop. */ @@ -854,13 +874,16 @@ export class HomePanel { } /** - * Fast synchronous check — ~/.openclaw/openclaw.json is the single - * definitive signal that OpenClaw is installed and initialised. + * Check — openclaw.json is the single definitive signal that OpenClaw + * is installed and initialised on the active host. */ - private _quickInstallCheck(): boolean { - // Config file is the sole source of truth — no config means not installed, - // even if the binary is still on PATH. - return fs.existsSync(path.join(os.homedir(), '.openclaw', 'openclaw.json')); + private async _quickInstallCheck(): Promise { + try { + const cfgPath = await this._host.getConfigPath(); + return this._host.exists(cfgPath); + } catch { + return false; + } } private _startPolling(): void { @@ -874,7 +897,7 @@ export class HomePanel { // No process spawn — just cheap stat calls. If the result differs from // the last known state, do a full _update() to confirm and re-render. if (this._pollTick % 2 === 0) { - const nowInstalled = this._quickInstallCheck(); + const nowInstalled = await this._quickInstallCheck(); if (nowInstalled !== this._lastInstalledState) { void this._update(); return; @@ -1200,26 +1223,21 @@ export class HomePanel { // openclaw requires Node.js >= 22. Check the active version and auto-install // via nvm if needed — the editor pins v20, so users may only have v20 active. if (process.platform !== 'win32') { - const nodeVerRaw = await new Promise(resolve => { - cp.exec('node --version', { env, timeout: 5000 }, (err, stdout) => - resolve((stdout || '').toString().trim()) - ); - }); + const nodeResult = await this._host.exec('node', ['--version'], { env: env as Record, timeout: 5000 }); + const nodeVerRaw = nodeResult.stdout.trim(); const nodeMinor = parseInt((nodeVerRaw.match(/^v?(\d+)/) || [])[1] || '0', 10); if (nodeMinor < 22) { const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); if (fs.existsSync(nvmSh)) { wizardPost(`Node.js ${nodeVerRaw || 'unknown'} detected — openclaw requires v22+. Installing Node.js 22 via nvm...\n`, false, false); - const nvmR = await new Promise(resolve => { - const child = cp.spawn('bash', ['-c', - `. "${nvmSh}" && nvm install 22 && nvm use 22 && nvm alias default 22` - ], { env, stdio: ['ignore', 'pipe', 'pipe'] }); - child.stdout?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); - child.stderr?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); - child.on('close', code => resolve(code ?? 1)); - child.on('error', () => resolve(1)); - }); - if (nvmR === 0) { + const nvmCode = await this._host.execStream( + 'bash', + ['-c', `. "${nvmSh}" && nvm install 22 && nvm use 22 && nvm alias default 22`], + { env: env as Record }, + (d) => wizardPost(d, false, false), + (d) => wizardPost(d, false, false), + ); + if (nvmCode === 0) { // Rebuild env so the new Node 22 bin is on PATH const nvmVersionsDir = path.join(os.homedir(), '.nvm', 'versions', 'node'); const v22dirs = fs.existsSync(nvmVersionsDir) @@ -1285,83 +1303,77 @@ export class HomePanel { writeLog(`\n=== _runSetup START provider=${data.provider} port=${port} ===\n`); wizardPost(isFree ? 'Installing Inference for MoltPilot...\nInstalling Inference for your new OpenClaw...\n' : 'Installing Inference for your new OpenClaw...\n', false, false); - await new Promise(resolve => { - const child = cp.spawn(cliPath, args, { - env, - stdio: ['ignore', 'pipe', 'pipe'], - ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), - }); - child.stdout?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); - child.stderr?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); - child.on('close', code => { - const ok = code === 0; - writeLog(`=== _runSetup END code=${code} ===\n`); - wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${code}.\n`, true, ok); - if (ok) { - if (isFree) { - // Write local free-tier marker (no remote enforcement). - try { - const occDir = path.join(os.homedir(), '.occ'); - if (!fs.existsSync(occDir)) fs.mkdirSync(occDir, { recursive: true }); - fs.writeFileSync( - path.join(occDir, 'moltpilot-tier.json'), - JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), - ); - } catch { /* non-fatal */ } - // Patch openclaw.json to inject correct cost/context metadata for occ-legacy. - // openclaw onboard writes the model with null/zero values; we fix them here. - try { - const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - const raw = fs.readFileSync(configPath, 'utf-8'); - const cfg = JSON.parse(raw) as Record; - // Recursively find any model object with id === OCC_LEGACY_MODEL_ID and patch it. - const patchModel = (obj: unknown): boolean => { - if (!obj || typeof obj !== 'object') return false; - if (Array.isArray(obj)) { - for (const item of obj) { - if (patchModel(item)) return true; - } - return false; - } - const o = obj as Record; - if (o['id'] === OCC_LEGACY_MODEL_ID) { - o['name'] = OCC_LEGACY_MODEL_NAME; - o['reasoning'] = false; - o['input'] = ['text']; - o['cost'] = { ...OCC_LEGACY_COST }; - o['contextWindow'] = OCC_LEGACY_CONTEXT_WINDOW; - o['maxTokens'] = OCC_LEGACY_MAX_TOKENS; - return true; - } - for (const v of Object.values(o)) { patchModel(v); } - return false; - }; - patchModel(cfg); - fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); - } catch { /* non-fatal — openclaw.json may not exist yet */ } - } - setTimeout(() => { - HomePanel.refresh(); - if (isFree) { - vscode.commands.executeCommand('openclaw.openWorkspace'); + let exitCode: number; + try { + exitCode = await this._host.execStream( + cliPath, args, + { + env: env as Record, + timeout: 120_000, + ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), + }, + (d) => wizardPost(d, false, false), + (d) => wizardPost(d, false, false), + ); + } catch (err) { + wizardPost(`Error: ${String(err)}\n`, true, false); + return; + } + + const ok = exitCode === 0; + writeLog(`=== _runSetup END code=${exitCode} ===\n`); + wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${exitCode}.\n`, true, ok); + + if (ok) { + if (isFree) { + // Write local free-tier marker (extension-host local state — always local fs). + try { + const occDir = path.join(os.homedir(), '.occ'); + if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } + fs.writeFileSync( + path.join(occDir, 'moltpilot-tier.json'), + JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), + ); + } catch { /* non-fatal */ } + // Patch openclaw.json on the active host to inject correct cost/context metadata. + try { + const cfg = await this._host.readConfig() as Record; + const patchModel = (obj: unknown): boolean => { + if (!obj || typeof obj !== 'object') return false; + if (Array.isArray(obj)) { + for (const item of obj) { if (patchModel(item)) return true; } + return false; } - // Give the dashboard time to render, then open a new chat asking AI to start the gateway. - // Set flag so the sidebar auto-closes once the gateway is confirmed running. - setTimeout(() => { - this._closeSidebarOnGatewayStart = true; - vscode.commands.executeCommand('void.openChatWithMessage', - 'Run `openclaw gateway start` to start the OpenClaw gateway.', - 'agent'); - }, 1000); - }, 1500); + const o = obj as Record; + if (o['id'] === OCC_LEGACY_MODEL_ID) { + o['name'] = OCC_LEGACY_MODEL_NAME; + o['reasoning'] = false; + o['input'] = ['text']; + o['cost'] = { ...OCC_LEGACY_COST }; + o['contextWindow'] = OCC_LEGACY_CONTEXT_WINDOW; + o['maxTokens'] = OCC_LEGACY_MAX_TOKENS; + return true; + } + for (const v of Object.values(o)) { patchModel(v); } + return false; + }; + patchModel(cfg); + await this._host.writeConfig(cfg); + } catch { /* non-fatal — openclaw.json may not exist yet */ } + } + setTimeout(() => { + HomePanel.refresh(); + if (isFree) { + vscode.commands.executeCommand('openclaw.openWorkspace'); } - resolve(); - }); - child.on('error', err => { - wizardPost(`Error: ${err.message}\n`, true, false); - resolve(); - }); - }); + setTimeout(() => { + this._closeSidebarOnGatewayStart = true; + vscode.commands.executeCommand('void.openChatWithMessage', + 'Run `openclaw gateway start` to start the OpenClaw gateway.', + 'agent'); + }, 1000); + }, 1500); + } } // ── Uninstall ────────────────────────────────────────────────────────────── @@ -4033,107 +4045,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; } private async _testOpenClawCli(): Promise<{ ok: boolean; output?: string; error?: string; command: string }> { - if (process.platform === 'win32') { - // ── 1. Find openclaw.mjs (checks npm prefix + version-manager paths) ────── - const mjs = await this._findWindowsOpenClawMjs(); - if (mjs) { - // ── 2. Find node.exe (PATH-first, then nvm/Volta/scoop, then hardcoded) ── - const nodeExe = await this._findWindowsNodeExe(); - if (nodeExe) { - return this._spawnNodeMjs(nodeExe, mjs, `"${nodeExe}" "${mjs}" --version`); - } - } - - // ── 3. .cmd / .exe shim fallback (npm prefix + scoop shims) ────────────── - const cmdPath = await this._findWindowsOpenClawCmd(); - if (cmdPath) { - return new Promise(resolve => { - cp.execFile( - 'cmd.exe', ['/c', cmdPath, '--version'], - { timeout: 30000, windowsHide: true, maxBuffer: 1024 * 1024 }, - (error, stdout, stderr) => { - if (error) { - const timedOut = (error as any).signal === 'SIGTERM' || error.code == null; - resolve({ - ok: false, - error: timedOut ? 'Timed out' : (stderr?.toString().trim() || `Exit ${error.code}`), - command: `${cmdPath} --version`, - }); - } else { - resolve({ ok: true, output: (stdout || stderr || '').toString().trim(), command: `${cmdPath} --version` }); - } - } - ); - }); - } - - return { ok: false, error: 'openclaw not found', command: 'openclaw --version' }; - } - - // ── Mac / Linux ────────────────────────────────────────────────────────────── - // Strategy: source nvm/nvm.sh first so the nvm-managed binary takes priority - // over any stale system install (e.g. /usr/local/bin/openclaw). Falls back to - // enumerating ~/.nvm/versions/node/*/bin/openclaw (newest version first), - // then the existing path-based search. - const home = os.homedir(); - const nvmSh = path.join(home, '.nvm', 'nvm.sh'); - - // 1. Try sourcing nvm so it activates the default alias / current version. - if (fs.existsSync(nvmSh)) { - const nvmCmd = `bash -c '. "${nvmSh}" 2>/dev/null && openclaw --version 2>&1'`; - const nvmResult = await new Promise<{ ok: boolean; output?: string; error?: string; command: string }>(resolve => { - cp.exec(nvmCmd, { timeout: 10000, maxBuffer: 1024 * 1024 }, (error, stdout) => { - const out = (stdout || '').toString().trim(); - // Extract just the version line (ignores nvm banner noise) - const line = out.split('\n').find(l => /\d/.test(l) && !l.startsWith('nvm') && !l.startsWith('Now')) || ''; - if (!error && line) { - resolve({ ok: true, output: line.trim(), command: nvmCmd }); - } else { - resolve({ ok: false, error: out || error?.message || 'not found', command: nvmCmd }); - } - }); - }); - if (nvmResult.ok) return nvmResult; - } - - // 2. Enumerate ~/.nvm/versions/node/*/bin/openclaw — newest node version first. - const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); - if (fs.existsSync(nvmVersionsDir)) { - const nodeVersions = fs.readdirSync(nvmVersionsDir) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); // newest first - for (const ver of nodeVersions) { - const candidate = path.join(nvmVersionsDir, ver, 'bin', 'openclaw'); - if (fs.existsSync(candidate)) { - const result = await new Promise<{ ok: boolean; output?: string; error?: string; command: string }>(resolve => { - cp.execFile(candidate, ['--version'], { timeout: 10000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { - const out = (stdout || stderr || '').toString().trim(); - if (error || !out) resolve({ ok: false, error: out || error?.message, command: `${candidate} --version` }); - else resolve({ ok: true, output: out, command: `${candidate} --version` }); - }); - }); - if (result.ok) return result; - } - } - } - - // 3. Fall back to path-based search. - const cliPath = await this._findOpenClawPath(); - if (!cliPath) { - return { ok: false, error: 'openclaw not found', command: 'openclaw --version' }; - } - return new Promise(resolve => { - cp.execFile( - cliPath, ['--version'], - { timeout: 15000, maxBuffer: 1024 * 1024, env: this._buildExecEnv() }, - (error, stdout, stderr) => { - if (error) { - resolve({ ok: false, error: stderr?.toString().trim() || error.message || `Exit ${(error as any).code}`, command: `${cliPath} --version` }); - } else { - resolve({ ok: true, output: (stdout || stderr || '').toString().trim(), command: `${cliPath} --version` }); - } - } - ); - }); + return this._host.testOpenClawCli(); } /** @@ -4290,70 +4202,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; } private async _findOpenClawPath(): Promise { - const cfgPath = vscode.workspace.getConfiguration('openclaw').get('cliPath'); - if (cfgPath && fs.existsSync(cfgPath)) return cfgPath; - - const envPath = process.env.OPENCLAW_CLI; - if (envPath && fs.existsSync(envPath)) return envPath; - - if (process.platform === 'win32') { - const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - const candidates = [ - path.join(appData, 'npm', 'openclaw.cmd'), - path.join(appData, 'npm', 'openclaw.exe'), - path.join(appData, 'npm', 'openclaw.bat'), - path.join(appData, 'npm', 'openclaw.ps1'), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - } - - if (process.platform === 'win32') { - for (const probe of ['openclaw.cmd', 'openclaw.exe', 'openclaw.bat', 'openclaw.ps1', 'openclaw']) { - const result = await this._runCommand(`where ${probe}`, 2000); - if (!result.error && !result.notFound) { - const out = (result.stdout || '').trim(); - if (out) { - const candidates = out - .split(/\r?\n/) - .map(l => l.trim().replace(/^"+|"+$/g, '')) - .filter(Boolean); - for (const candidate of candidates) { - const resolved = this._resolveWindowsCliPath(candidate); - if (fs.existsSync(resolved)) return resolved; - } - } - } - } - } else { - const result = await this._runCommand('which openclaw', 2000); - if (!result.error && !result.notFound) { - const out = (result.stdout || '').trim(); - if (out) { - const candidates = out - .split(/\r?\n/) - .map(l => l.trim().replace(/^"+|"+$/g, '')) - .filter(Boolean); - for (const candidate of candidates) { - const resolved = this._resolveWindowsCliPath(candidate); - if (fs.existsSync(resolved)) return resolved; - } - } - } - } - - const npmCandidates = await this._getNpmGlobalCliCandidates(); - for (const candidate of npmCandidates) { - if (fs.existsSync(candidate)) return candidate; - } - - const fallback = this._getCandidateCliPaths(); - for (const candidate of fallback) { - if (fs.existsSync(candidate)) return candidate; - } - - return undefined; + return this._host.findOpenClawPath(); } private _getCandidateCliPaths(): string[] { @@ -4449,59 +4298,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; }); } - private _buildExecEnv() { - const env = { ...process.env }; - const basePath = env.PATH || (env as any).Path || ''; - const extra: string[] = []; - if (process.platform === 'win32') { - const appData = env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - if (appData) extra.push(path.join(appData, 'npm')); - if (env.ProgramFiles) extra.push(path.join(env.ProgramFiles, 'nodejs')); - if (env.LOCALAPPDATA) extra.push(path.join(env.LOCALAPPDATA, 'Programs', 'nodejs')); - const systemRoot = env.SystemRoot || (env as any).WINDIR; - if (systemRoot) extra.push(path.join(systemRoot, 'System32')); - } else { - extra.push('/usr/local/bin', '/opt/homebrew/bin'); - extra.push(path.join(os.homedir(), '.local', 'bin')); - extra.push(path.join(os.homedir(), '.npm-global', 'bin')); - extra.push(path.join(os.homedir(), '.openclaw', 'bin')); - - // Add nvm-managed Node.js paths (newest version first) - const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm'); - const nvmVersionsDir = path.join(nvmDir, 'versions', 'node'); - if (fs.existsSync(nvmVersionsDir)) { - try { - const versions = fs.readdirSync(nvmVersionsDir) - .filter(e => /^v?\d+/.test(e)) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); - for (const v of versions.slice(0, 3)) { - extra.push(path.join(nvmVersionsDir, v, 'bin')); - } - } catch { /* non-fatal */ } - } - - // Add fnm paths (popular Node version manager on macOS) - const fnmDir = path.join(os.homedir(), '.local', 'share', 'fnm', 'node-versions'); - if (fs.existsSync(fnmDir)) { - try { - const versions = fs.readdirSync(fnmDir) - .filter(e => /^v?\d+/.test(e)) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); - for (const v of versions.slice(0, 3)) { - extra.push(path.join(fnmDir, v, 'installation', 'bin')); - } - } catch { /* non-fatal */ } - } - - // Homebrew Node.js (macOS) - if (process.platform === 'darwin') { - extra.push('/opt/homebrew/opt/node/bin'); - extra.push('/usr/local/opt/node/bin'); - } - } - const sep = process.platform === 'win32' ? ';' : ':'; - env.PATH = [...extra, basePath].filter(Boolean).join(sep); - (env as any).Path = env.PATH; - return env; + private _buildExecEnv(): Record { + return this._host.buildExecEnv(); } } From fa70f41ef09f1bdf0d0ce3deeb67cccc156648a5 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Sat, 21 Mar 2026 23:03:32 -0400 Subject: [PATCH 08/19] feat: extract StatusPanelController + show full status panel in adapter tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract _getHtml() from HomePanel into shared renderStatusHtml() in statusHtml.ts - Create StatusPanelController (statusController.ts) with all behavioral logic: gateway polling, version checks, sign-in/out, CASS/Better Memory setup, workspace file shortcuts — usable by any adapter panel - LocalSetupPanel: if OpenClaw already installed, show full status panel immediately instead of wizard; after setup success, show status panel in-tab instead of delegating to HomePanel - DockerSetupPanel: after configure success, show status panel in-tab instead of disposing the panel - Adapters now delegate all webview messages to StatusPanelController once the status panel is active Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw-docker/package.json | 4 +- .../openclaw-docker/src/extension.ts | 6 + .../openclaw-docker/src/setup-panel.ts | 717 ++++ .../extensions/openclaw-local/package.json | 4 +- .../openclaw-local/src/extension.ts | 6 + .../openclaw-local/src/setup-panel.ts | 1412 ++++++++ .../extensions/openclaw-ssh/package.json | 6 +- .../extensions/openclaw-ssh/src/adapter.ts | 71 + .../extensions/openclaw-ssh/src/connection.ts | 268 ++ .../extensions/openclaw-ssh/src/extension.ts | 31 +- .../openclaw-ssh/src/setup-panel.ts | 341 ++ .../extensions/openclaw/src/extension.ts | 8 +- .../openclaw/src/hosts/hostsFile.ts | 37 + .../extensions/openclaw/src/hosts/manager.ts | 9 + .../extensions/openclaw/src/hosts/types.ts | 1 + .../extensions/openclaw/src/panels/home.ts | 3035 ++--------------- .../openclaw/src/panels/statusController.ts | 678 ++++ .../openclaw/src/panels/statusHtml.ts | 1436 ++++++++ 18 files changed, 5225 insertions(+), 2845 deletions(-) create mode 100644 apps/editor/extensions/openclaw-docker/src/setup-panel.ts create mode 100644 apps/editor/extensions/openclaw-local/src/setup-panel.ts create mode 100644 apps/editor/extensions/openclaw-ssh/src/adapter.ts create mode 100644 apps/editor/extensions/openclaw-ssh/src/connection.ts create mode 100644 apps/editor/extensions/openclaw-ssh/src/setup-panel.ts create mode 100644 apps/editor/extensions/openclaw/src/hosts/hostsFile.ts create mode 100644 apps/editor/extensions/openclaw/src/panels/statusController.ts create mode 100644 apps/editor/extensions/openclaw/src/panels/statusHtml.ts diff --git a/apps/editor/extensions/openclaw-docker/package.json b/apps/editor/extensions/openclaw-docker/package.json index 53298d49..a2cf4302 100644 --- a/apps/editor/extensions/openclaw-docker/package.json +++ b/apps/editor/extensions/openclaw-docker/package.json @@ -10,7 +10,9 @@ "categories": ["Other"], "activationEvents": ["onStartupFinished"], "main": "./out/openclaw-docker/src/extension", - "contributes": {}, + "contributes": { + "commands": [{ "command": "openclaw.host.setup.docker", "title": "OpenClaw: Set up Docker" }] + }, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-docker/src/extension.ts b/apps/editor/extensions/openclaw-docker/src/extension.ts index a49e0c93..63d70fef 100644 --- a/apps/editor/extensions/openclaw-docker/src/extension.ts +++ b/apps/editor/extensions/openclaw-docker/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; import { DockerHostAdapter } from './adapter'; +import { DockerSetupPanel } from './setup-panel'; export async function activate(context: vscode.ExtensionContext): Promise { const coreExt = vscode.extensions.getExtension('openclaw.home'); @@ -22,6 +23,11 @@ export async function activate(context: vscode.ExtensionContext): Promise const disposable = coreAPI.registerHostAdapter(adapter); context.subscriptions.push(disposable); + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.docker', () => { + DockerSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + console.log('[openclaw-docker] DockerHostAdapter registered'); } diff --git a/apps/editor/extensions/openclaw-docker/src/setup-panel.ts b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts new file mode 100644 index 00000000..1379c05f --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts @@ -0,0 +1,717 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { StatusPanelController } from '../../openclaw/src/panels/statusController'; + +export class DockerSetupPanel { + public static currentPanel: DockerSetupPanel | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _dockerWorkspaceHostPath: string | undefined; + private _disposables: vscode.Disposable[] = []; + private _statusController: StatusPanelController | undefined; + + private get _homeUri(): vscode.Uri { + const homeExt = vscode.extensions.getExtension('openclaw.home'); + return homeExt?.extensionUri ?? this._extensionUri; + } + + public static createOrShow(extensionUri: vscode.Uri, coreAPI: OpenClawCoreAPI): void { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (DockerSetupPanel.currentPanel) { + DockerSetupPanel.currentPanel._panel.reveal(column); + return; + } + + const homeExt = vscode.extensions.getExtension('openclaw.home'); + const homeUri = homeExt?.extensionUri ?? extensionUri; + const iconUri = vscode.Uri.joinPath(homeUri, 'media', 'icon.png'); + + const panel = vscode.window.createWebviewPanel( + 'openclawDockerSetup', + 'OCC Home [Docker]', + column ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(homeUri, 'media'), + ], + retainContextWhenHidden: true, + }, + ); + + DockerSetupPanel.currentPanel = new DockerSetupPanel(panel, extensionUri, coreAPI, iconUri); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + private readonly _coreAPI: OpenClawCoreAPI, + iconUri: vscode.Uri, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + + const webviewIconUri = panel.webview.asWebviewUri(iconUri).toString(); + this._panel.webview.html = this._getHtml(webviewIconUri); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + async (msg: { command: string; [key: string]: unknown }) => { + if (this._statusController) { + this._statusController.handleMessage(msg); + return; + } + switch (msg.command) { + case 'dockerPreflightCheck': + await this._handleDockerPreflight(); + break; + case 'dockerFindOrCreate': + await this._handleDockerFindOrCreate(); + break; + case 'dockerInstallCli': + await this._handleDockerInstallCli(); + break; + case 'dockerRunOnboard': + await this._handleDockerConfigure( + msg.provider as string, + msg.apiKey as string, + msg.port as string, + ); + break; + case 'closePanel': + this.dispose(); + break; + default: + if (msg.command) { + void vscode.commands.executeCommand(msg.command); + } + break; + } + }, + null, + this._disposables, + ); + } + + public dispose(): void { + DockerSetupPanel.currentPanel = undefined; + this._statusController?.dispose(); + this._statusController = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) { d.dispose(); } + } + } + + private async _showStatusPanel(): Promise { + if (!this._statusController) { + const { DockerHostConnection } = await import('./connection'); + const host = new DockerHostConnection( + { type: 'docker', containerLabel: 'occ-openclaw', portMappings: { gateway: 18789 } }, + 'occ-openclaw', + ); + this._statusController = new StatusPanelController(this._panel, this._homeUri, host); + } + await this._statusController.show(); + } + + // ── Handlers ─────────────────────────────────────────────────────────────── + + private async _handleDockerPreflight(): Promise { + try { + await new Promise((resolve) => { + const proc = cp.spawn('docker', ['info'], { windowsHide: true, timeout: 10000 }); + let stderr = ''; + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('close', (code) => { + if (code === 0) { + try { this._panel.webview.postMessage({ type: 'dockerPreflightResult', ok: true }); } catch { /* ignore */ } + } else { + const lower = stderr.toLowerCase(); + const error = lower.includes('permission denied') + ? 'Permission denied connecting to Docker. Try restarting Docker Desktop or OrbStack.' + : (lower.includes('cannot connect') || lower.includes('daemon') || lower.includes('no such file')) + ? 'Docker daemon is not running. Please start Docker Desktop or OrbStack, then try again.' + : 'Docker is not available. Please install Docker Desktop or OrbStack.'; + try { this._panel.webview.postMessage({ type: 'dockerPreflightResult', ok: false, error }); } catch { /* ignore */ } + } + resolve(); + }); + proc.on('error', () => { + try { this._panel.webview.postMessage({ type: 'dockerPreflightResult', ok: false, error: 'Docker CLI not found. Please install Docker Desktop or OrbStack.' }); } catch { /* ignore */ } + resolve(); + }); + }); + } catch (err) { + try { this._panel.webview.postMessage({ type: 'dockerPreflightResult', ok: false, error: String(err) }); } catch { /* ignore */ } + } + } + + private async _handleDockerFindOrCreate(): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'dockerLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + try { + // Check if container exists (running or stopped) + const check = cp.spawnSync('docker', ['ps', '-a', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], { timeout: 8000, windowsHide: true }); + const status = check.stdout?.toString().trim() ?? ''; + + if (status) { + if (status.toLowerCase().startsWith('up')) { + log('✓ Found occ-openclaw — already running\n'); + } else { + log('Found occ-openclaw (stopped) — starting...\n'); + const start = cp.spawnSync('docker', ['start', 'occ-openclaw'], { timeout: 15000, windowsHide: true }); + if (start.status !== 0) { + fail(`Failed to start container: ${start.stderr?.toString().trim() ?? 'unknown error'}`); + return; + } + log('✓ Container started\n'); + } + } else { + // Pull image + log('Container not found — pulling node:22-slim...\n'); + const pullCode = await new Promise((resolve) => { + const proc = cp.spawn('docker', ['pull', 'node:22-slim'], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString())); + proc.on('close', (code) => resolve(code ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (pullCode !== 0) { fail('Failed to pull node:22-slim. Check your internet connection.'); return; } + + // Prepare state dir on host Desktop (mounted as entire .openclaw dir in container) + const wsDir = path.join(os.homedir(), 'Desktop', 'occ-state-dir'); + try { fs.mkdirSync(wsDir, { recursive: true }); } catch { /* ok */ } + this._dockerWorkspaceHostPath = wsDir; + + // Create container with full openclaw state dir mounted + log('\nCreating occ-openclaw container...\n'); + const createCode = await new Promise((resolve) => { + const proc = cp.spawn('docker', [ + 'run', '-d', + '--name', 'occ-openclaw', + '--restart', 'unless-stopped', + '-p', '18789:18789', + '-v', `${wsDir}:/root/.openclaw`, + 'node:22-slim', + 'tail', '-f', '/dev/null', + ], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (code) => resolve(code ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (createCode !== 0) { fail('Failed to create occ-openclaw container.'); return; } + log('✓ Container created\n'); + } + + try { this._panel.webview.postMessage({ type: 'dockerContainerReady' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + private async _handleDockerInstallCli(): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'dockerLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + try { + // Check if already installed + log('Checking for OpenClaw CLI in container...\n'); + const check = cp.spawnSync('docker', ['exec', 'occ-openclaw', 'which', 'openclaw'], { timeout: 8000, windowsHide: true }); + const alreadyInstalled = check.status === 0 && check.stdout.toString().trim().length > 0; + + if (!alreadyInstalled) { + // Ensure curl is available then run installer + log('Installing curl and OpenClaw...\n'); + const installCode = await new Promise((resolve) => { + const proc = cp.spawn('docker', [ + 'exec', 'occ-openclaw', + 'bash', '-c', + 'apt-get update -qq 2>&1 && apt-get install -y -qq curl 2>&1 && curl -fsSL https://get.openclaw.sh | bash', + ], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (code) => resolve(code ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (installCode !== 0) { fail('OpenClaw installation failed. See the log above for details.'); return; } + log('\n✓ OpenClaw installed\n'); + } else { + log('✓ OpenClaw already installed — skipping\n'); + } + + // Register docker host and set as active + log('\nRegistering Docker host...\n'); + const entry = await this._coreAPI.addHost({ + type: 'docker', + label: 'Docker (occ-openclaw)', + connection: { type: 'docker', containerLabel: 'occ-openclaw', portMappings: { gateway: 18789 } }, + lastStatus: 'online', + }); + await this._coreAPI.setActiveHost(entry.id); + + // Ensure workspace host path is set (existing container scenario) + if (!this._dockerWorkspaceHostPath) { + this._dockerWorkspaceHostPath = path.join(os.homedir(), 'Desktop', 'occ-state-dir'); + try { fs.mkdirSync(this._dockerWorkspaceHostPath, { recursive: true }); } catch { /* ok */ } + } + + // Open the state dir as a workspace folder + const wsUri = vscode.Uri.file(this._dockerWorkspaceHostPath); + vscode.workspace.updateWorkspaceFolders( + vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders.length : 0, + null, + { uri: wsUri, name: 'occ-state-dir' }, + ); + + log('✓ Done — loading setup...\n'); + try { this._panel.webview.postMessage({ type: 'dockerInstallDone' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + private async _handleDockerConfigure(provider: string, apiKey: string, port: string): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'configureLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + const providerFlags: Record = { + anthropic: ['--auth-choice', 'apiKey', '--anthropic-api-key', apiKey], + openai: ['--auth-choice', 'openai-api-key', '--openai-api-key', apiKey], + openrouter: ['--auth-choice', 'openrouter-api-key', '--openrouter-api-key', apiKey], + gemini: ['--auth-choice', 'gemini-api-key', '--gemini-api-key', apiKey], + }; + const flags = providerFlags[provider]; + if (!flags) { fail(`Unknown provider: ${provider}`); return; } + + const gwPort = /^\d+$/.test(port) ? port : '18789'; + const args = [ + 'onboard', + '--non-interactive', '--accept-risk', + '--flow', 'quickstart', + '--gateway-auth', 'token', + '--gateway-port', gwPort, + '--skip-channels', '--skip-skills', '--skip-health', + ...flags, + ]; + + try { + log('Configuring OpenClaw...\n'); + const code = await new Promise((resolve) => { + const proc = cp.spawn('docker', ['exec', 'occ-openclaw', 'openclaw', ...args], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (c) => resolve(c ?? -1)); + proc.on('error', () => resolve(-1)); + }); + + if (code !== 0) { + fail('Setup failed. See the log above for details.'); + return; + } + + log('\n\u2713 Setup complete!\n'); + try { this._panel.webview.postMessage({ type: 'configureDone' }); } catch { /* ignore */ } + setTimeout(() => void this._showStatusPanel(), 1800); + } catch (err) { + fail(String(err)); + } + } + + // ── HTML ─────────────────────────────────────────────────────────────────── + + private _getHtml(iconUri: string): string { + return ` + + + + + + + + +
Docker Installation
+
Setting up OpenClaw in a Docker container
+ + +
+
+
1
+
Docker
Check
+
+
+
2
+
Container
Setup
+
+
+
3
+
Install
CLI
+
+
+
4
+
Configure
AI
+
+
+ + +
+ +
+
+
+ Checking Docker... +
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw-local/package.json b/apps/editor/extensions/openclaw-local/package.json index d34547dd..1fc26106 100644 --- a/apps/editor/extensions/openclaw-local/package.json +++ b/apps/editor/extensions/openclaw-local/package.json @@ -10,7 +10,9 @@ "categories": ["Other"], "activationEvents": ["onStartupFinished"], "main": "./out/openclaw-local/src/extension", - "contributes": {}, + "contributes": { + "commands": [{ "command": "openclaw.host.setup.local", "title": "OpenClaw: Set up Local" }] + }, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-local/src/extension.ts b/apps/editor/extensions/openclaw-local/src/extension.ts index e0619b15..c1097f18 100644 --- a/apps/editor/extensions/openclaw-local/src/extension.ts +++ b/apps/editor/extensions/openclaw-local/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; import { LocalHostAdapter } from './adapter'; +import { LocalSetupPanel } from './setup-panel'; export async function activate(context: vscode.ExtensionContext): Promise { // Grab the core API exported by the openclaw.home extension @@ -23,6 +24,11 @@ export async function activate(context: vscode.ExtensionContext): Promise const disposable = coreAPI.registerHostAdapter(adapter); context.subscriptions.push(disposable); + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.local', () => { + LocalSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + console.log('[openclaw-local] LocalHostAdapter registered'); } diff --git a/apps/editor/extensions/openclaw-local/src/setup-panel.ts b/apps/editor/extensions/openclaw-local/src/setup-panel.ts new file mode 100644 index 00000000..07765ac7 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/setup-panel.ts @@ -0,0 +1,1412 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { DefaultLocalHostConnection } from '../../openclaw/src/hosts/localDefault'; +import { StatusPanelController } from '../../openclaw/src/panels/statusController'; + +// ── Persistent diagnostics log ──────────────────────────────────────────────── +const LOG_PATH = path.join(os.homedir(), '.openclaw', 'occ-home.log'); +const LOG_MAX_BYTES = 512 * 1024; // 500 KB + +const _ansiRe = /\x1b(\[[0-9;]*[A-Za-z]|[^[])/g; + +function writeLog(text: string): void { + try { + const dir = path.dirname(LOG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + if (fs.existsSync(LOG_PATH) && fs.statSync(LOG_PATH).size > LOG_MAX_BYTES) { + const lines = fs.readFileSync(LOG_PATH, 'utf-8').split('\n'); + fs.writeFileSync(LOG_PATH, lines.slice(Math.floor(lines.length / 2)).join('\n'), 'utf-8'); + } + const ts = new Date().toISOString(); + const clean = text.replace(_ansiRe, ''); + const stamped = clean + .split('\n') + .map(l => (l.trim() ? `[${ts}] ${l}` : l)) + .join('\n'); + fs.appendFileSync(LOG_PATH, stamped, 'utf-8'); + } catch { /* non-fatal */ } +} + +// ── OCC Legacy model constants ──────────────────────────────────────────────── +const OCC_LEGACY_MODEL_ID = 'occ-legacy'; +const OCC_LEGACY_MODEL_NAME = 'occ-legacy'; +const OCC_LEGACY_BASE_URL = 'https://occ.mba.sh/v1'; +const OCC_LEGACY_API = 'openai-completions'; +const OCC_LEGACY_COST = { + input: 0.0000006, + output: 0.000003, + cacheRead: 0.0000001, + cacheWrite: 0, +}; +const OCC_LEGACY_CONTEXT_WINDOW = 262144; +const OCC_LEGACY_MAX_TOKENS = 262144; + +export class LocalSetupPanel { + public static currentPanel: LocalSetupPanel | undefined; + private static _installedCliPath: string | undefined; + /** Resolves with the password (or undefined on cancel) when the webview modal submits. */ + private _pendingPasswordResolve: ((pwd: string | undefined) => void) | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private readonly _coreAPI: OpenClawCoreAPI; + private _disposables: vscode.Disposable[] = []; + private _host = new DefaultLocalHostConnection(); + private _statusController: StatusPanelController | undefined; + + /** Returns the home extension URI (has media/icon.png and media/emojis/). */ + private get _homeUri(): vscode.Uri { + const homeExt = vscode.extensions.getExtension('openclaw.home'); + return homeExt?.extensionUri ?? this._extensionUri; + } + + public static createOrShow(extensionUri: vscode.Uri, coreAPI: OpenClawCoreAPI): void { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (LocalSetupPanel.currentPanel) { + LocalSetupPanel.currentPanel._panel.reveal(column); + return; + } + + const homeExt = vscode.extensions.getExtension('openclaw.home'); + const homeUri = homeExt?.extensionUri ?? extensionUri; + const iconUri = vscode.Uri.joinPath(homeUri, 'media', 'icon.png'); + + const panel = vscode.window.createWebviewPanel( + 'openclawLocalSetup', + 'OCC Home [Local]', + column ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(homeUri, 'media'), + ], + retainContextWhenHidden: true, + }, + ); + + LocalSetupPanel.currentPanel = new LocalSetupPanel(panel, extensionUri, coreAPI, iconUri); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + coreAPI: OpenClawCoreAPI, + iconUri: vscode.Uri, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + this._coreAPI = coreAPI; + + void this._initHtml(iconUri); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + async (msg: { command: string; [key: string]: unknown }) => { + // When the status panel is active, delegate all messages to it. + if (this._statusController) { + this._statusController.handleMessage(msg); + return; + } + if (msg.command === 'openclaw.install') { + void this._runInstall(); + } else if (msg.command === 'runSetup') { + void this._runSetup(msg as { command: string; provider: string; apiKey: string; port: string }); + } else if (msg.command === 'autoSetupSkipped') { + setTimeout(() => { + void this._showStatusPanel(); + void vscode.commands.executeCommand('openclaw.openWorkspace'); + setTimeout(() => { + void vscode.commands.executeCommand('void.openChatWithMessage', + 'Run `openclaw gateway start` to start the OpenClaw gateway.', + 'agent'); + }, 1000); + }, 500); + } else if (msg.command === 'verifyCliBeforeSetup') { + const storedPath = LocalSetupPanel._installedCliPath; + if (storedPath && fs.existsSync(storedPath)) { + try { this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); } catch {} + return; + } + void this._host.testOpenClawCli().then(result => { + if (result.ok) { + try { + this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); + } catch {} + } else { + try { + this._panel.webview.postMessage({ + type: 'installLog', + text: '\n⚠️ OpenClaw was installed but could not be found in PATH.\n' + + ' Please restart OCCode to pick up the new PATH.\n' + }); + } catch {} + } + }); + } else if (msg.command === 'sudoPassword') { + this._pendingPasswordResolve?.(msg.password as string | undefined); + this._pendingPasswordResolve = undefined; + } else if (msg.command === 'signIn') { + void vscode.env.openExternal(vscode.Uri.parse('https://occ.mba.sh/login?ref=occ-editor')); + } else if (msg.command === 'openDashboard') { + void vscode.env.openExternal(vscode.Uri.parse('https://occ.mba.sh/dashboard')); + } else if (msg.command === 'signOut') { + void vscode.commands.executeCommand('occ.auth.setLegacyJwt', ''); + void vscode.commands.executeCommand('occ.auth.setMoltpilotKey', ''); + void vscode.commands.executeCommand('openclaw.jwt.set', ''); + } else if (msg.command === 'closePanel') { + this.dispose(); + } else if (msg.command) { + void vscode.commands.executeCommand(msg.command); + } + }, + null, + this._disposables, + ); + } + + public dispose(): void { + LocalSetupPanel.currentPanel = undefined; + this._statusController?.dispose(); + this._statusController = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) { d.dispose(); } + } + } + + private async _initHtml(iconUri: vscode.Uri): Promise { + const configPath = await this._host.getConfigPath(); + const isInstalled = await this._host.exists(configPath); + + if (isInstalled) { + await this._showStatusPanel(); + return; + } + + const webviewIconUri = this._panel.webview.asWebviewUri(iconUri); + + const occJwt = await vscode.commands.executeCommand('occ.auth.getLegacyJwt').then(r => r ?? '', () => ''); + + let occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null; + if (occJwt) { + try { + const r = await fetch('https://occ.mba.sh/api/v1/me', { + headers: { Authorization: `Bearer ${occJwt}` }, + }); + if (r.ok) occUser = await r.json() as { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null }; + } catch { /* network error */ } + } + + this._panel.webview.html = this._getHtml(isInstalled, webviewIconUri.toString(), occUser); + } + + /** Switch this panel to show the full status panel (with gateway controls, AI model, etc.). */ + private async _showStatusPanel(): Promise { + if (this._statusController) { + await this._statusController._update(); + return; + } + this._statusController = new StatusPanelController(this._panel, this._homeUri, this._host); + await this._statusController.show(); + } + + private async _runInstall(): Promise { + const platform = process.platform; + const arch = process.arch; + const shell = process.env.SHELL || ''; + + const post = (msg: object) => { try { this._panel.webview.postMessage(msg); } catch {} }; + let fullLog = ''; + const tee = (text: string) => { fullLog += text; post({ type: 'installLog', text }); writeLog(text); }; + + writeLog(`\n=== _runInstall START platform=${platform} arch=${arch} ===\n`); + post({ type: 'installState', state: 'running' }); + + const env = this._host.buildExecEnv(); + const isPermError = (s: string) => /EACCES|permission denied|EPERM|not permitted|Need sudo access|needs to be an Administrator/i.test(s); + + const cmdExists = (cmd: string): Promise => + new Promise(resolve => + cp.exec(cmd, { env, timeout: 5000, windowsHide: true }, err => resolve(!err)) + ); + + const runCaptured = (cmd: string, args: string[], opts: cp.SpawnOptions = {}): Promise<{ code: number }> => + new Promise(resolve => { + const child = cp.spawn(cmd, args, { env, stdio: ['ignore', 'pipe', 'pipe'], ...opts }); + child.stdout?.on('data', (d: Buffer) => tee(d.toString())); + child.stderr?.on('data', (d: Buffer) => tee(d.toString())); + child.on('close', code => resolve({ code: code ?? 1 })); + child.on('error', err => { tee(`\nError: ${err.message}\n`); resolve({ code: 1 }); }); + }); + + const cacheSudo = async (_prompt: string): Promise => { + const password = await new Promise(resolve => { + this._pendingPasswordResolve = resolve; + post({ type: 'requestPassword' }); + }); + if (!password) return false; + tee('Verifying credentials...\n'); + return new Promise(resolve => { + const child = cp.spawn('sudo', ['-S', '-v'], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + child.stdin?.write(password + '\n'); + child.stdin?.end(); + child.on('close', code => resolve(code === 0)); + child.on('error', () => resolve(false)); + }); + }; + + const fixOpenclawPermissions = async () => { + if (platform === 'win32') return; + const openclawDir = path.join(os.homedir(), '.openclaw'); + if (!fs.existsSync(openclawDir)) return; + try { + const username = os.userInfo().username; + tee('Fixing .openclaw folder ownership...\n'); + await runCaptured('sudo', ['-n', 'chown', '-R', username, openclawDir]); + await runCaptured('chmod', ['700', openclawDir]); + tee('✅ Permissions set (700, owned by you)\n'); + } catch { /* non-fatal */ } + }; + + const failCancelled = () => { + post({ type: 'installState', state: 'cancelled' }); + }; + + const fail = async () => { + writeLog('=== _runInstall END failed ===\n'); + post({ type: 'installState', state: 'failed' }); + const platformDesc = platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : `Linux (${arch})`; + await vscode.commands.executeCommand('void.openChatWithMessage', [ + `OpenClaw installation failed on **${platformDesc}**.`, + ``, `**System info:**`, + `- Node.js: \`${process.version}\``, + `- Shell: \`${shell || 'unknown'}\``, + ``, `**Full output:**`, `\`\`\``, fullLog.trim(), `\`\`\``, ``, + `Please diagnose what went wrong and provide exact steps to fix it on this platform.`, + `If Node.js or npm is missing, explain how to install them first.`, + ].join('\n')); + void vscode.commands.executeCommand('openclaw.balance.spend'); + }; + + const captureInstalledPath = async (): Promise => { + if (platform === 'win32') return; + const candidates: string[] = []; + const prefixResult = await new Promise(resolve => { + cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => + resolve(err ? '' : (stdout || '').trim()) + ); + }); + if (prefixResult) candidates.push(path.join(prefixResult, 'bin', 'openclaw')); + const home = os.homedir(); + candidates.push( + '/usr/local/bin/openclaw', + '/opt/homebrew/bin/openclaw', + path.join(home, '.local', 'bin', 'openclaw'), + path.join(home, '.npm-global', 'bin', 'openclaw'), + path.join(home, '.openclaw', 'bin', 'openclaw'), + ); + const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); + if (fs.existsSync(nvmVersionsDir)) { + fs.readdirSync(nvmVersionsDir) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + .forEach(ver => candidates.push(path.join(nvmVersionsDir, ver, 'bin', 'openclaw'))); + } + const found = candidates.find(c => c && fs.existsSync(c)); + if (found) { + LocalSetupPanel._installedCliPath = found; + tee(` ✓ Binary found at ${found}\n`); + } + }; + + const succeed = async () => { + await captureInstalledPath(); + tee('\n✅ Installed successfully!\n'); + writeLog('=== _runInstall END ok ===\n'); + post({ type: 'installState', state: 'done' }); + }; + + // ── PREREQUISITE CHECKS ────────────────────────────────────────────────── + tee('Checking prerequisites...\n'); + + if (platform === 'darwin') { + const xcodeOk = await new Promise(resolve => + cp.exec('xcode-select -p', { env, timeout: 5000 }, (err, stdout) => { + resolve(!err && !!stdout?.toString().trim()); + }) + ); + if (!xcodeOk) { + post({ type: 'xcodeRequired' }); + return; + } + tee(' ✓ Xcode Command Line Tools\n'); + } + + if (platform === 'win32') { + let nodeOk = await cmdExists('node --version'); + if (!nodeOk) { + const nodeVersion = '20.18.2'; + const nodeArch = arch === 'arm64' ? 'arm64' : 'x64'; + const zipName = `node-v${nodeVersion}-win-${nodeArch}.zip`; + const zipUrl = `https://nodejs.org/dist/v${nodeVersion}/${zipName}`; + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + const installDir = path.join(localAppData, 'Programs', 'nodejs'); + const tmpZip = path.join(os.tmpdir(), zipName); + const tmpExtract = path.join(os.tmpdir(), `occ-node-${Date.now()}`); + + tee(` ⚠ Node.js not found — downloading v${nodeVersion} (${nodeArch})...\n`); + const dlR = await runCaptured('powershell', [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing '${zipUrl}' -OutFile '${tmpZip}'`, + ], { windowsHide: true, shell: true } as cp.SpawnOptions); + + if (dlR.code !== 0) { + tee(' ❌ Failed to download Node.js. Check your internet connection.\n'); + await fail(); return; + } + + tee(` Extracting...\n`); + const innerDir = path.join(tmpExtract, `node-v${nodeVersion}-win-${nodeArch}`); + const exR = await runCaptured('powershell', [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + `$ProgressPreference='SilentlyContinue'; ` + + `Expand-Archive -Path '${tmpZip}' -DestinationPath '${tmpExtract}' -Force; ` + + `if (Test-Path '${installDir}') { Remove-Item '${installDir}' -Recurse -Force }; ` + + `Move-Item '${innerDir}' '${installDir}'`, + ], { windowsHide: true, shell: true } as cp.SpawnOptions); + + try { fs.unlinkSync(tmpZip); } catch {} + + if (exR.code !== 0) { + tee(' ❌ Failed to extract Node.js.\n'); + await fail(); return; + } + + env.PATH = [installDir, env.PATH || ''].filter(Boolean).join(';'); + (env as Record).Path = env.PATH; + + nodeOk = await cmdExists('node --version'); + if (!nodeOk) { + tee(' ❌ Node.js install did not complete properly.\n'); + await fail(); return; + } + tee(` ✓ Node.js v${nodeVersion} installed to ${installDir}\n`); + } else { + tee(' ✓ Node.js found\n'); + } + } + + const npmOk = await cmdExists('npm --version'); + if (npmOk) { + tee(' ✓ npm found\n'); + } else if (platform === 'win32') { + tee(' ❌ npm not found after Node.js install — unexpected.\n'); + await fail(); return; + } else { + tee(' ⚠ npm not found — will attempt to install Node.js\n'); + } + + let sudoCached = false; + if (platform !== 'win32') { + let needsSudo = false; + if (npmOk) { + const prefixResult = await new Promise(resolve => + cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => + resolve(err ? '' : stdout?.toString().trim() || '')) + ); + if (prefixResult) { + try { + const gBin = path.join(prefixResult, 'bin'); + const gLib = path.join(prefixResult, 'lib'); + if (fs.existsSync(gBin)) fs.accessSync(gBin, fs.constants.W_OK); + if (fs.existsSync(gLib)) fs.accessSync(gLib, fs.constants.W_OK); + } catch { + needsSudo = true; + } + } + } else { + for (const dir of ['/usr/local/bin', '/usr/local/lib']) { + try { if (fs.existsSync(dir)) fs.accessSync(dir, fs.constants.W_OK); } catch { needsSudo = true; break; } + } + } + + if (needsSudo) { + tee('\n Administrator password required for installation.\n'); + const sudoOk = await cacheSudo('Enter your system password to install OpenClaw'); + if (!sudoOk) { tee(' Incorrect password or cancelled.\n'); failCancelled(); return; } + tee(' ✓ Credentials verified\n'); + sudoCached = true; + } else { + tee(' ✓ Write access OK\n'); + } + } + + tee('\n'); + + if (npmOk) { + tee('Installing openclaw via npm...\n'); + const spawnOpts: cp.SpawnOptions = platform === 'win32' ? { shell: true, windowsHide: true } : {}; + const npmArgs = ['install', '-g', 'openclaw']; + const r1 = sudoCached + ? await runCaptured('sudo', ['-E', 'npm', ...npmArgs]) + : await runCaptured('npm', npmArgs, spawnOpts); + if (r1.code === 0) { + if (sudoCached) await fixOpenclawPermissions(); + await succeed(); return; + } + if (!sudoCached && platform !== 'win32' && isPermError(fullLog)) { + tee('\nPermission error — elevated access required.\n'); + const ok = await cacheSudo('Enter your system password to install OpenClaw'); + if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + sudoCached = true; + tee('Retrying with elevated permissions...\n'); + const r2 = await runCaptured('sudo', ['-E', 'npm', 'install', '-g', 'openclaw']); + if (r2.code === 0) { + await fixOpenclawPermissions(); + await succeed(); return; + } + } + tee('\nnpm install did not succeed — trying full installer script...\n'); + } else if (platform !== 'win32') { + + const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); + if (fs.existsSync(nvmSh)) { + tee('nvm detected — installing Node.js LTS...\n'); + const nvmR = await runCaptured('bash', ['-c', + `. "${nvmSh}" && nvm install --lts && nvm use --lts && npm install -g openclaw` + ]); + if (nvmR.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } + tee('nvm install failed — falling back to system install...\n'); + } + + if (!sudoCached) { + tee('\nNode.js is required. Your password is needed once to install it.\n'); + const sudoOk = await cacheSudo('Enter your password to install Node.js'); + if (!sudoOk) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + sudoCached = true; + } + + if (platform === 'darwin') { + const nodeVersion = '20.18.2'; + const pkgUrl = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}.pkg`; + const pkgPath = `/tmp/.occ-node-${nodeVersion}.pkg`; + tee(`Downloading Node.js v${nodeVersion}...\n`); + const dlR = await runCaptured('curl', ['-fsSL', pkgUrl, '-o', pkgPath]); + if (dlR.code !== 0) { try { fs.unlinkSync(pkgPath); } catch {} await fail(); return; } + tee('Installing Node.js (this may take a moment)...\n'); + const instR = await runCaptured('sudo', ['-n', 'installer', '-pkg', pkgPath, '-target', '/']); + try { fs.unlinkSync(pkgPath); } catch { /* non-fatal */ } + if (instR.code !== 0) { await fail(); return; } + } else { + const hasCmdSync = (cmd: string): boolean => { + try { cp.execSync(`which ${cmd}`, { env, stdio: 'ignore' }); return true; } catch { return false; } + }; + tee('Installing Node.js via package manager...\n'); + let pkgResult: { code: number } | undefined; + if (hasCmdSync('apt-get')) { + pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', + 'curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get install -y nodejs' + ]); + } else if (hasCmdSync('dnf')) { + pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', + 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && dnf install -y nodejs' + ]); + } else if (hasCmdSync('yum')) { + pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', + 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && yum install -y nodejs' + ]); + } + if (!pkgResult) { tee('No supported package manager found (tried apt-get, dnf, yum).\n'); await fail(); return; } + if (pkgResult.code !== 0) { await fail(); return; } + } + + tee('Installing OpenClaw...\n'); + const npmCandidates = ['/usr/local/bin/npm', '/usr/bin/npm']; + const npmBin = npmCandidates.find(p => fs.existsSync(p)) ?? 'npm'; + const npmR1 = await runCaptured(npmBin, ['install', '-g', 'openclaw']); + if (npmR1.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } + if (isPermError(fullLog)) { + const npmR2 = await runCaptured('sudo', ['-n', npmBin, 'install', '-g', 'openclaw']); + if (npmR2.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } + } + await fail(); return; + + } else { + tee('npm not found — running full installer script...\n'); + } + + if (platform === 'win32') { + tee('Running PowerShell installer...\n'); + const psArgs = [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; ` + + `Invoke-WebRequest -UseBasicParsing https://openclaw.ai/install.ps1 | Invoke-Expression`, + ]; + const r = await runCaptured('powershell', psArgs, { windowsHide: true } as cp.SpawnOptions); + if (r.code === 0) { await succeed(); return; } + } else { + tee('Running install script...\n'); + const r1 = await runCaptured('bash', ['-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); + if (r1.code === 0) { + await fixOpenclawPermissions(); + await succeed(); return; + } + if (isPermError(fullLog)) { + tee('\nPermission error in installer — elevated access required.\n'); + const ok = await cacheSudo('Enter your system password to complete installation'); + if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + tee('Retrying with elevated permissions...\n'); + const r2 = await runCaptured('sudo', ['-E', 'bash', '-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); + if (r2.code === 0) { + await fixOpenclawPermissions(); + await succeed(); return; + } + } + } + + await fail(); + } + + private async _runSetup(data: { provider: string; apiKey: string; port: string }): Promise { + const post = (msg: object) => { try { this._panel.webview.postMessage(msg); } catch {} }; + const wizardPost = (text: string, done: boolean, ok: boolean) => { + writeLog(text); + post({ type: 'wizardLog', text, done, ok }); + }; + const env = this._host.buildExecEnv(); + + if (process.platform !== 'win32') { + const nodeResult = await this._host.exec('node', ['--version'], { env: env as Record, timeout: 5000 }); + const nodeVerRaw = nodeResult.stdout.trim(); + const nodeMinor = parseInt((nodeVerRaw.match(/^v?(\d+)/) || [])[1] || '0', 10); + if (nodeMinor < 22) { + const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); + if (fs.existsSync(nvmSh)) { + wizardPost(`Node.js ${nodeVerRaw || 'unknown'} detected — openclaw requires v22+. Installing Node.js 22 via nvm...\n`, false, false); + const nvmCode = await this._host.execStream( + 'bash', + ['-c', `. "${nvmSh}" && nvm install 22 && nvm use 22 && nvm alias default 22`], + { env: env as Record }, + (d) => wizardPost(d, false, false), + (d) => wizardPost(d, false, false), + ); + if (nvmCode === 0) { + const nvmVersionsDir = path.join(os.homedir(), '.nvm', 'versions', 'node'); + const v22dirs = fs.existsSync(nvmVersionsDir) + ? fs.readdirSync(nvmVersionsDir).filter(v => /^v?22/.test(v)) + : []; + if (v22dirs.length > 0) { + const v22bin = path.join(nvmVersionsDir, v22dirs[0], 'bin'); + env.PATH = [v22bin, env.PATH || ''].filter(Boolean).join(':'); + (env as Record).Path = env.PATH; + } + wizardPost(' ✓ Node.js 22 ready.\n', false, false); + } else { + wizardPost(' ⚠️ Node.js 22 install via nvm failed. Setup may fail.\n', false, false); + } + } else { + wizardPost(`⚠️ Node.js ${nodeVerRaw || 'v20'} is active but openclaw requires v22+.\n Please install Node.js 22 (e.g. via https://nodejs.org) and restart OCCode.\n`, true, false); + return; + } + } + } + + const cliPath = await this._host.findOpenClawPath() ?? 'openclaw'; + const port = data.port && /^\d+$/.test(data.port) ? data.port : '18789'; + const isFree = data.provider === 'free'; + + const providerFlags: Record = { + free: [ + '--auth-choice', 'custom-api-key', + '--custom-base-url', 'https://occ.mba.sh/v1', + '--custom-api-key', data.apiKey, + '--custom-model-id', 'occ-legacy', + '--custom-compatibility', 'openai', + ], + anthropic: ['--auth-choice', 'apiKey', '--anthropic-api-key', data.apiKey], + openai: ['--auth-choice', 'openai-api-key', '--openai-api-key', data.apiKey], + openrouter: ['--auth-choice', 'openrouter-api-key', '--openrouter-api-key', data.apiKey], + gemini: ['--auth-choice', 'gemini-api-key', '--gemini-api-key', data.apiKey], + ollama: [ + '--auth-choice', 'custom-api-key', + '--custom-base-url', data.apiKey || 'http://localhost:11434', + '--custom-api-key', 'ollama', + '--custom-model-id', 'llama3', + '--custom-compatibility', 'openai', + ], + }; + const flags = providerFlags[data.provider]; + if (!flags) { + wizardPost('Unknown provider selected.\n', true, false); + return; + } + + const args = [ + 'onboard', + '--non-interactive', '--accept-risk', + '--flow', 'quickstart', + '--gateway-auth', 'token', + '--gateway-port', port, + '--skip-channels', '--skip-skills', '--skip-health', + ...flags, + ]; + + writeLog(`\n=== _runSetup START provider=${data.provider} port=${port} ===\n`); + wizardPost(isFree ? 'Installing Inference for MoltPilot...\nInstalling Inference for your new OpenClaw...\n' : 'Installing Inference for your new OpenClaw...\n', false, false); + + let exitCode: number; + try { + exitCode = await this._host.execStream( + cliPath, args, + { + env: env as Record, + timeout: 120_000, + ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), + }, + (d) => wizardPost(d, false, false), + (d) => wizardPost(d, false, false), + ); + } catch (err) { + wizardPost(`Error: ${String(err)}\n`, true, false); + return; + } + + const ok = exitCode === 0; + writeLog(`=== _runSetup END code=${exitCode} ===\n`); + wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${exitCode}.\n`, true, ok); + + if (ok) { + if (isFree) { + try { + const occDir = path.join(os.homedir(), '.occ'); + if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } + fs.writeFileSync( + path.join(occDir, 'moltpilot-tier.json'), + JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), + ); + } catch { /* non-fatal */ } + try { + const cfg = await this._host.readConfig() as Record; + const patchModel = (obj: unknown): boolean => { + if (!obj || typeof obj !== 'object') return false; + if (Array.isArray(obj)) { + for (const item of obj) { if (patchModel(item)) return true; } + return false; + } + const o = obj as Record; + if (o['id'] === OCC_LEGACY_MODEL_ID) { + o['name'] = OCC_LEGACY_MODEL_NAME; + o['reasoning'] = false; + o['input'] = ['text']; + o['cost'] = { ...OCC_LEGACY_COST }; + o['contextWindow'] = OCC_LEGACY_CONTEXT_WINDOW; + o['maxTokens'] = OCC_LEGACY_MAX_TOKENS; + return true; + } + for (const v of Object.values(o)) { patchModel(v); } + return false; + }; + patchModel(cfg); + await this._host.writeConfig(cfg); + } catch { /* non-fatal */ } + } + setTimeout(() => { + void this._showStatusPanel(); + if (isFree) { + void vscode.commands.executeCommand('openclaw.openWorkspace'); + } + setTimeout(() => { + void vscode.commands.executeCommand('void.openChatWithMessage', + 'Run `openclaw gateway start` to start the OpenClaw gateway.', + 'agent'); + }, 1000); + }, 1500); + } + } + + private _getHtml( + isInstalled: boolean, + iconUri: string, + occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null + ): string { + let userAreaHtml: string; + if (!occUser) { + userAreaHtml = ``; + } else { + const initial = (occUser.email || '?')[0].toUpperCase(); + const safeEmail = occUser.email.replace(/"/g, '"').replace(/` + : initial; + userAreaHtml = ` +
+ +
+
+
${avatarImg}
+
${safeEmail}
+
+ +
+ +
+
`; + } + + const providers = [ + { id: 'anthropic', label: 'Anthropic Claude', hint: 'console.anthropic.com/settings/keys', placeholder: 'sk-ant-...' }, + { id: 'openai', label: 'OpenAI', hint: 'platform.openai.com/api-keys', placeholder: 'sk-...' }, + { id: 'openrouter', label: 'OpenRouter', hint: 'openrouter.ai/settings/keys', placeholder: 'sk-or-...' }, + { id: 'gemini', label: 'Google Gemini', hint: 'aistudio.google.com/apikey', placeholder: 'AIza...' }, + ]; + + const providerCards = providers.map(p => + `` + ).join('\n '); + + return ` + + + + + + + + +
${userAreaHtml}
+ + + +
Set up OpenClaw
+
Follow the steps below to get started
+ + +
+
+
${isInstalled ? '✓' : '1'}
+
Install
OpenClaw
+
+
+
2
+
Configure
AI Model
+
+
+
3
+
Ready
+
+
+ + +
+ + +
+ + + + + + + + + + + + + + +
+
+
Working
+ + + +
+ + + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/package.json b/apps/editor/extensions/openclaw-ssh/package.json index 323749bd..47df9f67 100644 --- a/apps/editor/extensions/openclaw-ssh/package.json +++ b/apps/editor/extensions/openclaw-ssh/package.json @@ -9,8 +9,10 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/extension", - "contributes": {}, + "main": "./out/openclaw-ssh/src/extension", + "contributes": { + "commands": [{ "command": "openclaw.host.setup.ssh", "title": "OpenClaw: Set up SSH" }] + }, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-ssh/src/adapter.ts b/apps/editor/extensions/openclaw-ssh/src/adapter.ts new file mode 100644 index 00000000..2d862a87 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/adapter.ts @@ -0,0 +1,71 @@ +import * as vscode from 'vscode'; +import type { + HostAdapter, + HostType, + HostConnection, + HostConnectionConfig, + SSHConnection, + TestResult, + DiscoveredHost, + ConfigField, + ConfigValidationResult, +} from '../../openclaw/src/hosts/types'; +import { SSHHostConnection } from './connection'; + +export class SSHHostAdapter implements HostAdapter { + readonly type: HostType = 'ssh'; + readonly displayName = 'SSH Server'; + readonly icon = new vscode.ThemeIcon('remote'); + + async discover(): Promise { + // Future: parse ~/.ssh/config for known hosts + return []; + } + + async connect(config: HostConnectionConfig): Promise { + return new SSHHostConnection(config as SSHConnection); + } + + async testConnection(config: HostConnectionConfig): Promise { + const sshCfg = config as SSHConnection; + const conn = new SSHHostConnection(sshCfg); + try { + const r = await conn.exec('echo', ['ok'], { timeout: 15000 }); + if (r.code !== 0 || !r.stdout.includes('ok')) { + return { success: false, message: `SSH connection failed: ${r.stderr.trim()}` }; + } + const cliCheck = await conn.testOpenClawCli(); + return { + success: true, + message: cliCheck.ok + ? `Connected to ${sshCfg.user}@${sshCfg.host}. OpenClaw ${cliCheck.output ?? ''}` + : `Connected to ${sshCfg.user}@${sshCfg.host}. OpenClaw not installed.`, + details: { + openclawInstalled: cliCheck.ok, + openclawVersion: cliCheck.output, + os: 'remote', + hostname: sshCfg.host, + }, + }; + } catch (err) { + return { success: false, message: `SSH error: ${String(err)}` }; + } + } + + getConfigFields(): ConfigField[] { + return [ + { id: 'host', label: 'Hostname or IP', type: 'text', placeholder: 'example.com or 192.168.1.10', required: true }, + { id: 'user', label: 'SSH User', type: 'text', placeholder: 'ubuntu, ec2-user, root...', required: true }, + { id: 'port', label: 'Port', type: 'number', placeholder: '22', defaultValue: 22 }, + { id: 'keyPath', label: 'SSH Key Path', type: 'text', placeholder: '~/.ssh/id_rsa (blank = SSH agent)', required: false }, + ]; + } + + validateConfig(config: HostConnectionConfig): ConfigValidationResult { + const sshCfg = config as SSHConnection; + const errors: { fieldId: string; message: string }[] = []; + if (!sshCfg.host) { errors.push({ fieldId: 'host', message: 'Hostname is required' }); } + if (!sshCfg.user) { errors.push({ fieldId: 'user', message: 'SSH user is required' }); } + return errors.length ? { valid: false, errors } : { valid: true }; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/src/connection.ts b/apps/editor/extensions/openclaw-ssh/src/connection.ts new file mode 100644 index 00000000..79dcfd99 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/connection.ts @@ -0,0 +1,268 @@ +import * as cp from 'child_process'; +import * as path from 'path'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + GatewayRunState, + OpenClawConfig, + SetupParams, + SSHConnection, +} from '../../openclaw/src/hosts/types'; + +export class SSHHostConnection implements HostConnection { + readonly type: HostType = 'ssh'; + + get id(): string { return `ssh:${this._cfg.user}@${this._cfg.host}`; } + get label(): string { return `${this._cfg.user}@${this._cfg.host}`; } + + constructor(private readonly _cfg: SSHConnection) {} + + dispose(): void {} + + // ── SSH helpers ─────────────────────────── + + private _sshBaseArgs(): string[] { + const args = [ + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15', + '-o', 'StrictHostKeyChecking=accept-new', + ]; + if (this._cfg.port && this._cfg.port !== 22) { args.push('-p', String(this._cfg.port)); } + if (this._cfg.keyPath) { args.push('-i', this._cfg.keyPath); } + if (this._cfg.jumpHost) { args.push('-J', this._cfg.jumpHost); } + return args; + } + + private _remote(): string { + return `${this._cfg.user}@${this._cfg.host}`; + } + + private _remoteHome(): string { + return this._cfg.user === 'root' ? '/root' : `/home/${this._cfg.user}`; + } + + // ── Process execution ───────────────────── + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + const remoteCmd = [cmd, ...args] + .map(a => `'${a.replace(/'/g, "'\\''")}'`) + .join(' '); + return new Promise((resolve, reject) => { + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), remoteCmd], { + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', reject); + proc.on('close', code => resolve({ stdout, stderr, code: code ?? -1 })); + }); + } + + execStream(cmd: string, args: string[], opts: ExecOpts, onData: LogFn, onError: LogFn): Promise { + const remoteCmd = [cmd, ...args] + .map(a => `'${a.replace(/'/g, "'\\''")}'`) + .join(' '); + return new Promise((resolve, reject) => { + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), remoteCmd], { + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + // ── Filesystem ──────────────────────────── + + async readFile(filePath: string): Promise { + const r = await this.exec('cat', [filePath]); + if (r.code !== 0) { throw new Error(`cat ${filePath} failed: ${r.stderr}`); } + return r.stdout; + } + + async writeFile(filePath: string, content: string): Promise { + const dir = path.posix.dirname(filePath); + await this.exec('mkdir', ['-p', dir]); + await new Promise((resolve, reject) => { + const quotedPath = filePath.replace(/'/g, "'\\''"); + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), `cat > '${quotedPath}'`], { windowsHide: true }); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('error', reject); + proc.on('close', code => code === 0 ? resolve() : reject(new Error(`writeFile failed: code ${code}`))); + }); + } + + async exists(filePath: string): Promise { + const r = await this.exec('test', ['-e', filePath]); + return r.code === 0; + } + + async mkdir(dirPath: string): Promise { + await this.exec('mkdir', ['-p', dirPath]); + } + + async stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + const r = await this.exec('stat', ['-c', '%s %F', filePath]); + if (r.code !== 0) { return null; } + const parts = r.stdout.trim().split(' '); + return { + size: parseInt(parts[0] ?? '0', 10), + isDirectory: (parts[1] ?? '').includes('directory'), + }; + } + + // ── CLI ─────────────────────────────────── + + async findOpenClawPath(): Promise { + const r = await this.exec('which', ['openclaw']); + if (r.code === 0) { + const p = r.stdout.trim().split('\n')[0]; + if (p) { return p; } + } + for (const p of [ + '/usr/local/bin/openclaw', + '/usr/bin/openclaw', + `${this._remoteHome()}/.local/bin/openclaw`, + ]) { + if (await this.exists(p)) { return p; } + } + return undefined; + } + + async isCliInstalled(): Promise { + return (await this.findOpenClawPath()) !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const r = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (r.stdout + r.stderr).trim(); + const m = out.match(/[\d]+\.[\d]+\.[\d]+/); + return m ? m[0] : out || null; + } catch { return null; } + } + + async testOpenClawCli(): Promise { + const p = await this.findOpenClawPath(); + const command = p ? `ssh ${this._remote()} ${p} --version` : `ssh ${this._remote()} openclaw --version`; + if (!p) { return { ok: false, command, error: 'OpenClaw CLI not found on remote host' }; } + try { + const r = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (r.stdout + r.stderr).trim(); + return r.code === 0 + ? { ok: true, command, output: out } + : { ok: false, command, error: `Exit ${r.code}: ${out}` }; + } catch (err) { return { ok: false, command, error: String(err) }; } + } + + async installCli(onLog: LogFn): Promise { + onLog('Installing OpenClaw on remote host...\n'); + const code = await this.execStream( + 'bash', ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], + { timeout: 120_000 }, onLog, onLog, + ); + if (code !== 0) { throw new Error(`Installer exited with code ${code}`); } + onLog('OpenClaw installed.\n'); + } + + // ── OpenClaw config ─────────────────────── + + async getConfigPath(): Promise { + return `${this._remoteHome()}/.openclaw/openclaw.json`; + } + + async readConfig(): Promise { + const p = await this.getConfigPath(); + if (!(await this.exists(p))) { return {}; } + try { return JSON.parse(await this.readFile(p)) as OpenClawConfig; } catch { return {}; } + } + + async writeConfig(patch: Partial): Promise { + const p = await this.getConfigPath(); + let existing: OpenClawConfig = {}; + if (await this.exists(p)) { + try { existing = JSON.parse(await this.readFile(p)) as OpenClawConfig; } catch { /* ok */ } + } + await this.writeFile(p, JSON.stringify({ ...existing, ...patch }, null, 2)); + } + + // ── Gateway ─────────────────────────────── + + async gatewayHealthCheck(): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { return { state: 'unknown' as GatewayRunState, error: 'CLI not installed' }; } + try { + const r = await this.exec(cliPath, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (r.code === 0) { + try { + const parsed = JSON.parse(r.stdout) as Partial; + return { + state: parsed.state ?? 'unknown', + port: parsed.port ?? this._cfg.gatewayPort, + version: parsed.version, + uptime: parsed.uptime, + }; + } catch { + const out = (r.stdout + r.stderr).toLowerCase(); + return { state: out.includes('running') ? 'running' : 'stopped', port: this._cfg.gatewayPort }; + } + } + return { state: 'stopped' as GatewayRunState }; + } catch { return { state: 'error' as GatewayRunState, error: 'Health check failed' }; } + } + + async gatewayStart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited with code ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited with code ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } + } + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed — call installCli first'); } + const code = await this.execStream( + p, + ['onboard', '--provider', params.provider, '--api-key', params.apiKey, '--port', params.port], + {}, onLog, onLog, + ); + if (code !== 0) { throw new Error(`onboard exited with code ${code}`); } + } + + // ── Environment ─────────────────────────── + + buildExecEnv(): Record { + return { ...process.env }; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/src/extension.ts b/apps/editor/extensions/openclaw-ssh/src/extension.ts index 55a38ee4..47ac0855 100644 --- a/apps/editor/extensions/openclaw-ssh/src/extension.ts +++ b/apps/editor/extensions/openclaw-ssh/src/extension.ts @@ -1,13 +1,28 @@ import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { SSHHostAdapter } from './adapter'; +import { SSHSetupPanel } from './setup-panel'; -/** - * openclaw-ssh — Preview stub. - * - * SSH host support is coming soon. This extension activates silently - * and will register an SSHHostAdapter once the implementation lands. - */ -export function activate(_context: vscode.ExtensionContext): void { - console.log('[openclaw-ssh] SSH adapter — coming soon'); +export async function activate(context: vscode.ExtensionContext): Promise { + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (!coreExt) { + console.warn('[openclaw-ssh] Core extension not found'); + return; + } + const coreAPI = coreExt.isActive ? coreExt.exports : await coreExt.activate(); + if (!coreAPI) { + console.warn('[openclaw-ssh] Core extension did not export API'); + return; + } + const disposable = coreAPI.registerHostAdapter(new SSHHostAdapter()); + context.subscriptions.push(disposable); + + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.ssh', () => { + SSHSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + + console.log('[openclaw-ssh] SSHHostAdapter registered'); } export function deactivate(): void {} diff --git a/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts b/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts new file mode 100644 index 00000000..dc5e7212 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts @@ -0,0 +1,341 @@ +import * as cp from 'child_process'; +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; + +export class SSHSetupPanel { + public static currentPanel: SSHSetupPanel | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _disposables: vscode.Disposable[] = []; + + public static createOrShow(extensionUri: vscode.Uri, coreAPI: OpenClawCoreAPI): void { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (SSHSetupPanel.currentPanel) { + SSHSetupPanel.currentPanel._panel.reveal(column); + return; + } + + const homeExt = vscode.extensions.getExtension('openclaw.home'); + const homeUri = homeExt?.extensionUri ?? extensionUri; + const iconUri = vscode.Uri.joinPath(homeUri, 'media', 'icon.png'); + + const panel = vscode.window.createWebviewPanel( + 'openclawSSHSetup', + 'OCC Home [SSH]', + column ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(homeUri, 'media'), + ], + retainContextWhenHidden: true, + }, + ); + + SSHSetupPanel.currentPanel = new SSHSetupPanel(panel, extensionUri, coreAPI, iconUri); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + private readonly _coreAPI: OpenClawCoreAPI, + iconUri: vscode.Uri, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + + const webviewIconUri = panel.webview.asWebviewUri(iconUri).toString(); + this._panel.webview.html = this._getHtml(webviewIconUri); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + async (msg: { command: string; host?: string; user?: string; port?: string; keyPath?: string }) => { + switch (msg.command) { + case 'sshInstallBegin': { + const host = msg.host ?? ''; + const user = msg.user ?? ''; + const port = parseInt(msg.port ?? '22', 10) || 22; + const keyPath = msg.keyPath || undefined; + await this._handleSSHInstall(host, user, port, keyPath); + break; + } + case 'closePanel': + this.dispose(); + break; + default: + if (msg.command) { + void vscode.commands.executeCommand(msg.command); + } + break; + } + }, + null, + this._disposables, + ); + } + + public dispose(): void { + SSHSetupPanel.currentPanel = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) { d.dispose(); } + } + } + + // ── Handler ──────────────────────────────────────────────────────────────── + + private async _handleSSHInstall(host: string, user: string, port: number, keyPath?: string): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'sshLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'sshError', text }); } catch { /* ignore */ } + }; + + try { + const sshBase = [ + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15', + '-o', 'StrictHostKeyChecking=accept-new', + ]; + if (port !== 22) { sshBase.push('-p', String(port)); } + if (keyPath) { sshBase.push('-i', keyPath); } + const remote = `${user}@${host}`; + + // Test connection + log(`Testing SSH connection to ${remote}...\n`); + const test = cp.spawnSync('ssh', [...sshBase, remote, 'echo ok'], { timeout: 15000, windowsHide: true }); + if (test.status !== 0 || !test.stdout.toString().includes('ok')) { + fail(`Cannot connect to ${remote}.\n${test.stderr?.toString().trim() || 'Check hostname, user, and SSH key.'}`); + return; + } + log(`\u2713 Connected to ${remote}\n`); + + // Check if openclaw already installed + log('Checking for OpenClaw CLI...\n'); + const check = cp.spawnSync('ssh', [...sshBase, remote, 'which openclaw'], { timeout: 8000, windowsHide: true }); + const alreadyInstalled = check.status === 0 && check.stdout.toString().trim().length > 0; + + if (!alreadyInstalled) { + log('Installing OpenClaw...\n'); + const code = await new Promise((resolve) => { + const proc = cp.spawn('ssh', [...sshBase, remote, 'curl -fsSL https://get.openclaw.sh | bash'], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (c) => resolve(c ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (code !== 0) { fail('OpenClaw installation failed. See the log above for details.'); return; } + log('\n\u2713 OpenClaw installed\n'); + } else { + log('\u2713 OpenClaw already installed \u2014 skipping\n'); + } + + // Register SSH host + log('\nRegistering SSH host...\n'); + const entry = await this._coreAPI.addHost({ + type: 'ssh', + label: `SSH (${user}@${host})`, + connection: { + type: 'ssh', + host, + port: port !== 22 ? port : undefined, + user, + authMethod: keyPath ? 'key' : 'agent', + keyPath: keyPath || undefined, + }, + lastStatus: 'online', + }); + await this._coreAPI.setActiveHost(entry.id); + + log('\u2713 Done \u2014 loading setup...\n'); + try { this._panel.webview.postMessage({ type: 'sshInstallDone' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + // ── HTML ─────────────────────────────────────────────────────────────────── + + private _getHtml(iconUri: string): string { + return ` + + + + + + + + +
SSH Installation
+
Install OpenClaw on a remote server via SSH
+ + +
+
+
+
+
Hostname or IP
+ +
+
+
Port
+ +
+
+
+
SSH User
+ +
+
+
SSH Key Path (leave blank to use SSH agent)
+ +
+
+ +
+
+ + +
+
+
+ Connecting... +
+
+ + +
+ + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index d39f3e48..7ae0d746 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -628,12 +628,8 @@ export async function activate(context: vscode.ExtensionContext): Promise { - void HomePanel.runInstall( - context.extensionUri, - process.platform, - process.arch, - process.env.SHELL ?? '', - ); + // Delegate to LocalSetupPanel via the host setup command + void vscode.commands.executeCommand('openclaw.host.setup.local'); }), vscode.commands.registerCommand('openclaw.openWorkspace', () => { void openOpenClawFolder(); diff --git a/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts b/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts new file mode 100644 index 00000000..77f82d4b --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface HostFileEntry { + id: string; + type: 'local' | 'docker' | 'ssh'; + label: string; + configuredAt: string; + containerName?: string; + lastStatus?: string; +} + +export interface HostsFileData { + hosts: HostFileEntry[]; +} + +const HOSTS_PATH = path.join(os.homedir(), '.occ', 'hosts.json'); + +export function readHostsFile(): HostsFileData { + try { + const raw = fs.readFileSync(HOSTS_PATH, 'utf-8'); + return JSON.parse(raw) as HostsFileData; + } catch { + return { hosts: [] }; + } +} + +export function writeHostEntry(entry: HostFileEntry): void { + const occDir = path.join(os.homedir(), '.occ'); + if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } + const file = readHostsFile(); + // Remove existing entry with same id + file.hosts = file.hosts.filter(h => h.id !== entry.id); + file.hosts.push(entry); + fs.writeFileSync(HOSTS_PATH, JSON.stringify(file, null, 2), 'utf-8'); +} diff --git a/apps/editor/extensions/openclaw/src/hosts/manager.ts b/apps/editor/extensions/openclaw/src/hosts/manager.ts index 275a3d45..868755d9 100644 --- a/apps/editor/extensions/openclaw/src/hosts/manager.ts +++ b/apps/editor/extensions/openclaw/src/hosts/manager.ts @@ -130,6 +130,15 @@ export class HostManager implements OpenClawCoreAPI, vscode.Disposable { return undefined; } + async addHost(entry: Omit): Promise { + const id = `${entry.type}-${Date.now()}`; + const full: HostEntry = { ...entry, id, createdAt: new Date().toISOString() }; + this.registry.addHost(full); + this._onDidAddHost.fire(full); + await this._ensureConnected(id).catch(() => { /* ignore — caller handles */ }); + return full; + } + async refreshHost(id: string): Promise { const conn = this._connections.get(id); if (!conn) { diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts index 86a628b0..932d4634 100644 --- a/apps/editor/extensions/openclaw/src/hosts/types.ts +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -300,5 +300,6 @@ export interface OpenClawCoreAPI { showHostPicker(): Promise; showAddHostWizard(type?: HostType): Promise; + addHost(entry: Omit): Promise; refreshHost(id: string): Promise; } diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 20af41df..f794e2e6 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -7,6 +7,7 @@ import * as os from 'os'; import * as path from 'path'; import type { HostConnection, OpenClawCoreAPI } from '../hosts/types'; import { DefaultLocalHostConnection } from '../hosts/localDefault'; +import { renderStatusHtml } from './statusHtml'; type GatewayStatus = 'checking' | 'running' | 'stopped' | 'starting' | 'stopping' | 'restarting' | 'errored' | 'ai-fixing'; @@ -85,10 +86,6 @@ function getOpenClawWorkspaceDir(): string { export class HomePanel { public static currentPanel: HomePanel | undefined; private static _installTerminal: vscode.Terminal | undefined; - /** Resolves with the password (or undefined on cancel) when the webview modal submits. */ - private static _pendingPasswordResolve: ((pwd: string | undefined) => void) | undefined; - /** Absolute path to the openclaw binary found immediately after a successful install. */ - private static _installedCliPath: string | undefined; private readonly _panel: vscode.WebviewPanel; private readonly _extensionUri: vscode.Uri; private _disposables: vscode.Disposable[] = []; @@ -108,6 +105,8 @@ export class HomePanel { private _host: HostConnection = new DefaultLocalHostConnection(); /** Cached gateway port — populated on every _update() so _getConfiguredPort() stays sync. */ private _cachedGatewayPort = 18789; + /** Core extension API — used to register docker hosts after wizard completes. */ + private _coreAPI: OpenClawCoreAPI | undefined; private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { this._panel = panel; @@ -117,6 +116,7 @@ export class HomePanel { const coreExt = vscode.extensions.getExtension('openclaw.home'); if (coreExt?.isActive && coreExt.exports != null) { const coreAPI = coreExt.exports; + this._coreAPI = coreAPI; const hostSub = coreAPI.onDidChangeActiveHost(conn => { this._host = conn ?? new DefaultLocalHostConnection(); void this._update(); @@ -162,47 +162,6 @@ export class HomePanel { 'Please run `openclaw update` to upgrade OpenClaw to the latest version.', 'agent', ); - } else if (msg.command === 'runSetup') { - void this._runSetup(msg as { command: string; provider: string; apiKey: string; port: string }); - } else if (msg.command === 'autoSetupSkipped') { - setTimeout(() => { - HomePanel.refresh(); - void vscode.commands.executeCommand('openclaw.openWorkspace'); - setTimeout(() => { - vscode.commands.executeCommand('void.openChatWithMessage', - 'Run `openclaw gateway start` to start the OpenClaw gateway.', - 'agent'); - }, 1000); - }, 500); - } else if (msg.command === 'verifyCliBeforeSetup') { - // After install, verify the CLI is actually findable before auto-configuring. - // First: use the path we captured immediately after npm install (most reliable). - const storedPath = HomePanel._installedCliPath; - if (storedPath && fs.existsSync(storedPath)) { - try { this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); } catch {} - return; - } - // Fallback: full PATH-based search. - void this._testOpenClawCli().then(result => { - if (result.ok) { - try { - this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); - } catch {} - } else { - try { - this._panel.webview.postMessage({ - type: 'installLog', - text: '\n⚠️ OpenClaw was installed but could not be found in PATH.\n' + - ' Please restart OCCode to pick up the new PATH.\n' - }); - } catch {} - void this._update(); - } - }); - } else if (msg.command === 'sudoPassword') { - // Password modal submitted or cancelled from the webview. - HomePanel._pendingPasswordResolve?.(msg.password as string | undefined); - HomePanel._pendingPasswordResolve = undefined; } else if (msg.command === 'toggleChat') { const cmd = this._sidebarOpen ? 'void.sidebar.close' : 'void.sidebar.open'; void vscode.commands.executeCommand(cmd).then(async () => { @@ -293,6 +252,15 @@ export class HomePanel { if (args && args.length > 0) { void vscode.commands.executeCommand('void.openChatWithMessage', args[0], 'agent'); } + } else if (msg.command === 'chooseHostType') { + const t = msg.hostType as string; + if (t === 'local') { + void vscode.commands.executeCommand('openclaw.host.setup.local'); + } else if (t === 'docker') { + void vscode.commands.executeCommand('openclaw.host.setup.docker'); + } else if (t === 'ssh') { + void vscode.commands.executeCommand('openclaw.host.setup.ssh'); + } } else if (msg.command) { vscode.commands.executeCommand(msg.command); } @@ -325,405 +293,6 @@ export class HomePanel { } } - /** - * Fully silent install — no terminal is ever opened. - * Output is streamed line-by-line to the home panel webview. - * If sudo is needed, a VS Code password dialog is shown. - * On any failure the AI is invoked immediately with full context. - */ - public static async runInstall( - extensionUri: vscode.Uri, - platform: string, - arch: string, - shell: string, - ): Promise { - HomePanel.createOrShow(extensionUri); - const panel = HomePanel.currentPanel; - if (!panel) return; - - const post = (msg: object) => { try { panel._panel.webview.postMessage(msg); } catch {} }; - let fullLog = ''; - const tee = (text: string) => { fullLog += text; post({ type: 'installLog', text }); writeLog(text); }; - - writeLog(`\n=== runInstall START platform=${platform} arch=${arch} ===\n`); - post({ type: 'installState', state: 'running' }); - - const env = panel._buildExecEnv(); - const isPermError = (s: string) => /EACCES|permission denied|EPERM|not permitted|Need sudo access|needs to be an Administrator/i.test(s); - - // Quick command check helper (no output, just exit code) - const cmdExists = (cmd: string): Promise => - new Promise(resolve => - cp.exec(cmd, { env, timeout: 5000, windowsHide: true }, err => resolve(!err)) - ); - - // Spawn a command silently and stream output to the panel. - const runCaptured = (cmd: string, args: string[], opts: cp.SpawnOptions = {}): Promise<{ code: number }> => - new Promise(resolve => { - const child = cp.spawn(cmd, args, { env, stdio: ['ignore', 'pipe', 'pipe'], ...opts }); - child.stdout?.on('data', (d: Buffer) => tee(d.toString())); - child.stderr?.on('data', (d: Buffer) => tee(d.toString())); - child.on('close', code => resolve({ code: code ?? 1 })); - child.on('error', err => { tee(`\nError: ${err.message}\n`); resolve({ code: 1 }); }); - }); - - // Ask for sudo password via in-webview modal, cache with `sudo -S -v`, return success. - const cacheSudo = async (_prompt: string): Promise => { - const password = await new Promise(resolve => { - HomePanel._pendingPasswordResolve = resolve; - post({ type: 'requestPassword' }); - }); - if (!password) return false; - tee('Verifying credentials...\n'); - return new Promise(resolve => { - const child = cp.spawn('sudo', ['-S', '-v'], { env, stdio: ['pipe', 'pipe', 'pipe'] }); - child.stdin?.write(password + '\n'); - child.stdin?.end(); - child.on('close', code => resolve(code === 0)); - child.on('error', () => resolve(false)); - }); - }; - - // Fix ~/.openclaw ownership after any sudo-based install. - // Uses `sudo -n` (non-interactive) which succeeds as long as the cached sudo session - // from cacheSudo() is still valid (~15 min default). Safe to call even when sudo - // wasn't used — chown is a no-op when the directory is already user-owned. - const fixOpenclawPermissions = async () => { - if (platform === 'win32') return; - const openclawDir = path.join(os.homedir(), '.openclaw'); - if (!fs.existsSync(openclawDir)) return; - try { - const username = os.userInfo().username; - tee('Fixing .openclaw folder ownership...\n'); - await runCaptured('sudo', ['-n', 'chown', '-R', username, openclawDir]); - await runCaptured('chmod', ['700', openclawDir]); - tee('✅ Permissions set (700, owned by you)\n'); - } catch { /* non-fatal */ } - }; - - const failCancelled = () => { - post({ type: 'installState', state: 'cancelled' }); - }; - - const fail = async () => { - writeLog('=== runInstall END failed ===\n'); - post({ type: 'installState', state: 'failed' }); - const platformDesc = platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : `Linux (${arch})`; - await vscode.commands.executeCommand('void.openChatWithMessage', [ - `OpenClaw installation failed on **${platformDesc}**.`, - ``, `**System info:**`, - `- Node.js: \`${process.version}\``, - `- Shell: \`${shell || 'unknown'}\``, - ``, `**Full output:**`, `\`\`\``, fullLog.trim(), `\`\`\``, ``, - `Please diagnose what went wrong and provide exact steps to fix it on this platform.`, - `If Node.js or npm is missing, explain how to install them first.`, - ].join('\n')); - void vscode.commands.executeCommand('openclaw.balance.spend'); - }; - - // After a successful npm install, find the binary's absolute path immediately - // (before PATH settles) so verifyCliBeforeSetup can use it directly. - const captureInstalledPath = async (): Promise => { - if (platform === 'win32') return; // Windows uses cmd shims; handled by _testOpenClawCli - const candidates: string[] = []; - // 1. Ask npm where its global prefix is (same env as the install used) - const prefixResult = await new Promise(resolve => { - cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => - resolve(err ? '' : (stdout || '').trim()) - ); - }); - if (prefixResult) candidates.push(path.join(prefixResult, 'bin', 'openclaw')); - // 2. Hard-coded common macOS/Linux paths - const home = os.homedir(); - candidates.push( - '/usr/local/bin/openclaw', - '/opt/homebrew/bin/openclaw', - path.join(home, '.local', 'bin', 'openclaw'), - path.join(home, '.npm-global', 'bin', 'openclaw'), - path.join(home, '.openclaw', 'bin', 'openclaw'), - ); - // 3. nvm versions (newest first) - const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); - if (fs.existsSync(nvmVersionsDir)) { - fs.readdirSync(nvmVersionsDir) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) - .forEach(ver => candidates.push(path.join(nvmVersionsDir, ver, 'bin', 'openclaw'))); - } - const found = candidates.find(c => c && fs.existsSync(c)); - if (found) { - HomePanel._installedCliPath = found; - tee(` ✓ Binary found at ${found}\n`); - } - }; - - const succeed = async () => { - await captureInstalledPath(); - tee('\n✅ Installed successfully!\n'); - writeLog('=== runInstall END ok ===\n'); - post({ type: 'installState', state: 'done' }); - // Webview drives the post-install navigation via autoSetupSkipped or wizardLog done - }; - - // ══════════════════════════════════════════════════════════════════════════ - // PREREQUISITE CHECKS + PROACTIVE SUDO — run BEFORE any install attempt - // ══════════════════════════════════════════════════════════════════════════ - tee('Checking prerequisites...\n'); - - if (platform === 'darwin') { - // macOS: check Xcode Command Line Tools - const xcodeOk = await new Promise(resolve => - cp.exec('xcode-select -p', { env, timeout: 5000 }, (err, stdout) => { - resolve(!err && !!stdout?.toString().trim()); - }) - ); - if (!xcodeOk) { - post({ type: 'xcodeRequired' }); - return; - } - tee(' ✓ Xcode Command Line Tools\n'); - } - - if (platform === 'win32') { - // Windows: check Node.js exists; auto-install if missing (no UAC required) - let nodeOk = await cmdExists('node --version'); - if (!nodeOk) { - const nodeVersion = '20.18.2'; - const nodeArch = arch === 'arm64' ? 'arm64' : 'x64'; - const zipName = `node-v${nodeVersion}-win-${nodeArch}.zip`; - const zipUrl = `https://nodejs.org/dist/v${nodeVersion}/${zipName}`; - const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); - const installDir = path.join(localAppData, 'Programs', 'nodejs'); - const tmpZip = path.join(os.tmpdir(), zipName); - const tmpExtract = path.join(os.tmpdir(), `occ-node-${Date.now()}`); - - tee(` ⚠ Node.js not found — downloading v${nodeVersion} (${nodeArch})...\n`); - const dlR = await runCaptured('powershell', [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing '${zipUrl}' -OutFile '${tmpZip}'`, - ], { windowsHide: true, shell: true } as cp.SpawnOptions); - - if (dlR.code !== 0) { - tee(' ❌ Failed to download Node.js. Check your internet connection.\n'); - await fail(); return; - } - - tee(` Extracting...\n`); - // Pre-compute the inner folder name (avoids | pipe which cmd.exe would intercept) - const innerDir = path.join(tmpExtract, `node-v${nodeVersion}-win-${nodeArch}`); - const exR = await runCaptured('powershell', [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ProgressPreference='SilentlyContinue'; ` + - `Expand-Archive -Path '${tmpZip}' -DestinationPath '${tmpExtract}' -Force; ` + - `if (Test-Path '${installDir}') { Remove-Item '${installDir}' -Recurse -Force }; ` + - `Move-Item '${innerDir}' '${installDir}'`, - ], { windowsHide: true, shell: true } as cp.SpawnOptions); - - try { fs.unlinkSync(tmpZip); } catch {} - - if (exR.code !== 0) { - tee(' ❌ Failed to extract Node.js.\n'); - await fail(); return; - } - - // Make new Node.js dir visible to all subsequent commands in this install run - env.PATH = [installDir, env.PATH || ''].filter(Boolean).join(';'); - (env as any).Path = env.PATH; - - nodeOk = await cmdExists('node --version'); - if (!nodeOk) { - tee(' ❌ Node.js install did not complete properly.\n'); - await fail(); return; - } - tee(` ✓ Node.js v${nodeVersion} installed to ${installDir}\n`); - } else { - tee(' ✓ Node.js found\n'); - } - } - - const npmOk = await cmdExists('npm --version'); - if (npmOk) { - tee(' ✓ npm found\n'); - } else if (platform === 'win32') { - tee(' ❌ npm not found after Node.js install — unexpected.\n'); - await fail(); return; - } else { - tee(' ⚠ npm not found — will attempt to install Node.js\n'); - } - - // Proactive sudo detection (macOS / Linux only) - let sudoCached = false; - if (platform !== 'win32') { - let needsSudo = false; - if (npmOk) { - const prefixResult = await new Promise(resolve => - cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => - resolve(err ? '' : stdout?.toString().trim() || '')) - ); - if (prefixResult) { - try { - const gBin = path.join(prefixResult, 'bin'); - const gLib = path.join(prefixResult, 'lib'); - if (fs.existsSync(gBin)) fs.accessSync(gBin, fs.constants.W_OK); - if (fs.existsSync(gLib)) fs.accessSync(gLib, fs.constants.W_OK); - } catch { - needsSudo = true; - } - } - } else { - // No npm — will need to install Node.js, likely needs sudo - for (const dir of ['/usr/local/bin', '/usr/local/lib']) { - try { if (fs.existsSync(dir)) fs.accessSync(dir, fs.constants.W_OK); } catch { needsSudo = true; break; } - } - } - - if (needsSudo) { - tee('\n Administrator password required for installation.\n'); - const sudoOk = await cacheSudo('Enter your system password to install OpenClaw'); - if (!sudoOk) { tee(' Incorrect password or cancelled.\n'); failCancelled(); return; } - tee(' ✓ Credentials verified\n'); - sudoCached = true; - } else { - tee(' ✓ Write access OK\n'); - } - } - - tee('\n'); - - // ── Step 1: try npm install -g openclaw ─────────────────────────────────── - if (npmOk) { - tee('Installing openclaw via npm...\n'); - const spawnOpts: cp.SpawnOptions = platform === 'win32' ? { shell: true, windowsHide: true } : {}; - const npmArgs = ['install', '-g', 'openclaw']; - const r1 = sudoCached - ? await runCaptured('sudo', ['-E', 'npm', ...npmArgs]) - : await runCaptured('npm', npmArgs, spawnOpts); - if (r1.code === 0) { - if (sudoCached) await fixOpenclawPermissions(); - await succeed(); return; - } - // If sudo wasn't cached and we hit permission error, ask now (fallback) - if (!sudoCached && platform !== 'win32' && isPermError(fullLog)) { - tee('\nPermission error — elevated access required.\n'); - const ok = await cacheSudo('Enter your system password to install OpenClaw'); - if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - sudoCached = true; - tee('Retrying with elevated permissions...\n'); - const r2 = await runCaptured('sudo', ['-E', 'npm', 'install', '-g', 'openclaw']); - if (r2.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - } - tee('\nnpm install did not succeed — trying full installer script...\n'); - } else if (platform !== 'win32') { - - // ── Unix: npm not found — silent Node.js install (no terminal) ────────── - // Step A: try nvm (no sudo, no password needed) - const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); - if (fs.existsSync(nvmSh)) { - tee('nvm detected — installing Node.js LTS...\n'); - const nvmR = await runCaptured('bash', ['-c', - `. "${nvmSh}" && nvm install --lts && nvm use --lts && npm install -g openclaw` - ]); - if (nvmR.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - tee('nvm install failed — falling back to system install...\n'); - } - - // Step B: ensure sudo is cached (may already be from proactive check above) - if (!sudoCached) { - tee('\nNode.js is required. Your password is needed once to install it.\n'); - const sudoOk = await cacheSudo('Enter your password to install Node.js'); - if (!sudoOk) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - sudoCached = true; - } - - if (platform === 'darwin') { - // macOS: download official Node.js universal pkg, install silently with cached sudo - const nodeVersion = '20.18.2'; - const pkgUrl = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}.pkg`; - const pkgPath = `/tmp/.occ-node-${nodeVersion}.pkg`; - tee(`Downloading Node.js v${nodeVersion}...\n`); - const dlR = await runCaptured('curl', ['-fsSL', pkgUrl, '-o', pkgPath]); - if (dlR.code !== 0) { try { fs.unlinkSync(pkgPath); } catch {} await fail(); return; } - tee('Installing Node.js (this may take a moment)...\n'); - const instR = await runCaptured('sudo', ['-n', 'installer', '-pkg', pkgPath, '-target', '/']); - try { fs.unlinkSync(pkgPath); } catch { /* non-fatal */ } - if (instR.code !== 0) { await fail(); return; } - } else { - // Linux: detect package manager and install Node.js LTS via nodesource - const hasCmdSync = (cmd: string): boolean => { - try { cp.execSync(`which ${cmd}`, { env, stdio: 'ignore' }); return true; } catch { return false; } - }; - tee('Installing Node.js via package manager...\n'); - let pkgResult: { code: number } | undefined; - if (hasCmdSync('apt-get')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get install -y nodejs' - ]); - } else if (hasCmdSync('dnf')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && dnf install -y nodejs' - ]); - } else if (hasCmdSync('yum')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && yum install -y nodejs' - ]); - } - if (!pkgResult) { tee('No supported package manager found (tried apt-get, dnf, yum).\n'); await fail(); return; } - if (pkgResult.code !== 0) { await fail(); return; } - } - - // Step C: npm is now installed — find it and install openclaw - tee('Installing OpenClaw...\n'); - const npmCandidates = ['/usr/local/bin/npm', '/usr/bin/npm']; - const npmBin = npmCandidates.find(p => fs.existsSync(p)) ?? 'npm'; - const npmR1 = await runCaptured(npmBin, ['install', '-g', 'openclaw']); - if (npmR1.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - // Global prefix dir may be root-owned — retry with cached sudo - if (isPermError(fullLog)) { - const npmR2 = await runCaptured('sudo', ['-n', npmBin, 'install', '-g', 'openclaw']); - if (npmR2.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - } - await fail(); return; - - } else { - tee('npm not found — running full installer script...\n'); - } - - // ── Step 2: full install script ── (npm found but failed, or Windows no npm) - if (platform === 'win32') { - tee('Running PowerShell installer...\n'); - const psArgs = [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; ` + - `Invoke-WebRequest -UseBasicParsing https://openclaw.ai/install.ps1 | Invoke-Expression`, - ]; - const r = await runCaptured('powershell', psArgs, { windowsHide: true } as cp.SpawnOptions); - if (r.code === 0) { await succeed(); return; } - } else { - // npm was found but install failed — try the openclaw installer script - tee('Running install script...\n'); - const r1 = await runCaptured('bash', ['-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); - if (r1.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - if (isPermError(fullLog)) { - tee('\nPermission error in installer — elevated access required.\n'); - const ok = await cacheSudo('Enter your system password to complete installation'); - if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - tee('Retrying with elevated permissions...\n'); - const r2 = await runCaptured('sudo', ['-E', 'bash', '-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); - if (r2.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - } - } - - await fail(); - } - public dispose() { HomePanel.currentPanel = undefined; this._stopPolling(); @@ -773,7 +342,9 @@ export class HomePanel { const cliCheck = await this._testOpenClawCli(); writeLog(`[cli-check] ok=${cliCheck.ok} cmd="${cliCheck.command}" output="${(cliCheck.output ?? '').trim()}"\n`); - const isInstalled = isConfigured; // config file is the sole source of truth — a leftover binary without config is not "installed" + // For remote hosts (docker/ssh): CLI installed but not yet onboarded — go straight to configure step. + // For local: config file is the sole source of truth (leftover binary without config = not installed). + const isInstalled = isConfigured || (this._host.type !== 'local' && cliCheck.ok); this._lastInstalledState = isInstalled; this._lastInstalledVersion = cliCheck.ok ? (cliCheck.output ?? '').trim() : null; const iconUri = this._panel.webview.asWebviewUri( @@ -795,7 +366,7 @@ export class HomePanel { // Show unified setup view when OpenClaw is not fully configured yet. if (!isConfigured) { - this._panel.webview.html = this._getSetupHtml(isInstalled, iconUri.toString(), occUser); + this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString()); this._autoUpdateTriggered = false; // reset so check fires when they reach the dashboard } else { const emojiBaseUri = this._panel.webview.asWebviewUri( @@ -1113,6 +684,83 @@ export class HomePanel { } catch { /* best-effort */ } } + private _getHostTypeSelectionHtml(iconUri: string): string { + return ` + + + + + + + + +

Welcome to OpenClaw

+

Where do you want to run OpenClaw?

+
+ + + +
+ + +`; + } + private _getLoadingHtml(iconUri: string): string { return ` @@ -1210,172 +858,6 @@ export class HomePanel { `; } - // ── Setup wizard ─────────────────────────────────────────────────────────── - - private async _runSetup(data: { provider: string; apiKey: string; port: string }): Promise { - const post = (msg: object) => { try { this._panel.webview.postMessage(msg); } catch {} }; - const wizardPost = (text: string, done: boolean, ok: boolean) => { - writeLog(text); - post({ type: 'wizardLog', text, done, ok }); - }; - const env = this._buildExecEnv(); - - // openclaw requires Node.js >= 22. Check the active version and auto-install - // via nvm if needed — the editor pins v20, so users may only have v20 active. - if (process.platform !== 'win32') { - const nodeResult = await this._host.exec('node', ['--version'], { env: env as Record, timeout: 5000 }); - const nodeVerRaw = nodeResult.stdout.trim(); - const nodeMinor = parseInt((nodeVerRaw.match(/^v?(\d+)/) || [])[1] || '0', 10); - if (nodeMinor < 22) { - const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); - if (fs.existsSync(nvmSh)) { - wizardPost(`Node.js ${nodeVerRaw || 'unknown'} detected — openclaw requires v22+. Installing Node.js 22 via nvm...\n`, false, false); - const nvmCode = await this._host.execStream( - 'bash', - ['-c', `. "${nvmSh}" && nvm install 22 && nvm use 22 && nvm alias default 22`], - { env: env as Record }, - (d) => wizardPost(d, false, false), - (d) => wizardPost(d, false, false), - ); - if (nvmCode === 0) { - // Rebuild env so the new Node 22 bin is on PATH - const nvmVersionsDir = path.join(os.homedir(), '.nvm', 'versions', 'node'); - const v22dirs = fs.existsSync(nvmVersionsDir) - ? fs.readdirSync(nvmVersionsDir).filter(v => /^v?22/.test(v)) - : []; - if (v22dirs.length > 0) { - const v22bin = path.join(nvmVersionsDir, v22dirs[0], 'bin'); - env.PATH = [v22bin, env.PATH || ''].filter(Boolean).join(':'); - (env as any).Path = env.PATH; - } - wizardPost(' ✓ Node.js 22 ready.\n', false, false); - } else { - wizardPost(' ⚠️ Node.js 22 install via nvm failed. Setup may fail.\n', false, false); - } - } else { - wizardPost(`⚠️ Node.js ${nodeVerRaw || 'v20'} is active but openclaw requires v22+.\n Please install Node.js 22 (e.g. via https://nodejs.org) and restart OCCode.\n`, true, false); - return; - } - } - } - - const cliPath = await this._findOpenClawPath() ?? 'openclaw'; - const port = data.port && /^\d+$/.test(data.port) ? data.port : '18789'; - const isFree = data.provider === 'free'; - - // Map provider choice to openclaw flags. - const providerFlags: Record = { - free: [ - '--auth-choice', 'custom-api-key', - '--custom-base-url', 'https://occ.mba.sh/v1', - '--custom-api-key', data.apiKey, - '--custom-model-id', 'occ-legacy', - '--custom-compatibility', 'openai', - ], - anthropic: ['--auth-choice', 'apiKey', '--anthropic-api-key', data.apiKey], - openai: ['--auth-choice', 'openai-api-key', '--openai-api-key', data.apiKey], - openrouter: ['--auth-choice', 'openrouter-api-key', '--openrouter-api-key', data.apiKey], - gemini: ['--auth-choice', 'gemini-api-key', '--gemini-api-key', data.apiKey], - ollama: [ - '--auth-choice', 'custom-api-key', - '--custom-base-url', data.apiKey || 'http://localhost:11434', - '--custom-api-key', 'ollama', - '--custom-model-id', 'llama3', - '--custom-compatibility', 'openai', - ], - }; - const flags = providerFlags[data.provider]; - if (!flags) { - wizardPost('Unknown provider selected.\n', true, false); - return; - } - - const args = [ - 'onboard', - '--non-interactive', '--accept-risk', - '--flow', 'quickstart', - '--gateway-auth', 'token', - '--gateway-port', port, - '--skip-channels', '--skip-skills', '--skip-health', - ...flags, - ]; - - writeLog(`\n=== _runSetup START provider=${data.provider} port=${port} ===\n`); - wizardPost(isFree ? 'Installing Inference for MoltPilot...\nInstalling Inference for your new OpenClaw...\n' : 'Installing Inference for your new OpenClaw...\n', false, false); - - let exitCode: number; - try { - exitCode = await this._host.execStream( - cliPath, args, - { - env: env as Record, - timeout: 120_000, - ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), - }, - (d) => wizardPost(d, false, false), - (d) => wizardPost(d, false, false), - ); - } catch (err) { - wizardPost(`Error: ${String(err)}\n`, true, false); - return; - } - - const ok = exitCode === 0; - writeLog(`=== _runSetup END code=${exitCode} ===\n`); - wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${exitCode}.\n`, true, ok); - - if (ok) { - if (isFree) { - // Write local free-tier marker (extension-host local state — always local fs). - try { - const occDir = path.join(os.homedir(), '.occ'); - if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } - fs.writeFileSync( - path.join(occDir, 'moltpilot-tier.json'), - JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), - ); - } catch { /* non-fatal */ } - // Patch openclaw.json on the active host to inject correct cost/context metadata. - try { - const cfg = await this._host.readConfig() as Record; - const patchModel = (obj: unknown): boolean => { - if (!obj || typeof obj !== 'object') return false; - if (Array.isArray(obj)) { - for (const item of obj) { if (patchModel(item)) return true; } - return false; - } - const o = obj as Record; - if (o['id'] === OCC_LEGACY_MODEL_ID) { - o['name'] = OCC_LEGACY_MODEL_NAME; - o['reasoning'] = false; - o['input'] = ['text']; - o['cost'] = { ...OCC_LEGACY_COST }; - o['contextWindow'] = OCC_LEGACY_CONTEXT_WINDOW; - o['maxTokens'] = OCC_LEGACY_MAX_TOKENS; - return true; - } - for (const v of Object.values(o)) { patchModel(v); } - return false; - }; - patchModel(cfg); - await this._host.writeConfig(cfg); - } catch { /* non-fatal — openclaw.json may not exist yet */ } - } - setTimeout(() => { - HomePanel.refresh(); - if (isFree) { - vscode.commands.executeCommand('openclaw.openWorkspace'); - } - setTimeout(() => { - this._closeSidebarOnGatewayStart = true; - vscode.commands.executeCommand('void.openChatWithMessage', - 'Run `openclaw gateway start` to start the OpenClaw gateway.', - 'agent'); - }, 1000); - }, 1500); - } - } - // ── Uninstall ────────────────────────────────────────────────────────────── private _schedulePostUninstallClose(): void { if (this._uninstallCloseWatcher !== undefined) return; @@ -1587,11 +1069,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; } - private _getSetupHtml( - isInstalled: boolean, - iconUri: string, - occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null - ): string { + private _getWizardHtml(iconUri: string, occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null): string { // Render user area statically (avoids JS innerHTML escaping issues) let userAreaHtml: string; if (!occUser) { @@ -1650,10 +1128,9 @@ The binary is already downloaded — do NOT re-download or compile anything.`; font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); background: #1a1a1a; color: #e0e0e0; display: flex; flex-direction: column; align-items: center; justify-content: center; - min-height: 100vh; padding: 32px 20px 40px; text-align: center; + min-height: 100vh; padding: 20px; text-align: center; } - - /* ── Header ── */ + /* ── Header bar ──────────────────────────────────────────────── */ .header-bar { position: fixed; top: 12px; right: 12px; z-index: 200; display: flex; align-items: center; gap: 8px; @@ -1674,6 +1151,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; padding: 4px 10px; border-radius: 6px; cursor: pointer; transition: background 0.15s; } .sign-in-btn:hover { background: rgba(220,40,40,0.16); } + /* User popover */ .user-popover-wrap { position: relative; } .user-popover { display: none; position: absolute; top: calc(100% + 8px); right: 0; @@ -1710,47 +1188,9 @@ The binary is already downloaded — do NOT re-download or compile anything.`; text-align: left; cursor: pointer; transition: background 0.12s, color 0.12s; } .user-popover-signout:hover { background: rgba(255,255,255,0.06); color: #fff; } - - /* ── Logo + title ── */ - .logo { width: 56px; height: 56px; filter: drop-shadow(0 4px 12px rgba(220,40,40,0.3)); margin-bottom: 8px; } - .setup-title { font-size: 20px; font-weight: 700; color: #fff; margin-bottom: 4px; } - .setup-sub { font-size: 12px; color: #555; margin-bottom: 28px; } - - /* ── Step timeline ── */ - .steps { - display: flex; align-items: flex-start; gap: 0; - margin-bottom: 28px; width: min(420px, 96vw); - } - .step-item { - display: flex; flex-direction: column; align-items: center; flex: 1; - position: relative; - } - .step-item:not(:last-child)::after { - content: ''; - position: absolute; top: 13px; left: calc(50% + 16px); - width: calc(100% - 32px); height: 1px; - background: #2b2b2b; - } - .step-item.done:not(:last-child)::after { background: #dc2828; } - .step-dot { - width: 26px; height: 26px; border-radius: 50%; - display: flex; align-items: center; justify-content: center; - font-size: 11px; font-weight: 700; margin-bottom: 6px; - flex-shrink: 0; position: relative; z-index: 1; - } - .step-item.done .step-dot { background: #dc2828; color: #fff; border: 2px solid #dc2828; } - .step-item.active .step-dot { background: transparent; border: 2px solid #dc2828; color: #dc2828; } - .step-item.pending .step-dot { background: transparent; border: 2px solid #2b2b2b; color: #444; } - .step-label-text { font-size: 10px; color: #555; text-align: center; line-height: 1.3; display: flex; flex-direction: column; align-items: center; } - .step-item.done .step-label-text { color: #dc2828; } - .step-item.active .step-label-text { color: #e0e0e0; } - - /* ── Action panels ── */ - .panel { width: min(440px, 96vw); } - .panel-title { font-size: 15px; font-weight: 600; color: #fff; margin-bottom: 6px; } - .panel-desc { font-size: 12px; color: #888; margin-bottom: 20px; line-height: 1.5; } - - /* ── Buttons ── */ + /* ── Logo ──────────────────────────────────────────────────────── */ + .logo { width: 64px; height: 64px; filter: drop-shadow(0 4px 12px rgba(220,40,40,0.3)); } + /* ── Buttons ───────────────────────────────────────────────────── */ .btn-primary { background: #dc2828; border: none; color: #fff; font-size: 14px; font-weight: 600; padding: 10px 28px; border-radius: 8px; @@ -1765,19 +1205,17 @@ The binary is already downloaded — do NOT re-download or compile anything.`; transition: color 0.15s; text-decoration: underline; text-underline-offset: 2px; } .btn-link:hover { color: #aaa; } - .btn-back { - background: transparent; border: 1px solid #333; color: #888; - font-size: 13px; padding: 8px 18px; border-radius: 6px; cursor: pointer; font-family: inherit; - } - .btn-back:hover { background: rgba(255,255,255,0.05); } @keyframes spin { to { transform: rotate(360deg); } } .btn-spin { display: inline-block; width: 13px; height: 13px; border: 2px solid rgba(255,255,255,0.25); border-top-color: #fff; border-radius: 50%; animation: spin 0.65s linear infinite; flex-shrink: 0; } - - /* ── Provider cards ── */ + /* ── Provider cards (BYOK) ─────────────────────────────────────── */ + .step { width: min(480px, 96vw); text-align: left; } + .step-label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; text-align: center; } + h2 { font-size: 15px; font-weight: 600; color: #fff; margin-bottom: 6px; text-align: center; } + .step-desc { font-size: 12px; color: #888; margin-bottom: 20px; line-height: 1.5; text-align: center; } .prov-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 20px; } .prov-card { background: rgba(255,255,255,0.03); border: 1px solid #2b2b2b; @@ -1789,14 +1227,14 @@ The binary is already downloaded — do NOT re-download or compile anything.`; .prov-card.selected { border-color: #dc2828; background: rgba(220,40,40,0.08); } .prov-label { font-size: 13px; font-weight: 600; color: #e0e0e0; } .prov-hint { font-size: 11px; color: #666; } - .field-label { font-size: 11px; color: #888; margin-bottom: 5px; text-align: left; } + .field-label { font-size: 11px; color: #888; margin-bottom: 5px; } .key-input { width: 100%; background: #111; border: 1px solid #2b2b2b; border-radius: 6px; color: #e0e0e0; font-size: 13px; padding: 9px 12px; outline: none; margin-bottom: 6px; box-sizing: border-box; font-family: monospace; } .key-input:focus { outline: none; border-color: #dc2828; } - .key-hint { font-size: 11px; color: #555; margin-bottom: 16px; text-align: left; } + .key-hint { font-size: 11px; color: #555; margin-bottom: 20px; } .port-row { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; } .port-label { font-size: 12px; color: #888; white-space: nowrap; } .port-input { @@ -1805,272 +1243,118 @@ The binary is already downloaded — do NOT re-download or compile anything.`; } .port-input:focus { outline: none; border-color: #dc2828; } .btn-row { display: flex; gap: 10px; justify-content: flex-end; } - - /* ── Log panel ── */ - .log-wrap { - display: none; width: min(480px, 96vw); margin-top: 4px; - } - .log-wrap.visible { display: block; } - .log-box { - background: #0d0d0d; border: 1px solid #222; border-radius: 8px; - padding: 12px 14px; height: 200px; overflow-y: auto; - font-family: 'SF Mono', 'Fira Mono', 'Consolas', monospace; - font-size: 11px; line-height: 1.6; text-align: left; color: #888; - scroll-behavior: smooth; - } - .log-line { white-space: pre-wrap; word-break: break-all; } - .log-line.ok { color: #4ade80; } - .log-line.err { color: #f87171; } - .log-line.warn { color: #fbbf24; } - .log-status { - font-size: 12px; color: #555; margin-top: 8px; text-align: center; - } - .log-status.done { color: #4ade80; } - .log-status.failed { color: #f87171; } - @keyframes dots { 0%,100%{content:''} 33%{content:'.'} 66%{content:'..'} } - .dots::after { content: ''; animation: dots 1.2s steps(1) infinite; } - - /* ── MoltPilot help button ── */ - .molt-help { - display: none; margin-top: 16px; - background: rgba(167,139,250,0.1); border: 1px solid rgba(167,139,250,0.3); - color: #a78bfa; font-size: 13px; font-weight: 600; - padding: 10px 20px; border-radius: 8px; cursor: pointer; font-family: inherit; - transition: background 0.15s; - } - .molt-help.visible { display: inline-flex; align-items: center; gap: 8px; } - .molt-help:hover { background: rgba(167,139,250,0.2); } - - /* ── Password modal ── */ - .modal-overlay { - display: none; position: fixed; inset: 0; - background: rgba(0,0,0,0.7); z-index: 500; - align-items: center; justify-content: center; - } - .modal-overlay.open { display: flex; } - .modal-box { - background: #1e1e1e; border: 1px solid rgba(255,255,255,0.12); - border-radius: 16px; padding: 28px 28px 24px; width: min(360px, 92vw); - box-shadow: 0 24px 60px rgba(0,0,0,0.7); text-align: left; - } - .modal-title { font-size: 15px; font-weight: 700; color: #fff; margin-bottom: 6px; } - .modal-desc { font-size: 12px; color: #888; margin-bottom: 18px; line-height: 1.5; } - .modal-input { - width: 100%; background: #111; border: 1px solid #333; border-radius: 8px; - color: #e0e0e0; font-size: 14px; padding: 10px 14px; outline: none; - box-sizing: border-box; margin-bottom: 16px; letter-spacing: 0.1em; - } - .modal-input:focus { outline: none; border-color: #dc2828; } - .modal-btns { display: flex; gap: 10px; justify-content: flex-end; } - .modal-cancel { + .btn-back { background: transparent; border: 1px solid #333; color: #888; - font-size: 13px; padding: 8px 18px; border-radius: 6px; cursor: pointer; font-family: inherit; + font-size: 13px; padding: 8px 18px; border-radius: 6px; cursor: pointer; } - .modal-cancel:hover { background: rgba(255,255,255,0.05); } - .modal-confirm { - background: #dc2828; border: none; color: #fff; - font-size: 13px; font-weight: 600; padding: 8px 20px; border-radius: 6px; - cursor: pointer; font-family: inherit; transition: background 0.15s; + .btn-back:hover { background: rgba(255,255,255,0.05); } + /* Running step */ + .run-status { + font-size: 12px; color: #555; margin-top: 12px; + max-width: 280px; text-align: center; line-height: 1.5; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .modal-confirm:hover { background: #b91c1c; } + .run-status.done { color: #4ade80; white-space: normal; } + .run-status.failed { color: #f87171; white-space: normal; } + @keyframes dots { 0%,100%{content:''} 33%{content:'.'} 66%{content:'..'} 100%{content:'...'} } + .dots::after { content: ''; animation: dots 1.2s steps(1) infinite; } - -
${userAreaHtml}
- - - -
Set up OpenClaw
-
Follow the steps below to get started
- - -
-
-
${isInstalled ? '✓' : '1'}
-
Install
OpenClaw
-
-
-
2
-
Configure
AI Model
-
-
-
3
-
Ready
-
-
- - -
- + +
+ ${userAreaHtml}
- -