From ca90d88caacfc34cfa6cc9f23dffd436306d5097 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:42:31 -0400 Subject: [PATCH 001/144] =?UTF-8?q?chore:=20scaffold=20multihost=20branch?= =?UTF-8?q?=20=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 --- apps/editor/extensions/openclaw-docker/package.json | 6 ++---- apps/editor/extensions/openclaw-docker/tsconfig.json | 2 +- apps/editor/extensions/openclaw-local/package.json | 6 ++---- apps/editor/extensions/openclaw-local/tsconfig.json | 2 +- apps/editor/extensions/openclaw-ssh/package.json | 6 ++---- apps/editor/extensions/openclaw-ssh/tsconfig.json | 2 +- 6 files changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/editor/extensions/openclaw-docker/package.json b/apps/editor/extensions/openclaw-docker/package.json index a2cf4302..d2a8efa3 100644 --- a/apps/editor/extensions/openclaw-docker/package.json +++ b/apps/editor/extensions/openclaw-docker/package.json @@ -9,10 +9,8 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/openclaw-docker/src/extension", - "contributes": { - "commands": [{ "command": "openclaw.host.setup.docker", "title": "OpenClaw: Set up Docker" }] - }, + "main": "./out/extension", + "contributes": {}, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-docker/tsconfig.json b/apps/editor/extensions/openclaw-docker/tsconfig.json index c8613a95..a358ee24 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/package.json b/apps/editor/extensions/openclaw-local/package.json index 1fc26106..e73b1b71 100644 --- a/apps/editor/extensions/openclaw-local/package.json +++ b/apps/editor/extensions/openclaw-local/package.json @@ -9,10 +9,8 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/openclaw-local/src/extension", - "contributes": { - "commands": [{ "command": "openclaw.host.setup.local", "title": "OpenClaw: Set up Local" }] - }, + "main": "./out/extension", + "contributes": {}, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-local/tsconfig.json b/apps/editor/extensions/openclaw-local/tsconfig.json index c8613a95..a358ee24 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/package.json b/apps/editor/extensions/openclaw-ssh/package.json index 47df9f67..323749bd 100644 --- a/apps/editor/extensions/openclaw-ssh/package.json +++ b/apps/editor/extensions/openclaw-ssh/package.json @@ -9,10 +9,8 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/openclaw-ssh/src/extension", - "contributes": { - "commands": [{ "command": "openclaw.host.setup.ssh", "title": "OpenClaw: Set up SSH" }] - }, + "main": "./out/extension", + "contributes": {}, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-ssh/tsconfig.json b/apps/editor/extensions/openclaw-ssh/tsconfig.json index c8613a95..a358ee24 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 5304d1214e33558d8fbef61e18aed22d22952250 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:45:46 -0400 Subject: [PATCH 002/144] 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 | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts index 05496345..2d82e9f2 100644 --- a/apps/editor/extensions/openclaw/src/hosts/types.ts +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -13,8 +13,6 @@ 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 { @@ -122,8 +120,6 @@ export interface DockerConnection { dockerHost?: string; shell?: string; portMappings?: { gateway?: number }; - /** Host-side directory mounted as /home/node/.openclaw inside the container (e.g. ~/Desktop/occ-state-dir). */ - localMountPath?: string; } export interface SSHConnection { @@ -256,22 +252,10 @@ export interface HostConnection extends vscode.Disposable { gatewayStart(onLog: LogFn): Promise; gatewayStop(onLog: LogFn): Promise; gatewayRestart(onLog: LogFn): Promise; - gatewayReboot(onLog: LogFn): Promise; // ── Full install+onboard ── runSetup(params: SetupParams, onLog: LogFn): Promise; - // ── Port override (for tunnelled connections, e.g. Docker) ── - /** Host-side port to poll for gateway health. Overrides the port read from openclaw.json. */ - gatewayHostPort?(): number | undefined; - - // ── Local filesystem paths ── - /** - * Host-side path to the OpenClaw state directory. - * Local: ~/.openclaw Docker: ~/Desktop/occ-state-dir (or whatever is mounted) - */ - localStateDir?(): string; - // ── Environment ── buildExecEnv(): Record; } @@ -314,6 +298,5 @@ export interface OpenClawCoreAPI { showHostPicker(): Promise; showAddHostWizard(type?: HostType): Promise; - addHost(entry: Omit): Promise; refreshHost(id: string): Promise; } From f2a34a6c74cbccda3fbea029be55373685096a93 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:48:24 -0400 Subject: [PATCH 003/144] 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 | 15 +- .../extensions/openclaw/src/extension.ts | 489 ++---------------- .../extensions/openclaw/src/hosts/manager.ts | 9 - 3 files changed, 55 insertions(+), 458 deletions(-) diff --git a/apps/editor/extensions/openclaw/package.json b/apps/editor/extensions/openclaw/package.json index e42c25bb..ff4d92c0 100644 --- a/apps/editor/extensions/openclaw/package.json +++ b/apps/editor/extensions/openclaw/package.json @@ -83,7 +83,15 @@ "viewsContainers": { "activitybar": [] }, - "views": {}, + "views": { + "explorer": [ + { + "id": "openclaw.hosts", + "name": "OpenClaw Hosts", + "when": "true" + } + ] + }, "commands": [ { "command": "openclaw.home", @@ -120,10 +128,6 @@ { "command": "openclaw.refreshHost", "title": "OpenClaw: Refresh Host Status" - }, - { - "command": "openclaw.host.setup.docker", - "title": "OpenClaw: Docker Setup" } ] }, @@ -134,7 +138,6 @@ "devDependencies": { "@types/node": "^20.19.35", "@types/vscode": "^1.85.0", - "dotenv": "^17.3.1", "typescript": "^5.9.3" }, "icon": "media/icon.png", diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index 00ac684d..d39f3e48 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -1,5 +1,4 @@ import * as vscode from 'vscode'; -import * as cp from 'child_process'; import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; @@ -7,7 +6,6 @@ import * as http from 'http'; import * as https from 'https'; import { HomePanel } from './panels/home'; import { StatusPanel } from './panels/status'; -import { setActiveOpenClawWorkspaceFolder } from './panels/statusController'; import { stopConfigProxy, getDashboardUrl } from './panels/config'; import { HostRegistry } from './hosts/registry'; import { HostManager } from './hosts/manager'; @@ -31,85 +29,9 @@ function getConfiguredGatewayPort(): number { } } -// ── Window host binding ───────────────────────────────────────────────────── - -/** Describes which host this VS Code window is currently bound to. */ -export interface WindowHostBinding { - type: 'local' | 'docker' | 'ssh'; - hostId: string; - port: number; - label: string; -} - -const WINDOW_HOST_KEY = 'occ.windowHost'; - -function registerWindowHostCommands(context: vscode.ExtensionContext): void { - context.subscriptions.push( - vscode.commands.registerCommand('occ.window.setHost', (binding: WindowHostBinding) => { - void context.workspaceState.update(WINDOW_HOST_KEY, binding); - }), - vscode.commands.registerCommand('occ.window.clearHost', () => { - void context.workspaceState.update(WINDOW_HOST_KEY, undefined); - }), - /** Returns the current WindowHostBinding for this window, or null. */ - vscode.commands.registerCommand('occ.window.getHost', () => { - return context.workspaceState.get(WINDOW_HOST_KEY) ?? null; - }), - ); -} - -/** - * Smart routing for the "OCC Home" command. - * Priority: stored window binding → detected state → install wizard. - */ -function routeHome(extensionUri: vscode.Uri, context: vscode.ExtensionContext, forcePicker = false): void { - // When forcePicker is set (e.g. after disconnect) always show the host picker — never auto-route. - if (forcePicker) { - HomePanel.createOrShow(extensionUri, true); - return; - } - - // 1. If this window already has a binding, honour it. - const binding = context.workspaceState.get(WINDOW_HOST_KEY); - if (binding?.type === 'local') { - void vscode.commands.executeCommand('openclaw.host.setup.local'); - return; - } - if (binding?.type === 'docker') { - void vscode.commands.executeCommand('openclaw.host.setup.docker'); - return; - } - - // 2. No binding — detect installed hosts and route. - const isLocalInstalled = fs.existsSync( - path.join(os.homedir(), '.openclaw', 'openclaw.json') - ); - - let isDockerRunning = false; - try { - const result = cp.spawnSync( - 'docker', - ['ps', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], - { timeout: 3000, windowsHide: true }, - ); - const st = (result.stdout?.toString() ?? '').trim(); - isDockerRunning = st.length > 0 && st.toLowerCase().startsWith('up'); - } catch { /* docker not available */ } - - if (isLocalInstalled && isDockerRunning) { - HomePanel.createOrShow(extensionUri); // hosts overview — user picks - } else if (isLocalInstalled) { - void vscode.commands.executeCommand('openclaw.host.setup.local'); - } else if (isDockerRunning) { - void vscode.commands.executeCommand('openclaw.host.setup.docker'); - } else { - HomePanel.createOrShow(extensionUri); // install wizard - } -} - /** Returns true if the OpenClaw web server is reachable. */ -function isWebServerReachable(portOverride?: number): Promise { - const port = portOverride ?? getConfiguredGatewayPort(); +function isWebServerReachable(): Promise { + const port = getConfiguredGatewayPort(); const url = `http://localhost:${port}/`; return new Promise(resolve => { const req = http.get(url, { timeout: 3000 }, res => { @@ -213,7 +135,7 @@ async function hideActivityBarItems( */ const WORKSPACE_FILENAME = 'My OpenClaw Workspace.code-workspace'; -async function openOpenClawFolder(context?: vscode.ExtensionContext): Promise { +async function openOpenClawFolder(): Promise { // Ensure ~/.occ exists — OCcode's internal state directory. const occPath = path.join(os.homedir(), '.occ'); if (!fs.existsSync(occPath)) { @@ -260,15 +182,9 @@ async function openOpenClawFolder(context?: vscode.ExtensionContext): Promise(WINDOW_HOST_KEY); - if (binding?.type !== 'docker') { - // Not bound to Docker — ensure ~/.openclaw is shown, not occ-state-dir. - setActiveOpenClawWorkspaceFolder(openclawPath); - } return; } @@ -279,7 +195,7 @@ async function openOpenClawFolder(context?: vscode.ExtensionContext): Promise void { // Restore cached backend balance so status bar shows the correct value immediately on startup @@ -373,13 +289,13 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => // FALLBACK: if extension globalState has no JWT (e.g. user signed in before this session // synced, or the extension was reloaded), read from occLegacyJwt in VS Code settings and // backfill extension globalState so future reads work without IPC. - let jwt = (await context.secrets.get(OCC_JWT_KEY)) ?? ''; + let jwt = context.globalState.get(OCC_JWT_KEY, ''); if (!jwt) { try { const legacyJwt = await vscode.commands.executeCommand('occ.auth.getLegacyJwt'); if (legacyJwt) { jwt = legacyJwt; - await context.secrets.store(OCC_JWT_KEY, jwt); + await context.globalState.update(OCC_JWT_KEY, jwt); } } catch { /* renderer not ready yet — will retry on next poll */ } } @@ -405,7 +321,7 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => const moltpilotKey = data.api_keys?.moltpilotKey ?? ''; // Guard: only sync back if the JWT wasn't cleared while the fetch was in-flight. // Without this check, an in-flight poll completing after sign-out would re-log the user in. - const currentJwt = (await context.secrets.get(OCC_JWT_KEY)) ?? ''; + const currentJwt = context.globalState.get(OCC_JWT_KEY, ''); if (currentJwt !== jwt) { return; } // Sync JWT + keys to renderer settings so ocFreeModel works vscode.commands.executeCommand('occ.auth.setLegacyJwt', jwt); @@ -436,7 +352,7 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => } } else if (r.status === 401) { // JWT expired or invalid — clear it, clear moltpilot key, and hide bar - void context.secrets.delete(OCC_JWT_KEY); + void context.globalState.update(OCC_JWT_KEY, ''); vscode.commands.executeCommand('occ.auth.setMoltpilotKey', ''); if (backendPollTimer) { clearInterval(backendPollTimer); backendPollTimer = undefined; } stopCountdown(); @@ -489,7 +405,7 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => // Called from the renderer (sidebarActions.ts occ.auth.setLegacyJwt) to sync the JWT // into extension-host storage so fetchAndUpdateBackendBalance can read it without IPC. vscode.commands.registerCommand('openclaw.jwt.set', async (token: string) => { - if (token) { await context.secrets.store(OCC_JWT_KEY, token); } else { await context.secrets.delete(OCC_JWT_KEY); } + await context.globalState.update(OCC_JWT_KEY, token ?? ''); // Stop polling immediately on sign-out so no more in-flight fetches can re-set the JWT if (!token && backendPollTimer) { clearInterval(backendPollTimer); @@ -509,13 +425,13 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => log(''); // 1. JWT - let jwt = (await context.secrets.get(OCC_JWT_KEY)) ?? ''; - log(`[1] JWT in SecretStorage (occJwtV1): ${jwt ? 'OK present [redacted]' : 'MISSING'}`); + let jwt = context.globalState.get(OCC_JWT_KEY, ''); + log(`[1] JWT in extension globalState (occJwtV1): ${jwt ? 'OK present (' + jwt.substring(0, 20) + '...)' : 'MISSING'}`); // Check what the renderer has for occLegacyJwt — this is what voidSettingsService reads try { const legacyJwt = await vscode.commands.executeCommand('occ.auth.getLegacyJwt'); - log(` occLegacyJwt in renderer settings: ${legacyJwt ? 'OK present [redacted]' : 'MISSING <-- this causes MoltPilot to use shared key!'}`); - if (!jwt && legacyJwt) { jwt = legacyJwt; await context.secrets.store(OCC_JWT_KEY, jwt); } + log(` occLegacyJwt in renderer settings: ${legacyJwt ? 'OK present (' + legacyJwt.substring(0, 20) + '...)' : 'MISSING <-- this causes MoltPilot to use shared key!'}`); + if (!jwt && legacyJwt) { jwt = legacyJwt; await context.globalState.update(OCC_JWT_KEY, jwt); } } catch { log(' occLegacyJwt check: renderer not ready'); } if (!jwt) { lines.push('\nNot signed in — cannot proceed.'); } @@ -535,8 +451,8 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => log(` Status: 200 OK`); log(` Email: ${d.email ?? '(not returned)'}`); log(` Balance: $${balanceBefore.toFixed(6)}`); - log(` MoltpilotKey: ${moltpilotKey ? 'OK [redacted]' : 'MISSING'}`); - log(` OccKey: ${occKey ? 'OK [redacted]' : 'MISSING'}`); + log(` MoltpilotKey: ${moltpilotKey ? 'OK ' + moltpilotKey.substring(0, 12) + '...' : 'MISSING'}`); + log(` OccKey: ${occKey ? 'OK ' + occKey.substring(0, 12) + '...' : 'MISSING'}`); } else { log(` HTTP ${r.status} -- JWT may be expired`); } @@ -599,245 +515,24 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => return () => {}; // spend is a no-op — kept so call sites don't break } -// ── Module-level context ref — needed by deactivate() which has no params ─── -let _extensionContext: vscode.ExtensionContext | undefined; - -// ── Protocol handler constants ──────────────────────────────────────────────── -const PROD_HANDLER_KEY = 'occ.linuxProtocolHandlerRegisteredFor'; -const DEV_PREV_HANDLER_KEY = 'occ.prevOccodeHandler'; -const LSREGISTER = '/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister'; - -// ── mimeapps.list helpers (Linux) ───────────────────────────────────────────── - -function ensureMimeSection(content: string, section: string, entry: string): string { - const header = `[${section}]`; - const keyPrefix = entry.split('=')[0] + '='; - if (!content.includes(header)) { - return content + (content.endsWith('\n') || content === '' ? '' : '\n') + `${header}\n${entry}\n`; - } - const lines = content.split('\n'); - const secIdx = lines.findIndex(l => l.trim() === header); - for (let i = secIdx + 1; i < lines.length; i++) { - if (lines[i].startsWith('[')) { lines.splice(secIdx + 1, 0, entry); return lines.join('\n'); } - if (lines[i].startsWith(keyPrefix)) { lines[i] = entry; return lines.join('\n'); } - } - lines.splice(secIdx + 1, 0, entry); - return lines.join('\n'); -} - -function removeMimeEntry(content: string, section: string, keyPrefix: string): string { - const header = `[${section}]`; - if (!content.includes(header)) return content; - const lines = content.split('\n'); - const secIdx = lines.findIndex(l => l.trim() === header); - for (let i = secIdx + 1; i < lines.length; i++) { - if (lines[i].startsWith('[')) break; - if (lines[i].startsWith(keyPrefix)) { lines.splice(i, 1); break; } - } - return lines.join('\n'); -} - -// ── Protocol handler registration ───────────────────────────────────────────── -// -// Dev mode (launch-editor.sh present): temporarily own occode:// for the -// lifetime of this process, restoring the previous handler on exit. -// -// Production: Linux-only permanent registration (existing behaviour). - -async function ensureDevProtocolHandler(context: vscode.ExtensionContext): Promise { - try { - const appRoot = vscode.env.appRoot; - const devLauncher = path.resolve(appRoot, '../../launch-editor.sh'); - const isDevMode = fs.existsSync(devLauncher); - - const product = JSON.parse( - fs.readFileSync(path.join(appRoot, 'product.json'), 'utf-8') - ) as { applicationName: string; urlProtocol: string; nameLong: string; linuxIconName: string; linuxDescription: string }; - - if (!isDevMode) { - // ── Production: Linux-only permanent registration ────────────────────── - if (process.platform !== 'linux') return; - const execPath = process.execPath; - const stored = context.globalState.get(PROD_HANDLER_KEY, ''); - if (stored === execPath) return; - - const appsDir = path.join(os.homedir(), '.local', 'share', 'applications'); - fs.mkdirSync(appsDir, { recursive: true }); - const desktopContent = [ - '[Desktop Entry]', - `Name=${product.nameLong} - URL Handler`, - `Comment=${product.linuxDescription || ''}`, - 'GenericName=Text Editor', - `Exec="${execPath}" --open-url %U`, - `Icon=${product.linuxIconName}`, - 'Type=Application', - 'NoDisplay=true', - 'StartupNotify=true', - 'Categories=Utility;TextEditor;Development;IDE;', - `MimeType=x-scheme-handler/${product.urlProtocol};`, - 'Keywords=vscode;', - '', - ].join('\n'); - fs.writeFileSync(path.join(appsDir, `${product.applicationName}-url-handler.desktop`), desktopContent, 'utf-8'); - - const mimeappsPath = path.join(os.homedir(), '.config', 'mimeapps.list'); - const mimeEntry = `x-scheme-handler/${product.urlProtocol}=${product.applicationName}-url-handler.desktop`; - let mimeContent = ''; - try { mimeContent = fs.readFileSync(mimeappsPath, 'utf-8'); } catch {} - mimeContent = ensureMimeSection(mimeContent, 'Default Applications', mimeEntry); - mimeContent = ensureMimeSection(mimeContent, 'Added Associations', mimeEntry); - fs.mkdirSync(path.dirname(mimeappsPath), { recursive: true }); - fs.writeFileSync(mimeappsPath, mimeContent, 'utf-8'); - cp.spawn('update-desktop-database', [appsDir], { stdio: 'ignore', detached: true }).unref(); - await context.globalState.update(PROD_HANDLER_KEY, execPath); - return; - } - - // ── Dev mode: temporarily own occode:// ─────────────────────────────────── - - if (process.platform === 'linux') { - const appsDir = path.join(os.homedir(), '.local', 'share', 'applications'); - const desktopPath = path.join(appsDir, `${product.applicationName}-url-handler.desktop`); - const mimeappsPath = path.join(os.homedir(), '.config', 'mimeapps.list'); - const keyPrefix = `x-scheme-handler/${product.urlProtocol}=`; - - // Read current mimeapps entry — this is what we'll restore on exit - let mimeContent = ''; - try { mimeContent = fs.readFileSync(mimeappsPath, 'utf-8'); } catch {} - const existingEntry = mimeContent.split('\n').find(l => l.startsWith(keyPrefix)) ?? ''; - - // If the existing .desktop has a dead dev PID, don't restore that ghost - let prevEntry = existingEntry; - try { - const existingDesktop = fs.readFileSync(desktopPath, 'utf-8'); - const pidMatch = existingDesktop.match(/^X-OCC-Dev-PID=(\d+)$/m); - if (pidMatch) { - const pid = parseInt(pidMatch[1], 10); - let isAlive = false; - try { process.kill(pid, 0); isAlive = true; } catch {} - if (!isAlive) prevEntry = ''; // stale — nothing to restore - } - } catch {} - await context.globalState.update(DEV_PREV_HANDLER_KEY, prevEntry); - - // Write .desktop with dev PID marker - fs.mkdirSync(appsDir, { recursive: true }); - const desktopContent = [ - '[Desktop Entry]', - `Name=${product.nameLong} - URL Handler`, - `Comment=${product.linuxDescription || ''}`, - 'GenericName=Text Editor', - `Exec="${devLauncher}" --open-url %U`, - `Icon=${product.linuxIconName}`, - 'Type=Application', - 'NoDisplay=true', - 'StartupNotify=true', - 'Categories=Utility;TextEditor;Development;IDE;', - `MimeType=x-scheme-handler/${product.urlProtocol};`, - 'Keywords=vscode;', - `X-OCC-Dev-PID=${process.pid}`, - '', - ].join('\n'); - fs.writeFileSync(desktopPath, desktopContent, 'utf-8'); - - const mimeEntry = `x-scheme-handler/${product.urlProtocol}=${product.applicationName}-url-handler.desktop`; - mimeContent = ensureMimeSection(mimeContent, 'Default Applications', mimeEntry); - mimeContent = ensureMimeSection(mimeContent, 'Added Associations', mimeEntry); - fs.mkdirSync(path.dirname(mimeappsPath), { recursive: true }); - fs.writeFileSync(mimeappsPath, mimeContent, 'utf-8'); - cp.spawn('update-desktop-database', [appsDir], { stdio: 'ignore', detached: true }).unref(); - - } else if (process.platform === 'darwin') { - const appBundle = path.join(appRoot, `../../.build/electron/${product.nameLong}.app`); - if (!fs.existsSync(appBundle)) return; - await context.globalState.update(DEV_PREV_HANDLER_KEY, appBundle); - cp.spawn(LSREGISTER, ['-R', '-f', appBundle], { stdio: 'ignore', detached: true }).unref(); - - } else if (process.platform === 'win32') { - const proto = product.urlProtocol; - const electronExe = process.execPath; - await context.globalState.update(DEV_PREV_HANDLER_KEY, 'win32-dev'); - const cmd = `"${electronExe}" --open-url "%1"`; - cp.execSync(`reg add "HKCU\\Software\\Classes\\${proto}" /ve /d "URL:${proto} protocol" /f`); - cp.execSync(`reg add "HKCU\\Software\\Classes\\${proto}" /v "URL Protocol" /d "" /f`); - cp.execSync(`reg add "HKCU\\Software\\Classes\\${proto}\\shell\\open\\command" /ve /d "${cmd}" /f`); - } - } catch { - // Silent failure — non-critical - } -} - -async function releaseDevProtocolHandler(context: vscode.ExtensionContext): Promise { - try { - const appRoot = vscode.env.appRoot; - const devLauncher = path.resolve(appRoot, '../../launch-editor.sh'); - if (!fs.existsSync(devLauncher)) return; // production — nothing to release - - const product = JSON.parse( - fs.readFileSync(path.join(appRoot, 'product.json'), 'utf-8') - ) as { applicationName: string; urlProtocol: string; nameLong: string; linuxIconName: string; linuxDescription: string }; - - const prevEntry = context.globalState.get(DEV_PREV_HANDLER_KEY, ''); - - if (process.platform === 'linux') { - const appsDir = path.join(os.homedir(), '.local', 'share', 'applications'); - const mimeappsPath = path.join(os.homedir(), '.config', 'mimeapps.list'); - const keyPrefix = `x-scheme-handler/${product.urlProtocol}=`; - let mimeContent = ''; - try { mimeContent = fs.readFileSync(mimeappsPath, 'utf-8'); } catch {} - - if (prevEntry) { - mimeContent = ensureMimeSection(mimeContent, 'Default Applications', prevEntry); - mimeContent = ensureMimeSection(mimeContent, 'Added Associations', prevEntry); - } else { - mimeContent = removeMimeEntry(mimeContent, 'Default Applications', keyPrefix); - mimeContent = removeMimeEntry(mimeContent, 'Added Associations', keyPrefix); - } - fs.writeFileSync(mimeappsPath, mimeContent, 'utf-8'); - cp.spawn('update-desktop-database', [appsDir], { stdio: 'ignore', detached: true }).unref(); - - } else if (process.platform === 'darwin') { - if (prevEntry) { - cp.spawn(LSREGISTER, ['-R', '-u', prevEntry], { stdio: 'ignore', detached: true }).unref(); - } - - } else if (process.platform === 'win32') { - const proto = product.urlProtocol; - try { cp.execSync(`reg delete "HKCU\\Software\\Classes\\${proto}" /f`); } catch {} - } - - await context.globalState.update(DEV_PREV_HANDLER_KEY, undefined); - } catch { - // Silent failure — non-critical - } -} - export async function activate(context: vscode.ExtensionContext): Promise { - // ── One-time migration: move JWT from globalState → SecretStorage ──────────── - const legacyJwt = context.globalState.get(OCC_JWT_KEY, ''); - if (legacyJwt) { - await context.secrets.store(OCC_JWT_KEY, legacyJwt); - await context.globalState.update(OCC_JWT_KEY, undefined); - } - - _extensionContext = context; - - // ── Dev: temporarily own occode:// scheme; production: permanent Linux registration ── - void ensureDevProtocolHandler(context); - - // Release ownership on exit — covers graceful shutdown, Ctrl+C, and SIGTERM. - const releaseHandler = () => { if (_extensionContext) void releaseDevProtocolHandler(_extensionContext); }; - process.once('SIGTERM', releaseHandler); - process.once('SIGINT', releaseHandler); - context.subscriptions.push({ dispose: releaseHandler }); - // ── MultiHost: HostRegistry + HostManager ─────────────────────────────────── const hostRegistry = new HostRegistry(); await hostRegistry.init(); const hostManager = new HostManager(hostRegistry); context.subscriptions.push(hostRegistry, hostManager); - // (OPENCLAW HOSTS tree view and status bar removed — window-level binding used instead) + // 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( @@ -861,7 +556,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { + // Store JWT in extension-host storage immediately (no renderer IPC needed). + void context.globalState.update(OCC_JWT_KEY, token).then(() => { // Also sync to renderer settings service (for chat / other renderer consumers). vscode.commands.executeCommand('occ.auth.setLegacyJwt', token); }); @@ -890,87 +585,20 @@ export async function activate(context: vscode.ExtensionContext): Promise { - routeHome(context.extensionUri, context); - }), - // Always shows the host picker regardless of what is installed — used after disconnect. - vscode.commands.registerCommand('openclaw.home.picker', () => { - routeHome(context.extensionUri, context, true); - }), - // Host-specific setup commands — invoked by routeHome() and the host picker in HomePanel. - // Each sets the window binding for this host type then opens the home panel, - // which renders the appropriate dashboard or setup wizard based on the binding. - vscode.commands.registerCommand('openclaw.host.setup.local', async () => { - const existing = context.workspaceState.get(WINDOW_HOST_KEY); - if (!existing || existing.type !== 'local') { - await context.workspaceState.update(WINDOW_HOST_KEY, { - type: 'local', hostId: 'local:main', port: getConfiguredGatewayPort(), label: 'Local', - } satisfies WindowHostBinding); - } - HomePanel.createOrShow(context.extensionUri); - }), - vscode.commands.registerCommand('openclaw.host.setup.docker', async () => { - const existing = context.workspaceState.get(WINDOW_HOST_KEY); - if (!existing || existing.type !== 'docker') { - await context.workspaceState.update(WINDOW_HOST_KEY, { - type: 'docker', hostId: 'docker:occ-openclaw', port: DEFAULT_GATEWAY_PORT, label: 'Docker', - } satisfies WindowHostBinding); - } - HomePanel.createOrShow(context.extensionUri, false, 'docker'); - }), - vscode.commands.registerCommand('openclaw.host.setup.ssh', () => { - // SSH host details are entered via the home panel UI — open it and let the panel drive. HomePanel.createOrShow(context.extensionUri); }), vscode.commands.registerCommand('openclaw.configure', async () => { - const windowHostBinding = context.workspaceState.get(WINDOW_HOST_KEY); - - // ── Docker path ─────────────────────────────────────────────────────── - if (windowHostBinding?.type === 'docker') { - const container = windowHostBinding.hostId.replace(/^docker:/, '') || 'occ-openclaw'; - const hostPort = windowHostBinding.port; // e.g. 18790 - const containerPort = 18789; - - // 1. Start the gateway inside the container (detached — safe if already running) - cp.spawn('docker', ['exec', '-d', container, 'openclaw', 'gateway', 'run'], { - windowsHide: true, - detached: true, - }).unref(); - - // 2. Give it a moment to start, then get the tokenized dashboard URL - await new Promise(r => setTimeout(r, 2000)); - - const dashResult = cp.spawnSync( - 'docker', - ['exec', container, 'openclaw', 'dashboard', '--no-open'], - { timeout: 10000, windowsHide: true, encoding: 'utf-8' }, - ); - let rawUrl = (dashResult.stdout as string ?? '').trim(); - - if (rawUrl) { - // Rewrite the internal container port to the host-mapped port - const url = rawUrl - .replace(new RegExp(`localhost:${containerPort}`, 'g'), `localhost:${hostPort}`) - .replace(new RegExp(`127\\.0\\.0\\.1:${containerPort}`, 'g'), `127.0.0.1:${hostPort}`); - await vscode.env.openExternal(vscode.Uri.parse(url)); - } else { - // dashboard command failed — open plain URL as fallback - await vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${hostPort}/`)); - } - return; - } - - // ── Local / SSH path ────────────────────────────────────────────────── - const effectivePort = windowHostBinding ? windowHostBinding.port : getConfiguredGatewayPort(); - const reachable = await isWebServerReachable(effectivePort); + const reachable = await isWebServerReachable(); if (reachable) { - const url = getDashboardUrl()?.url ?? `http://localhost:${effectivePort}/`; + const dashInfo = getDashboardUrl(); + const url = dashInfo?.url ?? `http://localhost:${getConfiguredGatewayPort()}/`; await vscode.env.openExternal(vscode.Uri.parse(url)); } else { - const configUrl = `http://localhost:${effectivePort}/`; + // Web server not running — ask the AI to start it + const port = getConfiguredGatewayPort(); + const configUrl = `http://localhost:${port}/`; const message = `The OpenClaw web configuration server is not running at ${configUrl}.\n\n` + `Please start it now by running the OpenClaw gateway in the terminal:\n` + @@ -1000,27 +628,16 @@ export async function activate(context: vscode.ExtensionContext): Promise { - // Delegate to LocalSetupPanel via the host setup command - void vscode.commands.executeCommand('openclaw.host.setup.local'); + void HomePanel.runInstall( + context.extensionUri, + process.platform, + process.arch, + process.env.SHELL ?? '', + ); }), vscode.commands.registerCommand('openclaw.openWorkspace', () => { void openOpenClawFolder(); }), - vscode.commands.registerCommand('occ.setup.reset', async (options?: { full?: boolean }) => { - const full = options?.full === true; - const confirm = await vscode.window.showWarningMessage( - 'This will stop all Docker containers and remove volumes. Your openclaw.json will be preserved. Continue?', - 'Yes, Reset', - 'Cancel', - ); - if (confirm !== 'Yes, Reset') return; - - await context.workspaceState.update(WINDOW_HOST_KEY, undefined); - - HomePanel.currentPanel?.resetSetup(full); - - HomePanel.createOrShow(context.extensionUri, true); - }), vscode.commands.registerCommand('openclaw.status', () => { StatusPanel.createOrShow(context.extensionUri); }), @@ -1056,7 +673,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { - const child = cp.spawn('sudo', ['-S', 'bash', '-c', command], { + const child = require('child_process').spawn('sudo', ['-S', 'bash', '-c', command], { stdio: ['pipe', 'pipe', 'pipe'], }); child.stdin?.write(password + '\n'); @@ -1106,12 +723,7 @@ export async function activate(context: vscode.ExtensionContext): Promise => { - // Resolve window-level host binding first - const windowHost = context.workspaceState.get(WINDOW_HOST_KEY) ?? null; const homedir = os.homedir(); const configPath = path.join(homedir, '.openclaw', 'openclaw.json'); const installed = fs.existsSync(configPath); @@ -1121,11 +733,8 @@ export async function activate(context: vscode.ExtensionContext): Promise { - if (windowHost?.type === 'docker' || windowHost?.type === 'ssh') { - return windowHost.port; - } const p = config['port'] ?? config['gateway_port'] ?? config['gatewayPort']; if (p === undefined) return 18789; const n = Number(p); @@ -1187,12 +796,7 @@ export async function activate(context: vscode.ExtensionContext): Promise 0; - const hostType = windowHost?.type ?? 'local'; - const containerName = (windowHost?.type === 'docker' || windowHost?.type === 'ssh') - ? (windowHost.hostId ?? null) - : null; - - return { installed, gatewayRunning, hasAgents, agentNames, hasAiModel, hasChannels, channelNames, hostType, containerName, gatewayPort: port }; + return { installed, gatewayRunning, hasAgents, agentNames, hasAiModel, hasChannels, channelNames }; }), ); @@ -1231,7 +835,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { - routeHome(context.extensionUri, context); + HomePanel.createOrShow(context.extensionUri); }, 500); // Return OpenClawCoreAPI so adapter extensions can register their adapters. @@ -1240,5 +844,4 @@ export async function activate(context: vscode.ExtensionContext): Promise): 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) { From 9dc166a82593b843c03ea16faaffe8bb2bfe5777 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:50:08 -0400 Subject: [PATCH 004/144] =?UTF-8?q?feat(multihost):=20openclaw-local=20ext?= =?UTF-8?q?ension=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 +- .../openclaw-local/src/connection.ts | 20 ------------------- .../openclaw-local/src/extension.ts | 6 ------ .../extensions/openclaw-local/tsconfig.json | 2 +- .../extensions/openclaw-ssh/tsconfig.json | 2 +- 5 files changed, 3 insertions(+), 29 deletions(-) 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/connection.ts b/apps/editor/extensions/openclaw-local/src/connection.ts index e6fe8303..de5ee339 100644 --- a/apps/editor/extensions/openclaw-local/src/connection.ts +++ b/apps/editor/extensions/openclaw-local/src/connection.ts @@ -79,7 +79,6 @@ export class LocalHostConnection implements HostConnection { env: { ...process.env, ...opts.env }, timeout: opts.timeout, windowsHide: opts.windowsHide ?? true, - shell: opts.shell, }); if (opts.stdinData !== undefined) { @@ -112,7 +111,6 @@ export class LocalHostConnection implements HostConnection { env: { ...process.env, ...opts.env }, timeout: opts.timeout, windowsHide: opts.windowsHide ?? true, - shell: opts.shell, }); if (opts.stdinData !== undefined) { @@ -346,24 +344,6 @@ export class LocalHostConnection implements HostConnection { if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } } - async gatewayReboot(onLog: LogFn): Promise { - const cliPath = await this.findOpenClawPath(); - if (cliPath) { - try { - const code = await this.execStream(cliPath, ['gateway', 'reboot'], {}, onLog, onLog); - if (code === 0) return; - } catch { /* fall through to OS-level reboot */ } - } - onLog('openclaw gateway reboot unavailable — falling back to OS reboot'); - if (process.platform === 'win32') { - const code = await this.execStream('shutdown', ['/r', '/t', '0'], { windowsHide: true }, onLog, onLog); - if (code !== 0) { throw new Error(`OS reboot command exited with code ${code}`); } - } else { - const code = await this.execStream('sudo', ['reboot'], {}, onLog, onLog); - if (code !== 0) { throw new Error(`OS reboot command exited with code ${code}`); } - } - } - // ── Full install + onboard ──────────────── async runSetup(params: SetupParams, onLog: LogFn): Promise { diff --git a/apps/editor/extensions/openclaw-local/src/extension.ts b/apps/editor/extensions/openclaw-local/src/extension.ts index c1097f18..e0619b15 100644 --- a/apps/editor/extensions/openclaw-local/src/extension.ts +++ b/apps/editor/extensions/openclaw-local/src/extension.ts @@ -1,7 +1,6 @@ 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 @@ -24,11 +23,6 @@ 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/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 88ba53786ffebeea87680e2716848182fed075e2 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:52:40 -0400 Subject: [PATCH 005/144] 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 --- .../openclaw-docker/src/connection.ts | 25 +-------------- .../openclaw-docker/src/extension.ts | 6 ---- .../extensions/openclaw-ssh/src/extension.ts | 31 +++++-------------- 3 files changed, 9 insertions(+), 53 deletions(-) diff --git a/apps/editor/extensions/openclaw-docker/src/connection.ts b/apps/editor/extensions/openclaw-docker/src/connection.ts index cae96a9b..c85fc7c5 100644 --- a/apps/editor/extensions/openclaw-docker/src/connection.ts +++ b/apps/editor/extensions/openclaw-docker/src/connection.ts @@ -227,15 +227,7 @@ export class DockerHostConnection implements HostConnection { // ── OpenClaw config ─────────────────────── async getConfigPath(): Promise { - return '/home/node/.openclaw/openclaw.json'; // official image runs as user 'node' (uid 1000) - } - - gatewayHostPort(): number | undefined { - return this._config.portMappings?.gateway; - } - - localStateDir(): string { - return this._config.localMountPath ?? path.join(os.homedir(), 'Desktop', 'occ-state-dir'); + return '/root/.openclaw/openclaw.json'; // containers typically run as root } async readConfig(): Promise { @@ -310,21 +302,6 @@ export class DockerHostConnection implements HostConnection { if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } } - async gatewayReboot(onLog: LogFn): Promise { - const cliPath = await this.findOpenClawPath(); - if (cliPath) { - try { - const code = await this.execStream(cliPath, ['gateway', 'reboot'], {}, onLog, onLog); - if (code === 0) return; - } catch { /* fall through to warning */ } - } - // For Docker hosts, we cannot reboot the host from inside the container. - // The existing "Restart" button handles container restart. Reboot should - // prompt the user to reboot the host machine externally. - onLog('Cannot reboot Docker host from inside container — please reboot the host machine externally'); - throw new Error('Docker host reboot requires external reboot (not container restart)'); - } - // ── Full install + onboard ──────────────── async runSetup(params: SetupParams, onLog: LogFn): Promise { diff --git a/apps/editor/extensions/openclaw-docker/src/extension.ts b/apps/editor/extensions/openclaw-docker/src/extension.ts index 63d70fef..a49e0c93 100644 --- a/apps/editor/extensions/openclaw-docker/src/extension.ts +++ b/apps/editor/extensions/openclaw-docker/src/extension.ts @@ -1,7 +1,6 @@ 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'); @@ -23,11 +22,6 @@ 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-ssh/src/extension.ts b/apps/editor/extensions/openclaw-ssh/src/extension.ts index 47ac0855..55a38ee4 100644 --- a/apps/editor/extensions/openclaw-ssh/src/extension.ts +++ b/apps/editor/extensions/openclaw-ssh/src/extension.ts @@ -1,28 +1,13 @@ import * as vscode from 'vscode'; -import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; -import { SSHHostAdapter } from './adapter'; -import { SSHSetupPanel } from './setup-panel'; -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'); +/** + * 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 13b332a5c53e1d7113921f95ab2f90ce3a30327f Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:57:04 -0400 Subject: [PATCH 006/144] 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 d8814d5af49636e02aa905552b1623635e80d3d5 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 05:14:48 -0400 Subject: [PATCH 007/144] =?UTF-8?q?feat(multihost):=20Phase=202b=20?= =?UTF-8?q?=E2=80=94=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 | 26 - .../extensions/openclaw/src/hosts/types.ts | 2 + .../extensions/openclaw/src/panels/home.ts | 3659 ++++++++++------- 4 files changed, 2098 insertions(+), 1591 deletions(-) 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 index 0b783554..179b4c98 100644 --- a/apps/editor/extensions/openclaw/src/hosts/localDefault.ts +++ b/apps/editor/extensions/openclaw/src/hosts/localDefault.ts @@ -286,24 +286,6 @@ export class DefaultLocalHostConnection implements HostConnection { if (code !== 0) { throw new Error(`gateway restart exited ${code}`); } } - async gatewayReboot(onLog: LogFn): Promise { - const p = await this.findOpenClawPath(); - if (p) { - try { - const code = await this.execStream(p, ['gateway', 'reboot'], {}, onLog, onLog); - if (code === 0) return; - } catch { /* fall through to OS-level reboot */ } - } - // Fallback to OS-level reboot - if (process.platform === 'win32') { - onLog('openclaw gateway reboot unavailable — falling back to OS reboot'); - await this.execStream('shutdown', ['/r', '/t', '0'], { windowsHide: true }, onLog, onLog); - } else { - onLog('openclaw gateway reboot unavailable — falling back to sudo reboot'); - await this.execStream('sudo', ['reboot'], {}, onLog, onLog); - } - } - async runSetup(params: SetupParams, onLog: LogFn): Promise { const p = await this.findOpenClawPath(); if (!p) { throw new Error('CLI not installed'); } @@ -311,14 +293,6 @@ export class DefaultLocalHostConnection implements HostConnection { if (code !== 0) { throw new Error(`onboard exited ${code}`); } } - gatewayHostPort(): number | undefined { - return undefined; // local host: no port remapping - } - - localStateDir(): string { - return path.join(os.homedir(), '.openclaw'); - } - 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 b64f788e..20af41df 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -7,10 +7,8 @@ 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'; -import { setActiveOpenClawWorkspaceFolder, closeFilesFromDir } from './statusController'; -type GatewayStatus = 'checking' | 'running' | 'stopped' | 'starting' | 'stopping' | 'restarting' | 'rebooting' | 'errored' | 'ai-fixing'; +type GatewayStatus = 'checking' | 'running' | 'stopped' | 'starting' | 'stopping' | 'restarting' | 'errored' | 'ai-fixing'; // ── Persistent diagnostics log ──────────────────────────────────────────────── const LOG_PATH = path.join(os.homedir(), '.openclaw', 'occ-home.log'); @@ -87,10 +85,14 @@ 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[] = []; - private _commandAction: 'start' | 'stop' | 'restart' | 'reboot' | null = null; + private _commandAction: 'start' | 'stop' | 'restart' | null = null; private _sidebarOpen = false; // tracks chat sidebar open state across webview reloads private _pollingTimer: ReturnType | undefined; private readonly _outputChannel: vscode.OutputChannel; @@ -106,33 +108,15 @@ 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; - /** When true, always show the host picker — never auto-route to a single installed host. */ - private _forcePicker = false; - /** When set, show the Docker/Local setup wizard instead of the install wizard. */ - private _setupFor: 'docker' | 'local' | null = null; - /** True once _getSetupHtml() has been rendered — prevents onDidChangeViewState re-renders from resetting in-progress wizard state. */ - private _setupHtmlShown = false; - private readonly _version: string; - private _dashboardAutoOpened = false; - - // Docker detection cache (5-minute TTL) - private static _dockerDetectionCache: { timestamp: number; result: Awaited> } | null = null; - private static readonly DOCKER_DETECTION_TTL = 5 * 60 * 1000; // 5 minutes - - private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, forcePicker = false, setupFor?: 'docker' | 'local') { - this._forcePicker = forcePicker; - this._setupFor = setupFor ?? null; + + private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { this._panel = panel; this._extensionUri = extensionUri; - this._version = (vscode.extensions.getExtension('openclaw.home')?.packageJSON as { version?: string })?.version ?? ''; 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; - this._coreAPI = coreAPI; const hostSub = coreAPI.onDidChangeActiveHost(conn => { this._host = conn ?? new DefaultLocalHostConnection(); void this._update(); @@ -144,7 +128,7 @@ export class HomePanel { const iconUri = this._panel.webview.asWebviewUri( vscode.Uri.joinPath(this._extensionUri, 'media', 'icon.png') ); - this._panel.webview.html = this._getLoadingHtml(iconUri.toString(), this._version); + this._panel.webview.html = this._getLoadingHtml(iconUri.toString()); void this._update(); this._panel.onDidDispose(() => this.dispose(), null, this._disposables); // Re-check installation whenever the panel becomes visible again. @@ -167,9 +151,9 @@ export class HomePanel { homeWatcher.onDidCreate(() => void this._update(), null, this._disposables); homeWatcher.onDidDelete(() => void this._update(), null, this._disposables); this._disposables.push(homeWatcher); - this._panel.webview.onDidReceiveMessage(async msg => { + this._panel.webview.onDidReceiveMessage(msg => { if (msg.command === 'gatewayAction') { - void this._handleGatewayAction(msg.action as 'start' | 'stop' | 'restart' | 'reboot'); + void this._handleGatewayAction(msg.action as 'start' | 'stop' | 'restart'); } else if (msg.command === 'checkVersion') { void this._checkLatestVersion(); } else if (msg.command === 'runUpdate') { @@ -178,6 +162,47 @@ 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 () => { @@ -206,12 +231,6 @@ export class HomePanel { } else { vscode.commands.executeCommand('vscode.open', vscode.Uri.file(LOG_PATH)); } - } else if (msg.command === 'localSetupCommand') { - const { step } = msg; - void this._handleLocalSetupStep(step); - } else if (msg.command === 'occ.setup.reset') { - const { full } = msg; - void vscode.commands.executeCommand('occ.setup.reset', { full }); } else if (msg.command === 'openWorkspaceFile') { const allowed = new Set(['AGENTS.md', 'IDENTITY.md', 'USER.md', 'MEMORY.md', 'SOUL.md', 'HEARTBEAT.md']); const file = msg.file as string; @@ -274,114 +293,15 @@ export class HomePanel { if (args && args.length > 0) { void vscode.commands.executeCommand('void.openChatWithMessage', args[0], 'agent'); } - } else if (msg.command === 'dockerGetDefaultPath') { - try { - this._panel.webview.postMessage({ type: 'dockerDefaultPath', path: HomePanel.getDefaultOpenClawDataPath() }); - } catch { /* non-fatal */ } - } else if (msg.command === 'dockerLoadEnv') { - try { - // Read existing .env from docker directory - const envPath = path.join(this._extensionUri.fsPath, '..', '..', '..', 'docker', '.env'); - let dataPath = ''; - let gatewayPort = '18789'; - if (fs.existsSync(envPath)) { - const content = fs.readFileSync(envPath, 'utf-8'); - const dataDirMatch = content.match(/^OPENCLAW_DATA_DIR=(.+)$/m); - const portMatch = content.match(/^GATEWAY_PORT=(.+)$/m); - if (dataDirMatch?.[1]) dataPath = dataDirMatch[1].trim(); - if (portMatch?.[1]) gatewayPort = portMatch[1].trim(); - } - this._panel.webview.postMessage({ type: 'dockerEnvLoaded', dataPath, gatewayPort }); - } catch { /* non-fatal */ } - } else if (msg.command === 'dockerSaveEnv') { - try { - const dataPath = msg.dataPath as string; - const gatewayPort = msg.gatewayPort as string; - const envPath = path.join(this._extensionUri.fsPath, '..', '..', '..', 'docker', '.env'); - let content = ''; - if (fs.existsSync(envPath)) { - // Preserve existing content, update only our vars - content = fs.readFileSync(envPath, 'utf-8'); - content = content.replace(/^OPENCLAW_DATA_DIR=.*$/m, `OPENCLAW_DATA_DIR=${dataPath || './openclaw_docker_data'}`); - content = content.replace(/^GATEWAY_PORT=.*$/m, `GATEWAY_PORT=${gatewayPort || '18789'}`); - if (!content.includes('OPENCLAW_DATA_DIR=')) content += `\nOPENCLAW_DATA_DIR=${dataPath || './openclaw_docker_data'}`; - if (!content.includes('GATEWAY_PORT=')) content += `\nGATEWAY_PORT=${gatewayPort || '18789'}`; - } else { - content = `OPENCLAW_DATA_DIR=${dataPath || './openclaw_docker_data'}\nGATEWAY_PORT=${gatewayPort || '18789'}\n`; - } - fs.writeFileSync(envPath, content, 'utf-8'); - } catch { /* non-fatal */ } - } else if (msg.command === 'dockerRunDoctor') { - const dataPath = msg.dataPath as string || HomePanel.getDefaultOpenClawDataPath(); - const gatewayPort = msg.gatewayPort as string | undefined; - const post = (m: object) => { try { this._panel.webview.postMessage(m); } catch {} }; - // Show spinner on all items first - post({ type: 'doctorUpdate', items: [ - { label: 'Detecting operating system…', status: 'pending' }, - { label: 'Looking for Docker or Podman…', status: 'pending' }, - ], allPassed: false, canRetry: false }); - const result = await HomePanel.detectDockerEnvironment(process.platform); - post({ type: 'doctorUpdate', ...result }); - // Store runtime for provisioning - (this as any)._dockerRuntime = result.runtime ?? 'docker'; - (this as any)._dockerDataPath = dataPath; - (this as any)._dockerGatewayPort = gatewayPort; - } else if (msg.command === 'dockerProvision') { - const dataPath = (msg.dataPath as string) || (this as any)._dockerDataPath || HomePanel.getDefaultOpenClawDataPath(); - const runtime: 'docker' | 'podman' = (this as any)._dockerRuntime ?? 'docker'; - const gatewayPort = (msg.gatewayPort as string) || (this as any)._dockerGatewayPort; - const post = (m: object) => { try { this._panel.webview.postMessage(m); } catch {} }; - void HomePanel.runDockerProvision(post, dataPath, this._extensionUri.fsPath, runtime, gatewayPort) - .then(() => { - // AI config panel is shown by the webview JS on provisionStatus done:ok. - // _update() is called after AI config is saved or skipped. - // Auto-open the web control panel after a short delay (guarded to prevent double-open). - if (!this._dashboardAutoOpened) { - this._dashboardAutoOpened = true; - setTimeout(() => void vscode.commands.executeCommand('openclaw.configure'), 2000); - } - }); - } else if (msg.command === 'saveAiConfig') { - const provider = msg.provider as string; - const apiKey = msg.apiKey as string; - void this._saveAiConfig(provider, apiKey); - } else if (msg.command === 'skipAiConfig') { - void this._skipAiConfig(); - } else if (msg.command === 'dockerCancel') { - const runtime: 'docker' | 'podman' = (this as any)._dockerRuntime ?? 'docker'; - void HomePanel.runDockerTeardown(this._extensionUri.fsPath, runtime); - } else if (msg.command === 'backToHostPicker') { - this._setupFor = null; - this._setupHtmlShown = false; - void this._update(); - } else if (msg.command === 'chooseHostType') { - const t = msg.hostType as string; - // Best-effort: close files from the other host's dir (non-blocking). - if (t === 'local') { void closeFilesFromDir(path.join(os.homedir(), 'Desktop', 'occ-state-dir')); } - else if (t === 'docker') { void closeFilesFromDir(path.join(os.homedir(), '.openclaw')); } - // Close the picker immediately — the adapter panel takes over as the sole OCC Home tab. - this.dispose(); - 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 === 'checkHostsStatus') { - void this._handleCheckHostsStatus(); } else if (msg.command) { vscode.commands.executeCommand(msg.command); } }, null, this._disposables); } - public static createOrShow(extensionUri: vscode.Uri, forcePicker = false, setupFor?: 'docker' | 'local') { + public static createOrShow(extensionUri: vscode.Uri) { if (HomePanel.currentPanel) { - if (forcePicker) { HomePanel.currentPanel._forcePicker = true; } - if (setupFor !== undefined) { HomePanel.currentPanel._setupFor = setupFor; HomePanel.currentPanel._setupHtmlShown = false; } HomePanel.currentPanel._panel.reveal(); - if (forcePicker || setupFor !== undefined) { void HomePanel.currentPanel._update(); } return; } const panel = vscode.window.createWebviewPanel( @@ -390,7 +310,7 @@ export class HomePanel { vscode.Uri.joinPath(extensionUri, 'media'), ] } ); - HomePanel.currentPanel = new HomePanel(panel, extensionUri, forcePicker, setupFor); + HomePanel.currentPanel = new HomePanel(panel, extensionUri); } /** Push a live balance update to the webview popover — called from extension.ts balance poller. */ @@ -405,6 +325,405 @@ 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(); @@ -454,9 +773,7 @@ export class HomePanel { const cliCheck = await this._testOpenClawCli(); writeLog(`[cli-check] ok=${cliCheck.ok} cmd="${cliCheck.command}" output="${(cliCheck.output ?? '').trim()}"\n`); - // 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); + 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; const iconUri = this._panel.webview.asWebviewUri( @@ -476,58 +793,11 @@ export class HomePanel { } catch { /* network error — leave null */ } } - // Check whether the Docker occ-openclaw container is also running. - let isDockerRunning = false; - try { - const dc = cp.spawnSync( - 'docker', - ['ps', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], - { timeout: 3000, windowsHide: true }, - ); - const st = (dc.stdout?.toString() ?? '').trim(); - isDockerRunning = st.length > 0 && st.toLowerCase().startsWith('up'); - } catch { /* docker not available */ } - - // Both hosts active → show the hosts overview (live status picker). - // Also always show it when forcePicker is set (e.g. after disconnect). - if ((isConfigured && isDockerRunning) || this._forcePicker) { - this._stopPolling(); - // Always read the local port directly from disk — _cachedGatewayPort may reflect - // the Docker host port (18790) if Docker was the last active host. - let localPort = 18789; - try { - const raw = fs.readFileSync(path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf-8'); - const cfg = JSON.parse(raw) as Record; - 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) { localPort = n; } - } catch { /* use default */ } - this._panel.webview.html = this._getHostsOverviewHtml(iconUri.toString(), localPort, this._version); - return; - } - - // Docker/Local setup wizard: setupFor is explicitly set by the user choosing - // Docker or Local from the host picker. Show the setup wizard regardless of - // whether there's an existing config - the user explicitly wants to set up that host. - // Only render once — subsequent _update() calls (from onDidChangeViewState, - // polling, etc.) must NOT replace the HTML or they'll reset in-progress - // wizard navigation state (panel-docker-path, panel-docker-doctor, etc.). - if (this._setupFor !== null && !this._setupHtmlShown) { - this._setupHtmlShown = true; - this._panel.webview.html = this._getSetupHtml(isInstalled, iconUri.toString(), occUser, this._setupFor); - return; - } - // Show unified setup view when OpenClaw is not fully configured yet. if (!isConfigured) { - this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString(), this._version); + this._panel.webview.html = this._getSetupHtml(isInstalled, iconUri.toString(), occUser); this._autoUpdateTriggered = false; // reset so check fires when they reach the dashboard } else { - // Local is configured and Docker is not running — show local status. - // Ensure workspace explorer shows only ~/.openclaw (not occ-state-dir from a previous Docker session). - setActiveOpenClawWorkspaceFolder(path.join(os.homedir(), '.openclaw')); - const emojiBaseUri = this._panel.webview.asWebviewUri( vscode.Uri.joinPath(this._extensionUri, 'media', 'emojis') ).toString(); @@ -549,7 +819,7 @@ export class HomePanel { } } catch { /* openclaw.json unreadable or missing fields */ } - this._panel.webview.html = this._getHtml(isInstalled, dirExists, cliCheck, iconUri.toString(), occJwt, occUser, emojiBaseUri, aiModelName, this._version); + this._panel.webview.html = this._getHtml(isInstalled, dirExists, cliCheck, iconUri.toString(), occJwt, occUser, emojiBaseUri, aiModelName); // One-shot version check: fires the first time the user lands on the full dashboard. // If the installed version is outdated, MoltPilot auto-starts the update. if (!this._autoUpdateTriggered) { @@ -694,28 +964,15 @@ export class HomePanel { * or the timeout expires. Streams live status updates to the webview while * waiting so the UI stays accurate (still "Starting…" etc.). */ - private async _handleGatewayAction(action: 'start' | 'stop' | 'restart' | 'reboot'): Promise { + private async _handleGatewayAction(action: 'start' | 'stop' | 'restart'): Promise { const intermediary: GatewayStatus = - action === 'start' ? 'starting' : action === 'stop' ? 'stopping' : action === 'restart' ? 'restarting' : 'rebooting'; + action === 'start' ? 'starting' : action === 'stop' ? 'stopping' : 'restarting'; const expectedState: GatewayStatus = action === 'stop' ? 'stopped' : 'running'; this._commandAction = action; try { this._panel.webview.postMessage({ type: 'gatewayStatus', status: intermediary }); } catch {} - // Hand off to AI for start/stop/restart, but reboot is direct (machine goes down) - if (action === 'reboot') { - const osInfo = `${process.platform} ${os.release()} (${process.arch})`; - this._outputChannel.appendLine(`[reboot] Initiating machine reboot on ${osInfo}`); - try { - await this._host.gatewayReboot(line => this._outputChannel.appendLine(line)); - } catch (err) { - this._outputChannel.appendLine(`[reboot] Error: ${err}`); - } - this._commandAction = null; - try { this._panel.webview.postMessage({ type: 'gatewayStatus', status: 'stopped' }); } catch {} - return; - } - + // Hand off to AI — it will run the command and handle any errors const verb = action === 'restart' ? 'restart' : action; const osInfo = `${process.platform} ${os.release()} (${process.arch})`; const port = this._getConfiguredPort(); @@ -772,119 +1029,6 @@ export class HomePanel { setTimeout(tick, 4000); } - // ── Reset/Teardown handler ───────────────────────────────────────────────── - public async resetSetup(full: boolean = false): Promise { - const dataDir = path.join(os.homedir(), '.openclaw'); - const composePath = path.join(this._extensionUri.fsPath, '..', '..', '..', '..', 'docker', 'docker-compose.openclaw.yml'); - - // 1. Tear down Docker environment if it exists - try { - const resolvedCompose = fs.existsSync(composePath) ? fs.realpathSync(composePath) : null; - if (resolvedCompose) { - const cliCmd = 'docker'; - await new Promise((resolve, reject) => { - const args = ['compose', '-f', resolvedCompose, 'down']; - if (full) args.push('-v'); // also remove volumes - const child = cp.spawn(cliCmd, args, { stdio: 'ignore' }); - child.on('close', code => code === 0 ? resolve() : reject(new Error(`docker compose down exited ${code}`))); - child.on('error', err => reject(err)); - }); - } - } catch (e) { - this._outputChannel.appendLine(`Docker teardown failed (non-fatal): ${e}`); - } - - // 2. If full reset, remove data directory contents BUT preserve openclaw.json - if (full) { - try { - if (fs.existsSync(dataDir)) { - const configPath = path.join(dataDir, 'openclaw.json'); - // Read and preserve openclaw.json - let savedConfig: string | null = null; - if (fs.existsSync(configPath)) { - savedConfig = fs.readFileSync(configPath, 'utf-8'); - } - // Remove everything in the data directory - fs.rmSync(dataDir, { recursive: true, force: true }); - // Recreate the directory and restore openclaw.json - fs.mkdirSync(dataDir, { recursive: true }); - if (savedConfig) { - fs.writeFileSync(configPath, savedConfig, 'utf-8'); - } - } - } catch { /* non-fatal */ } - } - - // 3. Refresh panel to show wizard - void this._update(); - } - - // ── Local Setup step handler ─────────────────────────────────────────────── - private async _handleLocalSetupStep(step: number): Promise { - // Define commands per step - const home = os.homedir(); - const commands: Record = { - 1: { - cmd: 'npm', - args: ['install', '-g', '@openclaw/cli'], - // May require sudo; we'll try without and if fails, show error - }, - 2: { - cmd: process.platform === 'win32' ? 'powershell' : 'bash', - args: process.platform === 'win32' - ? ['-Command', 'Start-Service -Name postgresql -ErrorAction SilentlyContinue'] - : ['-c', `mkdir -p "${home}/.openclaw" && echo "{\\"gateway\\": {\\"host\\": \\"127.0.0.1\\", \\"port\\": 3001}}" > "${home}/.openclaw/openclaw.json"`], - shell: true - }, - 3: { - cmd: 'git', - args: ['clone', '--depth', '1', 'https://github.com/openclaw/backend', path.join(home, '.openclaw', 'backend')], - cwd: home - }, - 4: { - cmd: process.platform === 'win32' ? 'cmd' : 'open', - args: process.platform === 'win32' - ? ['/c', 'start', '"OCcode"', path.join(home, '.openclaw', 'backend', 'README.md')] - : ['-a', 'TextEdit'], // placeholder; could launch editor script - shell: false - } - }; - - const { cmd, args = [], cwd } = commands[step] || { cmd: '' }; - if (!cmd) return; - - try { - await new Promise((resolve, reject) => { - const child = cp.spawn(cmd, args, { - cwd: cwd || os.homedir(), - stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32' // use shell on Windows for built-in commands - }); - let output = ''; - child.stdout?.on('data', (d: Buffer) => { - output += d.toString(); - this._panel.webview.postMessage({ type: 'localLog', step, text: d.toString() }); - }); - child.stderr?.on('data', (d: Buffer) => { - output += d.toString(); - this._panel.webview.postMessage({ type: 'localLog', step, text: d.toString() }); - }); - child.on('close', code => { - if (code === 0) { - this._panel.webview.postMessage({ type: 'localStatus', step, status: 'done' }); - resolve(); - } else { - reject(new Error(`Command exited with code ${code}`)); - } - }); - child.on('error', err => reject(err)); - }); - } catch (err) { - this._panel.webview.postMessage({ type: 'localStatus', step, status: 'failed' }); - this._outputChannel.appendLine(`Local setup step ${step} failed: ${err}`); - } - } - // ── Version check ────────────────────────────────────────────────────────── /** Fetches the latest openclaw version from the npm registry. */ @@ -969,457 +1113,46 @@ export class HomePanel { } catch { /* best-effort */ } } - private _checkPort(port: number): Promise<'running' | 'stopped'> { - return new Promise(resolve => { - const req = http.get(`http://localhost:${port}/`, { timeout: 2000 }, res => { - res.resume(); - resolve(res.statusCode !== undefined && res.statusCode < 500 ? 'running' : 'stopped'); - }); - req.on('error', () => resolve('stopped')); - req.on('timeout', () => { req.destroy(); resolve('stopped'); }); - }); - } - - private async _handleCheckHostsStatus(): Promise { - // Local: "running" = config file exists (OpenClaw is installed & configured). - // Gateway port check is unreliable because the gateway may not be auto-started. - const localConfigured = fs.existsSync(path.join(os.homedir(), '.openclaw', 'openclaw.json')); - const localStatus: 'running' | 'stopped' = localConfigured ? 'running' : 'stopped'; - - // Docker: "running" = container is up (regardless of whether gateway is started inside it). - const dockerContainerRunning = await new Promise(resolve => { - try { - const result = cp.spawnSync( - 'docker', - ['ps', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], - { timeout: 3000, windowsHide: true }, - ); - const st = (result.stdout?.toString() ?? '').trim(); - resolve(st.length > 0 && st.toLowerCase().startsWith('up')); - } catch { resolve(false); } - }); - const dockerStatus: 'running' | 'stopped' = dockerContainerRunning ? 'running' : 'stopped'; - - try { - this._panel.webview.postMessage({ type: 'hostsStatus', local: localStatus, docker: dockerStatus }); - } catch { /* ignore */ } - } - - // ── AI Config after Docker provision ───────────────────────────── - - /** - * Writes the selected AI provider and API key into openclaw.json, - * then transitions to the IDE experience. - */ - private async _saveAiConfig(provider: string, apiKey: string): Promise { - const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - try { - let config: Record = {}; - if (fs.existsSync(configPath)) { - config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record; - } - - // Provider model defaults - const defaultModels: Record = { - anthropic: 'anthropic/claude-sonnet-4-20250514', - openai: 'openai/gpt-4.1', - google: 'google/gemini-2.5-pro', - groq: 'groq/llama-3.3-70b-versatile', - openrouter: 'openrouter/anthropic/claude-sonnet-4-20250514', - }; - - // Ensure models.providers structure exists - const models = (config['models'] as Record | undefined) ?? {}; - const providers = (models['providers'] as Record | undefined) ?? {}; - - // Add the selected provider's API key - providers[provider] = { apiKey }; - models['providers'] = providers; - config['models'] = models; - - // Set the primary model - const agents = (config['agents'] as Record | undefined) ?? {}; - const defaults = (agents['defaults'] as Record | undefined) ?? {}; - const model = (defaults['model'] as Record | undefined) ?? {}; - model['primary'] = defaultModels[provider] ?? `${provider}/default`; - defaults['model'] = model; - agents['defaults'] = defaults; - config['agents'] = agents; - - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); - writeLog(`[ai-config] saved provider=${provider}\n`); - } catch (err) { - writeLog(`[ai-config] failed to save: ${err}\n`); - } - - void this._transitionToIde(true); - } - - /** - * Skips AI configuration and proceeds to the IDE experience. - */ - private async _skipAiConfig(): Promise { - writeLog('[ai-config] skipped by user\n'); - void this._transitionToIde(false); - } - - /** - * After setup is complete (Docker running + AI configured or skipped), - * transition the user into the actual IDE experience: - * 1. Close the OCC Home panel - * 2. Open the OpenClaw workspace folder (from openclaw.json) - * 3. Open the AI chat sidebar (only if AI was configured) - * - * @param aiConfigured - true if the user configured an AI provider - */ - private async _transitionToIde(aiConfigured: boolean): Promise { - // Verify Docker is running - let dockerRunning = false; - try { - const dc = cp.spawnSync( - 'docker', - ['ps', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], - { timeout: 3000, windowsHide: true }, - ); - const st = (dc.stdout?.toString() ?? '').trim(); - dockerRunning = st.length > 0 && st.toLowerCase().startsWith('up'); - } catch { /* docker not available */ } - - if (!dockerRunning) { - writeLog('[ide-transition] docker not running, falling back to _update()\n'); - setTimeout(() => void this._update(), 500); - return; - } - - // Close the Home panel - this.dispose(); - - // Open the workspace folder from openclaw.json if configured - const workspaceDir = getOpenClawWorkspaceDir(); - if (fs.existsSync(workspaceDir)) { - try { - await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(workspaceDir), { forceNewWindow: false }); - } catch { /* non-fatal — sidebar still opens */ } - } - - // Open the AI chat sidebar only if AI was configured - if (aiConfigured) { - try { - await vscode.commands.executeCommand('workbench.view.extension.void'); - // Show a welcome notification in the editor - void vscode.window.showInformationMessage( - 'OpenClaw is ready! Start chatting with your AI assistant in the sidebar.', - { modal: false }, - ); - } catch { /* sidebar view may not be registered yet */ } - } - } - - private _getHostsOverviewHtml(iconUri: string, localPort: number, version: string): string { + private _getLoadingHtml(iconUri: string): string { return ` - + - - - -

v${version}

-

OCC Home

-

Choose a host to open

- -
- - - -
- - - -`; - } - - private _getHostTypeSelectionHtml(iconUri: string, version: string): string { - return ` - - - - - - - - -

v${version}

-

Welcome to OpenClaw

-

Choose where OpenClaw runs. You can always switch later.

-
- - - - - - - -
- - -`; - } - - private _getLoadingHtml(iconUri: string, version: string): string { - return ` - - - - - - - -
- ${userAreaHtml} - -
+
${userAreaHtml}
@@ -2025,14 +1885,14 @@ The binary is already downloaded — do NOT re-download or compile anything.`;
Follow the steps below to get started
-
-
-
${setupFor === 'docker' ? '✓' : (isInstalled ? '✓' : '1')}
-
${setupFor === 'docker' ? 'Docker
Selected' : 'Install
OpenClaw'}
+
+
+
${isInstalled ? '✓' : '1'}
+
Install
OpenClaw
-
-
${setupFor === 'docker' ? '2' : '2'}
-
${setupFor === 'docker' ? 'Provision
Compose' : 'Configure
AI Model'}
+
+
2
+
Configure
AI Model
3
@@ -2040,163 +1900,14 @@ The binary is already downloaded — do NOT re-download or compile anything.`;
- -
- -
How would you like to set up OpenClaw?
-
Choose your installation method. Docker is recommended for a consistent, isolated environment.
-
- - -
-
- - -
2
-
Pull
Image
+
Container
Setup
3
-
Onboard
Config
+
Install
CLI
4
-
Launch
Gateway
+
Configure
AI
@@ -571,36 +494,65 @@ export class DockerSetupPanel {
- -