From 546ac75439a9866c1eb8ca0cfbc731a02c9e47c4 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Wed, 18 Mar 2026 22:05:00 -0400 Subject: [PATCH 001/294] feat: rename "Configure OpenClaw" button to "Open Web Control" Co-Authored-By: Claude Sonnet 4.6 --- apps/editor/extensions/openclaw/package.json | 2 +- apps/editor/extensions/openclaw/src/panels/home.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/extensions/openclaw/package.json b/apps/editor/extensions/openclaw/package.json index 5dfc93c4..a1cd46e4 100644 --- a/apps/editor/extensions/openclaw/package.json +++ b/apps/editor/extensions/openclaw/package.json @@ -87,7 +87,7 @@ }, { "command": "openclaw.configure", - "title": "OpenClaw: Configure" + "title": "OpenClaw: Open Web Control" }, { "command": "openclaw.install", diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index ae006083..8b01da11 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -2667,7 +2667,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; const statusIcon = isInstalled ? '✅' : '⚠️'; const statusText = isInstalled ? 'OpenClaw detected' : 'OpenClaw not found'; const statusClass = isInstalled ? 'detected' : 'not-found'; - const buttonLabel = isInstalled ? 'Configure OpenClaw' : 'Install OpenClaw'; + const buttonLabel = isInstalled ? 'Open Web Control' : 'Install OpenClaw'; const buttonCommand = isInstalled ? 'openclaw.configure' : 'openclaw.install'; const dirText = dirExists ? 'found' : 'missing'; const dirClass = dirExists ? 'ok' : 'warn'; @@ -3618,7 +3618,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; ).join('\n ')}
- +
From 9aa6c10430b5c4522520ea80ceac9fb22eb20a60 Mon Sep 17 00:00:00 2001 From: "Dominic Damoah (Agent)" Date: Thu, 19 Mar 2026 08:38:54 +0000 Subject: [PATCH 002/294] =?UTF-8?q?feat:=20update=20feature=20bentos=20?= =?UTF-8?q?=E2=80=94=20new=20copy=20+=20larger=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/page-client.tsx | 42 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/web/src/app/page-client.tsx b/apps/web/src/app/page-client.tsx index 72e8518f..eddad16d 100644 --- a/apps/web/src/app/page-client.tsx +++ b/apps/web/src/app/page-client.tsx @@ -66,8 +66,8 @@ const platformIcons: Record = { const features = [ { - title: "Ready in one click", - desc: "OpenClaw is set up for you automatically. No complicated steps, no confusing settings — just open the app and go.", + title: "Ready in One Click", + desc: "Installs locally on your machine. No servers, no cloud setup — just download, click install, and OpenClaw is running.", icon: ( - {/* heroicons code-bracket scaled to 32×32 */} - - - - - ), - }, - { - title: "Works on any computer", - desc: "Whether you're on Windows, Mac, or Linux — it just works. Same great experience everywhere.", + title: "Cross Platform", + desc: "Works on every computer — Windows, Mac, or Linux. Same polished experience no matter what you're running.", icon: ( @@ -104,8 +92,20 @@ const features = [ ), }, { - title: "Everything in one place", - desc: "Check your status, tweak your settings, and manage everything from a single, easy-to-use app.", + title: "Apps to Manage OpenClaw", + desc: "A companion control center to configure, monitor, and manage your OpenClaw agent — all from one clean interface.", + icon: ( + + + + + + + ), + }, + { + title: "Everything in One Place", + desc: "Settings, status, channels, skills — all accessible from a single app. No jumping between terminals and config files.", icon: (
-
{f.icon}
+
{f.icon}

{f.title}

{f.desc}

From 2e287e9b54e5fac637439b162b04d3add65e9928 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Thu, 19 Mar 2026 04:43:59 -0400 Subject: [PATCH 003/294] feat: add named maintainers to app WIP modals Security: Fletcher Frimpong (links to GitHub profile) All others: muted prompt to email team@mba.sh to become maintainer Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw/src/panels/home.ts | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 8b01da11..1d7a26e6 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -3327,6 +3327,9 @@ The binary is already downloaded — do NOT re-download or compile anything.`; cursor: pointer; flex-shrink: 0; font-family: inherit; transition: background 0.15s; } .app-wip-copy-btn:hover { background: rgba(255,255,255,0.14); color: #fff; } + .app-wip-maintainer { font-size: 11px; color: #555; margin: 0 0 14px; line-height: 1.5; } + .app-wip-maintainer a { color: #666; text-decoration: none; border-bottom: 1px solid #444; } + .app-wip-maintainer a:hover { color: #999; border-bottom-color: #666; } .app-wip-actions { display: flex; gap: 8px; } .app-wip-btn-cancel { flex: 1; padding: 9px 0; border-radius: 8px; @@ -3483,20 +3486,23 @@ The binary is already downloaded — do NOT re-download or compile anything.`;
OCC Apps
${[ - { label: 'Chat', icon: '', emoji: 'speech-balloon_1f4ac.png' }, - { label: 'Channels', icon: '', emoji: 'satellite-antenna_1f4e1.png' }, - { label: 'Agents', icon: '', emoji: 'briefcase_1f4bc.png' }, - { label: 'Models', icon: '', emoji: 'alembic_2697-fe0f.png' }, - { label: 'Skills', icon: '', emoji: 'high-voltage_26a1.png' }, - { label: 'Security', icon: '', emoji: 'shield_1f6e1-fe0f.png' }, - { label: 'Memory', icon: '', emoji: 'floppy-disk_1f4be.png' }, - { label: 'Empire', icon: '', emoji: 'crown_1f451.png' }, - { label: 'Social', icon: '', emoji: 'handshake_1f91d.png' }, - ].map(app => ` -
+ { label: 'Chat', emoji: 'speech-balloon_1f4ac.png', maintainer: null }, + { label: 'Channels', emoji: 'satellite-antenna_1f4e1.png', maintainer: null }, + { label: 'Agents', emoji: 'briefcase_1f4bc.png', maintainer: null }, + { label: 'Models', emoji: 'alembic_2697-fe0f.png', maintainer: null }, + { label: 'Skills', emoji: 'high-voltage_26a1.png', maintainer: null }, + { label: 'Security', emoji: 'shield_1f6e1-fe0f.png', maintainer: { name: 'Fletcher Frimpong', url: 'https://github.com/FletcherFrimpong' } }, + { label: 'Memory', emoji: 'floppy-disk_1f4be.png', maintainer: null }, + { label: 'Empire', emoji: 'crown_1f451.png', maintainer: null }, + { label: 'Social', emoji: 'handshake_1f91d.png', maintainer: null }, + ].map(app => { + const maintainerJson = app.maintainer ? JSON.stringify(app.maintainer).replace(/'/g, '\\x27') : 'null'; + return ` +
${app.label}
${app.label} -
`).join('')} +
`; + }).join('')}
@@ -3515,6 +3521,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; I want to contribute to [App] +

@@ -3692,12 +3699,20 @@ The binary is already downloaded — do NOT re-download or compile anything.`; const input = document.getElementById('more-menu-search-input'); if (input) { input.value = ''; filterMoreMenu(''); } } - function showWipModal(appName, emojiSrc) { + function showWipModal(appName, emojiSrc, maintainer) { const msg = 'I want to contribute to the ' + appName + ' app on OCC.'; document.getElementById('app-wip-title').textContent = appName + ' — Coming Soon'; document.getElementById('app-wip-copy-text').textContent = msg; const icon = document.getElementById('app-wip-icon'); if (icon) { icon.src = emojiSrc; icon.alt = appName; } + const el = document.getElementById('app-wip-maintainer'); + if (el) { + if (maintainer && maintainer.name) { + el.innerHTML = 'Maintained by ' + maintainer.name + ''; + } else { + el.innerHTML = 'Interested in maintaining this app? team@mba.sh'; + } + } document.getElementById('app-wip-overlay').classList.add('visible'); closeAppsPanel(); } From 66cd08ce84a46edbd0d107832309884f583068d0 Mon Sep 17 00:00:00 2001 From: "Dominic Damoah (Agent)" Date: Thu, 19 Mar 2026 08:50:58 +0000 Subject: [PATCH 004/294] =?UTF-8?q?feat:=20add=20Apps=20Ecosystem=20sectio?= =?UTF-8?q?n=20=E2=80=94=209=20purpose-built=20management=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/page-client.tsx | 190 +++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/apps/web/src/app/page-client.tsx b/apps/web/src/app/page-client.tsx index eddad16d..0b581218 100644 --- a/apps/web/src/app/page-client.tsx +++ b/apps/web/src/app/page-client.tsx @@ -753,6 +753,196 @@ export default function Home({ downloadUrls = FALLBACK_URLS }: { downloadUrls?:
+ + {/* ── Apps Ecosystem ─────────────────────────────────────────────── */} +
+
+
+
+ +
+ +
+ + + + The Suite +
+

+ Your command center. +

+

+ Nine purpose-built apps to manage every dimension of your OpenClaw instance — from conversations to security to real-time observability. +

+
+ +
+ {[ + { + name: "Chat", + tagline: "Talk to your agent", + desc: "A beautifully designed chat interface you can expose securely via Tailscale. Your private line to your AI.", + icon: ( + + + + + + + + ), + }, + { + name: "Channels", + tagline: "Connect everywhere", + desc: "The best UI for connecting OpenClaw to over a dozen messaging platforms — Telegram, Discord, Slack, WhatsApp, and more.", + icon: ( + + + + + + + + + + ), + }, + { + name: "Agents", + tagline: "Run many, manage one", + desc: "Create and manage multiple agents on a single host. Each with its own personality, skills, and purpose.", + icon: ( + + + + + + + + ), + }, + { + name: "Models", + tagline: "Pick your brain", + desc: "Configure AI models for inference, image generation, and fallback chains — all through a clean, intuitive interface.", + icon: ( + + + + + + + + + ), + }, + { + name: "Skills", + tagline: "Teach it anything", + desc: "Build custom skills, browse existing ones, and discover community-built skills in a marketplace with built-in virus scanning.", + icon: ( + + + + + ), + }, + { + name: "Security", + tagline: "Lock it down", + desc: "An amazing panel for securing agent resources, controlling data access, and governing how your agents process information.", + icon: ( + + + + + ), + }, + { + name: "Memory", + tagline: "Never forgets", + desc: "Ensure your agent remembers like an elephant. The most efficient way to give your AI persistent, reliable long-term memory.", + icon: ( + + + + + + + + ), + }, + { + name: "Empire", + tagline: "See everything", + desc: "A real-time visualization of all your agents and the work they\'re performing — beautiful agent observability at a glance.", + icon: ( + + + + + + + + + + + ), + }, + { + name: "Social", + tagline: "Where agents hang out", + desc: "A social hub to control where your agents live in the agentic universe — manage presence across platforms and communities.", + icon: ( + + + + + + + + + + + ), + }, + ].map((app, i) => ( + + {/* Hover glow */} +
+
+
+ +
+
+ {app.icon} +
+
+

{app.name}

+ {app.tagline} +
+

{app.desc}

+
+ + ))} +
+
+
+ {/* Global community */}
From edd30a02064c206eef2128c69bf36d0a062879d7 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Thu, 19 Mar 2026 04:58:21 -0400 Subject: [PATCH 005/294] feat: rename "AI Harness for OpenClaw" to "Cursor for OpenClaw" everywhere Updated README, web metadata (title/OG/Twitter), and home panel tagline. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 4 ++-- apps/editor/extensions/openclaw/src/panels/home.ts | 2 +- apps/web/src/app/layout.tsx | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1020105c..2ad39e9c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

OpenClaw Code

- AI-Powered Harness for OpenClaw
+ Cursor for OpenClaw
Set up, manage, and troubleshoot your OpenClaw agent — no terminal needed.

@@ -27,7 +27,7 @@ ---

- OCCode Home — AI-powered harness for OpenClaw + OCCode Home — Cursor for OpenClaw

## What is OCCode? diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 1d7a26e6..65ff7baa 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -1178,7 +1178,7 @@ export class HomePanel {

Welcome to OpenClaw Code

-

AI Powered Harness for OpenClaw

+

Cursor for OpenClaw

Checking environment diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c2f60395..3cc09536 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -17,7 +17,7 @@ export const viewport: Viewport = { export const metadata: Metadata = { metadataBase: new URL(SITE_URL), title: { - default: "OpenClaw Code — AI Powered Harness for OpenClaw", + default: "OpenClaw Code — Cursor for OpenClaw", template: "%s | OpenClaw Code", }, description: @@ -58,7 +58,7 @@ export const metadata: Metadata = { locale: "en_US", url: SITE_URL, siteName: "OpenClaw Code", - title: "OpenClaw Code — AI Powered Harness for OpenClaw", + title: "OpenClaw Code — Cursor for OpenClaw", description: "One download, zero configuration. Set up and manage your OpenClaw AI agent in minutes on Windows, macOS, or Linux.", images: [ @@ -73,7 +73,7 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "OpenClaw Code — AI Powered Harness for OpenClaw", + title: "OpenClaw Code — Cursor for OpenClaw", description: "One download, zero configuration. Set up and manage your OpenClaw AI agent in minutes on Windows, macOS, or Linux.", images: ["/OpenClawOGImage.png"], From 0b5610acb792b69ef6616f2a3f3689d55726f943 Mon Sep 17 00:00:00 2001 From: "Dominic Damoah (Agent)" Date: Thu, 19 Mar 2026 09:06:16 +0000 Subject: [PATCH 006/294] Report OS platform to backend once after login (desktop client) --- apps/editor/extensions/openclaw/src/extension.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index 27ef3212..67f318d3 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -331,6 +331,20 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => // Self-healing: restart the poll timer after every successful fetch so the // interval resets cleanly and polling survives network blips or missed starts. startBackendPolling(); + + // Report OS platform to backend once per install so the admin dashboard has reliable data. + const OS_REPORTED_KEY = 'occ.osPlatformReported'; + if (!context.globalState.get(OS_REPORTED_KEY)) { + const platformMap: Record = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' }; + const osPlatform = platformMap[process.platform] || process.platform; + fetch('https://occ.mba.sh/api/v1/me/platform', { + method: 'PATCH', + headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ os_platform: osPlatform }), + }).then(resp => { + if (resp.ok) { void context.globalState.update(OS_REPORTED_KEY, true); } + }).catch(() => { /* will retry next poll cycle */ }); + } } else if (r.status === 401) { // JWT expired or invalid — clear it, clear moltpilot key, and hide bar void context.globalState.update(OCC_JWT_KEY, ''); From 13cceb13688818657d48159533cadb5ff35c682c Mon Sep 17 00:00:00 2001 From: "Dominic Damoah (Agent)" Date: Thu, 19 Mar 2026 09:42:56 +0000 Subject: [PATCH 007/294] =?UTF-8?q?fix:=20NemoClaw=20docs=20link=20?= =?UTF-8?q?=E2=86=92=20/nemoclaw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/page-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/page-client.tsx b/apps/web/src/app/page-client.tsx index 0b581218..039d7d6f 100644 --- a/apps/web/src/app/page-client.tsx +++ b/apps/web/src/app/page-client.tsx @@ -1028,7 +1028,7 @@ export default function Home({ downloadUrls = FALLBACK_URLS }: { downloadUrls?: Safety, compliance, and scale — built in from day one.

From a4b8ce4ecb9474ec54b6915c803c18c943a49999 Mon Sep 17 00:00:00 2001 From: "Dominic Damoah (Agent)" Date: Thu, 19 Mar 2026 09:46:48 +0000 Subject: [PATCH 008/294] fix: remove hover flicker from command center grid --- apps/web/src/app/page-client.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/src/app/page-client.tsx b/apps/web/src/app/page-client.tsx index 039d7d6f..2473faf3 100644 --- a/apps/web/src/app/page-client.tsx +++ b/apps/web/src/app/page-client.tsx @@ -916,16 +916,12 @@ export default function Home({ downloadUrls = FALLBACK_URLS }: { downloadUrls?: ].map((app, i) => ( - {/* Hover glow */} -
-
-
From 80a04724f00c4f03803d675427b242fcc35818fd Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Thu, 19 Mar 2026 05:53:21 -0400 Subject: [PATCH 009/294] fix: hide MoltPilot by default, remove "Open OpenClaw State Directory" button - sidebarPane: isDefault false + remove SidebarStartContribution so MoltPilot no longer auto-opens on every launch - editorGroupWatermark: remove the blue "Open OpenClaw State Directory" button Co-Authored-By: Claude Sonnet 4.6 --- .../parts/editor/editorGroupWatermark.ts | 44 ------------------- .../contrib/void/browser/sidebarPane.ts | 13 +----- 2 files changed, 1 insertion(+), 56 deletions(-) diff --git a/apps/editor/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts b/apps/editor/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts index e0b980af..5c4cd0ab 100644 --- a/apps/editor/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts +++ b/apps/editor/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts @@ -192,50 +192,6 @@ export class EditorGroupWatermark extends Disposable { buttonContainer.style.marginBottom = '16px'; voidIconBox.appendChild(buttonContainer); - // Open OpenClaw State Directory button - const openFolderButton = h('button') - openFolderButton.root.classList.add('void-openfolder-button') - openFolderButton.root.style.display = 'block' - openFolderButton.root.style.width = '260px' - openFolderButton.root.textContent = 'Open OpenClaw State Directory' - openFolderButton.root.onclick = async () => { - // Open AI sidebar if not visible - void this.viewsService.openViewContainer('workbench.view.void'); - - // Check for ~/.openclaw workspace file - const home = process.env['HOME'] || process.env['USERPROFILE'] || ''; - const openclawDir = home ? `${home}/.openclaw` : ''; - const workspaceFile = openclawDir ? `${openclawDir}/My OpenClaw Workspace.code-workspace` : ''; - - let opened = false; - if (workspaceFile) { - try { - const { promises: fsPromises } = await import('fs'); - await fsPromises.access(workspaceFile); - // Workspace file exists — open it - const { URI } = await import('../../../../base/common/uri.js'); - await this.hostService.openWindow([{ workspaceUri: URI.file(workspaceFile) }]); - opened = true; - } catch { - // workspace file missing — try opening the folder directly - if (openclawDir) { - try { - const { promises: fsPromises } = await import('fs'); - await fsPromises.access(openclawDir); - const { URI } = await import('../../../../base/common/uri.js'); - await this.hostService.openWindow([{ folderUri: URI.file(openclawDir) }]); - opened = true; - } catch { /* fall through to picker */ } - } - } - } - - if (!opened) { - // ~/.openclaw doesn't exist — let user pick a folder - this.commandService.executeCommand(isMacintosh && isNative ? OpenFileFolderAction.ID : OpenFolderAction.ID); - } - } - buttonContainer.appendChild(openFolderButton.root); // Recents diff --git a/apps/editor/src/vs/workbench/contrib/void/browser/sidebarPane.ts b/apps/editor/src/vs/workbench/contrib/void/browser/sidebarPane.ts index 8934c580..9da66d99 100644 --- a/apps/editor/src/vs/workbench/contrib/void/browser/sidebarPane.ts +++ b/apps/editor/src/vs/workbench/contrib/void/browser/sidebarPane.ts @@ -42,8 +42,6 @@ import { toDisposable } from '../../../../base/common/lifecycle.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; // compare against search.contribution.ts and debug.contribution.ts, scm.contribution.ts (source control) @@ -120,7 +118,7 @@ const container = viewContainerRegistry.registerViewContainer({ icon: Codicon.symbolMethod, -}, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true, isDefault: true }); +}, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true, isDefault: false }); @@ -163,12 +161,3 @@ registerAction2(class extends Action2 { } }); -export class SidebarStartContribution implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.startupVoidSidebar'; - constructor( - @ICommandService private readonly commandService: ICommandService, - ) { - this.commandService.executeCommand(VOID_OPEN_SIDEBAR_ACTION_ID) - } -} -registerWorkbenchContribution2(SidebarStartContribution.ID, SidebarStartContribution, WorkbenchPhase.AfterRestored); From 1455ced08bebfa4eeb8bff31c2bb02c275b6c309 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Thu, 19 Mar 2026 05:59:59 -0400 Subject: [PATCH 010/294] fix: pass maintainer as separate string args to avoid JSON breaking onclick HTML attribute JSON object inline in onclick="..." caused double-quote collision that broke the entire script block, making all button functions undefined. Co-Authored-By: Claude Sonnet 4.6 --- apps/editor/extensions/openclaw/src/panels/home.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 65ff7baa..02799095 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -3496,9 +3496,10 @@ The binary is already downloaded — do NOT re-download or compile anything.`; { label: 'Empire', emoji: 'crown_1f451.png', maintainer: null }, { label: 'Social', emoji: 'handshake_1f91d.png', maintainer: null }, ].map(app => { - const maintainerJson = app.maintainer ? JSON.stringify(app.maintainer).replace(/'/g, '\\x27') : 'null'; + const mName = app.maintainer ? app.maintainer.name.replace(/'/g, '\\x27') : ''; + const mUrl = app.maintainer ? app.maintainer.url.replace(/'/g, '\\x27') : ''; return ` -
+
${app.label}
${app.label}
`; @@ -3699,7 +3700,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; const input = document.getElementById('more-menu-search-input'); if (input) { input.value = ''; filterMoreMenu(''); } } - function showWipModal(appName, emojiSrc, maintainer) { + function showWipModal(appName, emojiSrc, maintainerName, maintainerUrl) { const msg = 'I want to contribute to the ' + appName + ' app on OCC.'; document.getElementById('app-wip-title').textContent = appName + ' — Coming Soon'; document.getElementById('app-wip-copy-text').textContent = msg; @@ -3707,10 +3708,10 @@ The binary is already downloaded — do NOT re-download or compile anything.`; if (icon) { icon.src = emojiSrc; icon.alt = appName; } const el = document.getElementById('app-wip-maintainer'); if (el) { - if (maintainer && maintainer.name) { - el.innerHTML = 'Maintained by
' + maintainer.name + ''; + if (maintainerName) { + el.innerHTML = 'Maintained by ' + maintainerName + ''; } else { - el.innerHTML = 'Interested in maintaining this app? team@mba.sh'; + el.innerHTML = 'Interested in maintaining this app? team@mba.sh'; } } document.getElementById('app-wip-overlay').classList.add('visible'); From 72a92f4629e53a15fac7064d3b5026d64c13be64 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Thu, 19 Mar 2026 06:04:00 -0400 Subject: [PATCH 011/294] fix: eliminate \' escaping in template literal causing script parse error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \' inside a TS template literal becomes ' in output, breaking JS strings. Refactored to openMaintainerLink(el) helper + data-url attribute — zero escaping needed, no more Unexpected identifier 'openUrl' crash. Co-Authored-By: Claude Sonnet 4.6 --- apps/editor/extensions/openclaw/src/panels/home.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 02799095..0184fc40 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -3700,6 +3700,10 @@ The binary is already downloaded — do NOT re-download or compile anything.`; const input = document.getElementById('more-menu-search-input'); if (input) { input.value = ''; filterMoreMenu(''); } } + function openMaintainerLink(el) { + vscode.postMessage({ command: 'openUrl', url: el.dataset.url }); + return false; + } function showWipModal(appName, emojiSrc, maintainerName, maintainerUrl) { const msg = 'I want to contribute to the ' + appName + ' app on OCC.'; document.getElementById('app-wip-title').textContent = appName + ' — Coming Soon'; @@ -3709,9 +3713,9 @@ The binary is already downloaded — do NOT re-download or compile anything.`; const el = document.getElementById('app-wip-maintainer'); if (el) { if (maintainerName) { - el.innerHTML = 'Maintained by ' + maintainerName + ''; + el.innerHTML = 'Maintained by ' + maintainerName + ''; } else { - el.innerHTML = 'Interested in maintaining this app? team@mba.sh'; + el.innerHTML = 'Interested in maintaining this app? team@mba.sh'; } } document.getElementById('app-wip-overlay').classList.add('visible'); From 133dedfa94a3101a81c985e80cec45825b80c2b5 Mon Sep 17 00:00:00 2001 From: "Dominic Damoah (Agent)" Date: Thu, 19 Mar 2026 10:47:37 +0000 Subject: [PATCH 012/294] Add extensive MultiHost architecture plans (13 documents) Covers: - Host Registry (~/.occ/) data model and lifecycle - Extension-to-extension API (HostAdapter/HostConnection interfaces) - Core extension refactor plan (home.ts abstraction) - Local adapter (extract existing code) - Docker adapter (container discovery, exec routing) - SSH adapter (ssh2 + open-remote-ssh integration) - Cloud/MoltPod adapter (REST API, FileSystemProvider, OAuth) - UI design (host picker, sidebar tree, add wizard, dashboard) - Migration path (zero disruption for existing users) - Phased build order (10-12 weeks, 5 phases) - Security (credential storage, SSH keys, threat model) - Testing strategy (unit, integration, E2E, CI) --- docs/plans/multihost/00-overview.md | 75 +++ docs/plans/multihost/01-host-registry.md | 223 +++++++++ docs/plans/multihost/02-extension-api.md | 290 ++++++++++++ docs/plans/multihost/03-core-extension.md | 308 +++++++++++++ docs/plans/multihost/04-local-adapter.md | 348 ++++++++++++++ docs/plans/multihost/05-docker-adapter.md | 443 ++++++++++++++++++ docs/plans/multihost/06-ssh-adapter.md | 528 ++++++++++++++++++++++ docs/plans/multihost/07-cloud-adapter.md | 478 ++++++++++++++++++++ docs/plans/multihost/08-ui-design.md | 406 +++++++++++++++++ docs/plans/multihost/09-migration.md | 187 ++++++++ docs/plans/multihost/10-build-order.md | 210 +++++++++ docs/plans/multihost/11-security.md | 297 ++++++++++++ docs/plans/multihost/12-testing.md | 342 ++++++++++++++ 13 files changed, 4135 insertions(+) create mode 100644 docs/plans/multihost/00-overview.md create mode 100644 docs/plans/multihost/01-host-registry.md create mode 100644 docs/plans/multihost/02-extension-api.md create mode 100644 docs/plans/multihost/03-core-extension.md create mode 100644 docs/plans/multihost/04-local-adapter.md create mode 100644 docs/plans/multihost/05-docker-adapter.md create mode 100644 docs/plans/multihost/06-ssh-adapter.md create mode 100644 docs/plans/multihost/07-cloud-adapter.md create mode 100644 docs/plans/multihost/08-ui-design.md create mode 100644 docs/plans/multihost/09-migration.md create mode 100644 docs/plans/multihost/10-build-order.md create mode 100644 docs/plans/multihost/11-security.md create mode 100644 docs/plans/multihost/12-testing.md diff --git a/docs/plans/multihost/00-overview.md b/docs/plans/multihost/00-overview.md new file mode 100644 index 00000000..97c89cd9 --- /dev/null +++ b/docs/plans/multihost/00-overview.md @@ -0,0 +1,75 @@ +# MultiHost Architecture — Overview + +> **Goal**: Enable OCCode to manage multiple OpenClaw installations across Local, Docker, SSH, and Cloud (MoltPod) hosts from a single desktop client. + +## Problem Statement + +Today, OCCode is a **single-host manager**. Every interaction assumes: +- One `~/.openclaw/openclaw.json` on the local filesystem +- One gateway at `http://localhost:{port}` +- One set of agents, channels, and cron jobs +- `child_process.spawn("openclaw", ...)` runs locally + +Users who run OpenClaw on multiple machines (dev laptop + VPS, local + Docker container, etc.) must manually SSH in or switch contexts. There's no unified view. + +## Vision + +``` +┌─────────────────────────────────────────────────────────┐ +│ OCCode Desktop │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ +│ │ Local │ │ Docker │ │ SSH │ │ Cloud │ │ +│ │ Adapter │ │ Adapter │ │ Adapter │ │ Adapter │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ ┌────┴──────────────┴──────────────┴──────────────┴───┐ │ +│ │ Host Registry (~/.occ/) │ │ +│ │ HostAdapter / HostConnection API │ │ +│ └─────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────┴───────────────────────────────┐ │ +│ │ Core Extension (openclaw) │ │ +│ │ Host Picker · Sidebar Tree · Control Center │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ │ │ │ + Local Machine Docker Container Remote VPS MoltPod API + ~/.openclaw/ /root/.openclaw/ ~/.openclaw/ REST endpoints +``` + +## Design Principles + +1. **Separate extensions per host type** — modular install, independent release cycles, focused permissions +2. **`.occ/` for client state** — the desktop client owns `~/.occ/`, OpenClaw runtime owns `~/.openclaw/`. Never cross the streams. +3. **Native VS Code patterns** — use `FileSystemProvider`, `RemoteAuthorityResolver`, `TreeDataProvider`, Quick Pick. No custom explorer panels. +4. **Extension-to-extension API** — core exports a `registerHostAdapter()` API. Adapters are plugins to the core. +5. **Progressive disclosure** — local works out of the box. Docker/SSH/Cloud are opt-in extensions. +6. **Offline-first** — cached host state in `~/.occ/hosts/{id}/cache.json` for instant UI even when hosts are unreachable. + +## Extension Map + +| Extension | Package Name | Ships With OCCode? | Purpose | +|---|---|---|---| +| `openclaw` | `occ.openclaw` | ✅ Always | Core: host registry, picker, sidebar, control center | +| `openclaw-local` | `occ.openclaw-local` | ✅ Always | Local filesystem + process adapter | +| `openclaw-docker` | `occ.openclaw-docker` | ❌ Optional | Docker container adapter | +| `openclaw-ssh` | `occ.openclaw-ssh` | ❌ Optional | SSH remote host adapter | +| `openclaw-cloud` | `occ.openclaw-cloud` | ❌ Optional | MoltPod REST API adapter | + +## Documents in This Plan + +| File | Contents | +|---|---| +| `01-host-registry.md` | `~/.occ/` directory structure, `hosts.json` schema, host lifecycle | +| `02-extension-api.md` | `HostAdapter` and `HostConnection` interface specs, extension-to-extension protocol | +| `03-core-extension.md` | Core extension refactor plan — what moves, what stays, new UI components | +| `04-local-adapter.md` | Local host adapter — extracting existing code from `home.ts` | +| `05-docker-adapter.md` | Docker host adapter — container discovery, exec routing, port mapping | +| `06-ssh-adapter.md` | SSH host adapter — leveraging `open-remote-ssh`, key management | +| `07-cloud-adapter.md` | MoltPod Cloud adapter — REST API design, pod management | +| `08-ui-design.md` | Host picker, sidebar tree, control center multi-host views | +| `09-migration.md` | Migration path from current single-host to multi-host without breaking users | +| `10-build-order.md` | Phased implementation plan with effort estimates | +| `11-security.md` | Credential storage, SSH key handling, API token management | +| `12-testing.md` | Test strategy per extension, mock hosts, integration testing | diff --git a/docs/plans/multihost/01-host-registry.md b/docs/plans/multihost/01-host-registry.md new file mode 100644 index 00000000..a20a28bd --- /dev/null +++ b/docs/plans/multihost/01-host-registry.md @@ -0,0 +1,223 @@ +# Host Registry — `~/.occ/` + +## Why `.occ/` Not `.openclaw/` + +- `~/.openclaw/` is owned by the **OpenClaw runtime** (gateway, agents, config, sessions) +- `~/.occ/` is owned by the **OCCode desktop client** (host registry, cached state, app preferences) +- They coexist on the same machine but serve different purposes +- On a remote host, only `~/.openclaw/` exists. The user's laptop has both. + +## Directory Structure + +``` +~/.occ/ +├── hosts.json # Host registry — the source of truth +├── settings.json # App-level preferences (theme, default host, etc.) +├── hosts/ +│ ├── local-default/ +│ │ ├── cache.json # Last-known gateway status, version, agent count +│ │ └── identity.json # Cached host identity (hostname, OS, OpenClaw version) +│ ├── docker-mypod/ +│ │ ├── cache.json +│ │ └── identity.json +│ └── ssh-vps-prod/ +│ ├── cache.json +│ ├── identity.json +│ └── known_config.json # Last-fetched openclaw.json snapshot (for offline viewing) +└── logs/ + └── occ-client.log # Client-side diagnostic log +``` + +## `hosts.json` Schema + +```typescript +interface HostsFile { + version: 1; + activeHostId: string; // Currently selected host + hosts: HostEntry[]; +} + +interface HostEntry { + // ── Identity ── + id: string; // Unique ID (slug format: "local-default", "ssh-my-vps") + type: "local" | "docker" | "ssh" | "cloud"; + label: string; // User-friendly name shown in picker + + // ── Connection ── + // Fields vary by type — see type-specific schemas below + connection: LocalConnection | DockerConnection | SSHConnection | CloudConnection; + + // ── Preferences ── + configPath?: string; // Override path to openclaw.json on the host (default: ~/.openclaw/openclaw.json) + default?: boolean; // Auto-select on app launch + color?: string; // Accent color for visual distinction in UI + tags?: string[]; // User-defined tags for organization + + // ── Metadata (auto-populated) ── + createdAt: string; // ISO 8601 + lastConnectedAt?: string; // ISO 8601 + lastStatus?: "online" | "offline" | "error"; + lastError?: string; +} +``` + +### Local Connection + +```typescript +interface LocalConnection { + type: "local"; + // No additional fields — uses the local filesystem and process directly + // configPath defaults to ~/.openclaw/openclaw.json +} +``` + +### Docker Connection + +```typescript +interface DockerConnection { + type: "docker"; + containerId?: string; // Container ID or name (e.g., "openclaw-pod-1") + containerLabel?: string; // Docker label filter (e.g., "com.openclaw=true") + composeService?: string; // Docker Compose service name + composeFile?: string; // Path to docker-compose.yml + dockerHost?: string; // DOCKER_HOST override (e.g., "ssh://user@remote") + shell?: string; // Shell to use inside container (default: "/bin/sh") + portMappings?: { + gateway?: number; // Host port mapped to container's gateway port + }; +} +``` + +### SSH Connection + +```typescript +interface SSHConnection { + type: "ssh"; + host: string; // Hostname or IP + port?: number; // SSH port (default: 22) + user: string; // SSH username + authMethod: "key" | "agent" | "password"; + keyPath?: string; // Path to private key (for authMethod: "key") + passphrase?: boolean; // Whether key has a passphrase (triggers prompt) + jumpHost?: string; // ProxyJump / bastion host + gatewayPort?: number; // OpenClaw gateway port on remote (default: 18789) + sshConfigHost?: string; // Reference to ~/.ssh/config Host entry (overrides all above) +} +``` + +### Cloud (MoltPod) Connection + +```typescript +interface CloudConnection { + type: "cloud"; + provider: "moltpod"; // Extensible for future providers + podId: string; // MoltPod pod ID + apiEndpoint?: string; // API base URL (default: https://api.moltpod.com) + // Auth token stored in VS Code SecretStorage, not in this file +} +``` + +## Host Lifecycle + +### Adding a Host + +``` +User clicks "Add Host" in sidebar + → Quick Pick: "Local | Docker | SSH | Cloud" + → Adapter's getConfigFields() returns the form fields + → User fills in connection details + → Core calls adapter.testConnection(config) + → On success: write to hosts.json, create ~/.occ/hosts/{id}/ + → On failure: show error, offer to save anyway (offline-first) +``` + +### Connecting to a Host + +``` +User selects host from picker + → Core reads hosts.json for the entry + → Core finds the registered adapter for entry.type + → Core calls adapter.connect(entry.connection) + → Adapter returns a HostConnection + → Core sets activeHostId, fires onDidChangeActiveHost + → Control center, home panel, sidebar all re-render against new host + → For SSH/Docker: adapter may trigger VS Code Remote window +``` + +### Health Check Loop + +``` +Every 30 seconds for the active host: + → activeHost.healthCheck() + → Returns: { gateway: boolean, version: string, agents: number, uptime: string } + → Update ~/.occ/hosts/{id}/cache.json + → Update sidebar status indicator + → If status changed: fire onDidChangeHostStatus event + +Every 5 minutes for inactive hosts: + → Lightweight ping only (is the host reachable?) + → Update cache.json with reachability +``` + +### Removing a Host + +``` +User right-clicks host in sidebar → "Remove Host" + → Confirmation dialog + → Disconnect if active + → Remove from hosts.json + → Delete ~/.occ/hosts/{id}/ directory + → If it was the active host, switch to local-default +``` + +## Cache Schema (`cache.json`) + +```typescript +interface HostCache { + lastCheckedAt: string; // ISO 8601 + gateway: { + running: boolean; + version?: string; + uptime?: string; + port?: number; + }; + agents: { + count: number; + names: string[]; + defaultAgent?: string; + }; + channels: { + count: number; + connected: string[]; // Channel names that are connected + }; + system: { + os?: string; // "macOS" | "Linux" | "Windows" + hostname?: string; + openclawVersion?: string; + nodeVersion?: string; + }; +} +``` + +## Settings Schema (`settings.json`) + +```typescript +interface OccSettings { + version: 1; + defaultHostId?: string; // Host to auto-select on launch + healthCheckIntervalMs?: number; // Default: 30000 + backgroundCheckIntervalMs?: number; // Default: 300000 + showOfflineHosts?: boolean; // Show unreachable hosts in picker (default: true) + theme?: { + hostColors?: boolean; // Use per-host accent colors (default: true) + }; +} +``` + +## File Permissions + +- `~/.occ/hosts.json` — `0600` (contains connection details, may include hostnames/IPs) +- `~/.occ/settings.json` — `0644` (no sensitive data) +- `~/.occ/hosts/*/cache.json` — `0600` (may contain agent names, channel info) +- SSH keys are **never** stored in `~/.occ/` — always reference paths to `~/.ssh/` +- Cloud API tokens are stored in **VS Code SecretStorage** (encrypted), not in JSON files diff --git a/docs/plans/multihost/02-extension-api.md b/docs/plans/multihost/02-extension-api.md new file mode 100644 index 00000000..80863cde --- /dev/null +++ b/docs/plans/multihost/02-extension-api.md @@ -0,0 +1,290 @@ +# Extension-to-Extension API + +## Overview + +The core `openclaw` extension exports a typed API surface that host adapter extensions consume. This follows the VS Code pattern used by GitHub Copilot, Remote SSH, and other multi-extension ecosystems. + +## Core Extension Exports (`occ.openclaw`) + +```typescript +// Returned from the core extension's activate() function +export interface OpenClawCoreAPI { + readonly version: string; // API version for compatibility checking + + // ── Host Registration ── + registerHostAdapter(adapter: HostAdapter): vscode.Disposable; + + // ── Host State ── + getActiveHost(): HostConnection | undefined; + getHost(id: string): HostConnection | undefined; + getAllHosts(): HostEntry[]; + setActiveHost(id: string): Promise; + + // ── Events ── + readonly onDidChangeActiveHost: vscode.Event; + readonly onDidChangeHostStatus: vscode.Event<{ hostId: string; status: HostStatus }>; + readonly onDidAddHost: vscode.Event; + readonly onDidRemoveHost: vscode.Event; // host ID + + // ── Commands (for adapter extensions to invoke) ── + showHostPicker(): Promise; // returns selected host ID + showAddHostWizard(type?: string): Promise; + refreshHost(id: string): Promise; +} +``` + +## Host Adapter Interface + +Each adapter extension implements this interface and registers it with the core: + +```typescript +interface HostAdapter { + // ── Identity ── + readonly type: string; // "local" | "docker" | "ssh" | "cloud" + readonly displayName: string; // "Local Machine" | "Docker Container" | "SSH Remote" | "MoltPod Cloud" + readonly icon: vscode.ThemeIcon; // Icon shown in picker and sidebar + + // ── Discovery ── + // Find available hosts of this type (e.g., list Docker containers, scan SSH config) + discover(): Promise; + + // ── Connection ── + // Create a live connection to a host given its config + connect(config: HostConnectionConfig): Promise; + + // ── Test connection without fully connecting ── + testConnection(config: HostConnectionConfig): Promise; + + // ── Configuration UI ── + // Define what fields the "Add Host" wizard needs for this type + getConfigFields(): ConfigField[]; + + // ── Validation ── + validateConfig(config: HostConnectionConfig): ConfigValidationResult; +} + +interface DiscoveredHost { + suggestedId: string; + suggestedLabel: string; + connection: HostConnectionConfig; + metadata?: Record; // e.g., { containerImage: "openclaw/pod:latest" } +} + +interface TestResult { + success: boolean; + message: string; + details?: { + openclawInstalled: boolean; + openclawVersion?: string; + gatewayRunning?: boolean; + os?: string; + }; +} + +interface ConfigField { + id: string; + label: string; + type: "text" | "password" | "file" | "select" | "number" | "boolean"; + placeholder?: string; + defaultValue?: string | number | boolean; + required?: boolean; + options?: { label: string; value: string }[]; // For "select" type + validation?: { + pattern?: string; // Regex + message?: string; // Error message + min?: number; // For "number" type + max?: number; + }; + helpText?: string; // Shown below the field + group?: string; // Group fields visually (e.g., "Authentication", "Advanced") +} + +interface ConfigValidationResult { + valid: boolean; + errors: { fieldId: string; message: string }[]; +} +``` + +## Host Connection Interface + +The live connection object returned by `adapter.connect()`: + +```typescript +interface HostConnection extends vscode.Disposable { + // ── Identity ── + readonly hostId: string; + readonly hostEntry: HostEntry; + readonly adapter: HostAdapter; + + // ── Status ── + readonly status: HostStatus; + readonly onDidChangeStatus: vscode.Event; + + // ── Command Execution ── + // Run a command on the host (equivalent of child_process.spawn) + exec(command: string, args?: string[], options?: ExecOptions): Promise; + + // Run a command and stream output (for long-running operations) + execStream(command: string, args?: string[], options?: ExecOptions): ExecStream; + + // ── File Operations ── + readFile(remotePath: string): Promise; + readTextFile(remotePath: string, encoding?: string): Promise; + writeFile(remotePath: string, content: Uint8Array): Promise; + writeTextFile(remotePath: string, content: string, encoding?: string): Promise; + stat(remotePath: string): Promise; + readDirectory(remotePath: string): Promise; + mkdir(remotePath: string, recursive?: boolean): Promise; + delete(remotePath: string, recursive?: boolean): Promise; + + // ── OpenClaw Specific ── + // Read the host's openclaw.json + readConfig(): Promise; + writeConfig(config: OpenClawConfig): Promise; + + // Gateway operations + getGatewayUrl(): string; + gatewayHealthCheck(): Promise; + gatewayStart(): Promise; + gatewayStop(): Promise; + gatewayRestart(): Promise; + + // ── VS Code Integration ── + // Open the host's filesystem in the VS Code explorer + // For SSH: triggers Remote SSH connection + // For Docker: triggers container attach + // For Local: opens folder + // For Cloud: registers FileSystemProvider + openExplorer(path?: string): Promise; + + // Open a terminal connected to the host + openTerminal(name?: string): Promise; +} + +type HostStatus = "connected" | "connecting" | "disconnected" | "error"; + +interface ExecOptions { + cwd?: string; + env?: Record; + timeout?: number; // ms + stdin?: string; + shell?: boolean; // Run through shell (default: true) +} + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; + timedOut?: boolean; +} + +interface ExecStream { + readonly stdout: vscode.Event; + readonly stderr: vscode.Event; + readonly onDidExit: vscode.Event; + write(data: string): void; // Write to stdin + kill(signal?: string): void; +} + +interface GatewayHealth { + running: boolean; + version?: string; + uptime?: string; + port?: number; + url?: string; + agents?: number; + channels?: number; +} + +interface FileStat { + type: "file" | "directory" | "symlink"; + size: number; + mtime: number; + ctime: number; +} + +interface DirectoryEntry { + name: string; + type: "file" | "directory" | "symlink"; +} +``` + +## Registration Flow + +### Extension Activation Order + +1. **Core (`occ.openclaw`)** activates first (uses `*` activation event or `onStartupFinished`) + - Reads `~/.occ/hosts.json` + - Sets up host picker, sidebar, commands + - Exports the `OpenClawCoreAPI` + +2. **Adapter extensions** activate on: + - `onCommand:occ.addHost.{type}` — when user picks this type in Add Host wizard + - `onStartupFinished` — if the user has hosts of this type in their registry + - Extension dependency on `occ.openclaw` in `package.json`: + ```json + { + "extensionDependencies": ["occ.openclaw"] + } + ``` + +3. **Each adapter** calls `registerHostAdapter()` in its `activate()`: + ```typescript + export async function activate(context: vscode.ExtensionContext) { + const core = vscode.extensions.getExtension('occ.openclaw'); + if (!core) { return; } + + const api = core.isActive ? core.exports : await core.activate(); + + const disposable = api.registerHostAdapter(new DockerHostAdapter()); + context.subscriptions.push(disposable); + } + ``` + +### Error Handling Between Extensions + +```typescript +// Core wraps all adapter calls with timeout and error handling +async function safeAdapterCall( + adapter: HostAdapter, + method: string, + fn: () => Promise, + timeoutMs = 30000 +): Promise { + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${adapter.type} adapter timed out on ${method}`)), timeoutMs) + ), + ]); + } catch (err) { + vscode.window.showErrorMessage( + `${adapter.displayName}: ${err instanceof Error ? err.message : 'Unknown error'}` + ); + throw err; + } +} +``` + +## Version Compatibility + +The API includes a version field for forward compatibility: + +```typescript +// Core checks adapter compatibility +function isCompatible(adapterApiVersion: string, coreApiVersion: string): boolean { + const [adapterMajor] = adapterApiVersion.split('.'); + const [coreMajor] = coreApiVersion.split('.'); + return adapterMajor === coreMajor; // Same major version = compatible +} +``` + +Adapters should declare the API version they target: +```json +{ + "engines": { + "occ-core": "^1.0.0" + } +} +``` diff --git a/docs/plans/multihost/03-core-extension.md b/docs/plans/multihost/03-core-extension.md new file mode 100644 index 00000000..ab30479f --- /dev/null +++ b/docs/plans/multihost/03-core-extension.md @@ -0,0 +1,308 @@ +# Core Extension Refactor Plan + +## Current State (`extensions/openclaw/`) + +The OpenClaw extension is currently a **monolithic single-host manager**: + +| File | Lines | Responsibility | +|---|---|---| +| `extension.ts` | 806 | Activation, balance bar, JWT sync, backend polling | +| `panels/home.ts` | 4,502 | Install wizard, gateway management, full home panel webview | +| `panels/config.ts` | 589 | Config proxy for gateway dashboard | +| `panels/setup.ts` | 687 | Channel manager / control center panel | +| `panels/status.ts` | 937 | Status panel webview | +| `panels/onboarding.ts` | 340 | Onboarding flow | +| `panels/config-path.ts` | 43 | Config path resolution | +| **Total** | **~7,900** | | + +### Hard-Coded Local Assumptions (must be abstracted) + +```typescript +// extension.ts — hardcoded localhost +const r = await fetch('https://occ.mba.sh/api/v1/me', ...); // ← backend polling, keep as-is (account-level) +function isWebServerReachable(): Promise { + const url = `http://localhost:${port}/`; // ← must use activeHost.getGatewayUrl() +} + +// home.ts — hardcoded local CLI +cp.spawn('openclaw', ['gateway', 'start'], ...); // ← must use activeHost.exec('openclaw', ['gateway', 'start']) +fs.readFileSync(configPath, 'utf-8'); // ← must use activeHost.readTextFile(configPath) +cp.exec('which openclaw', ...); // ← must use activeHost.exec('which', ['openclaw']) + +// config.ts — hardcoded localhost proxy +http.request({ hostname: '127.0.0.1', port: targetPort, ... }); // ← must use activeHost gateway URL + +// setup.ts — hardcoded local config +fs.readFileSync(configPath, "utf-8"); // ← must use activeHost.readTextFile() +``` + +## Refactor Plan + +### Phase 1: Extract Host Abstraction Layer + +Create new files in `extensions/openclaw/src/`: + +``` +src/ +├── hosts/ +│ ├── types.ts # HostAdapter, HostConnection, HostEntry interfaces +│ ├── registry.ts # HostRegistry class — manages ~/.occ/hosts.json +│ ├── manager.ts # HostManager — active host state, adapter registration +│ └── cache.ts # Host cache read/write +├── api/ +│ └── exports.ts # OpenClawCoreAPI implementation (exported to other extensions) +├── ui/ +│ ├── hostPicker.ts # Status bar host picker + Quick Pick +│ ├── hostTree.ts # Sidebar TreeDataProvider for host list +│ └── addHostWizard.ts # Multi-step input wizard for adding hosts +├── panels/ # (existing, to be refactored) +│ ├── home.ts # Refactored to use activeHost instead of local calls +│ ├── config.ts +│ ├── setup.ts +│ ├── status.ts +│ └── onboarding.ts +└── extension.ts # Refactored activation +``` + +### Phase 2: New — `HostRegistry` Class + +```typescript +// src/hosts/registry.ts +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const OCC_DIR = path.join(os.homedir(), '.occ'); +const HOSTS_FILE = path.join(OCC_DIR, 'hosts.json'); + +export class HostRegistry { + private hosts: Map = new Map(); + private _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + constructor() { + this.load(); + // Watch for external changes to hosts.json + this.startFileWatcher(); + } + + load(): void { + if (!fs.existsSync(HOSTS_FILE)) { + this.initializeDefaults(); + return; + } + const data = JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf-8')); + this.hosts.clear(); + for (const entry of data.hosts) { + this.hosts.set(entry.id, entry); + } + } + + private initializeDefaults(): void { + // Create ~/.occ/ and seed with local-default host + fs.mkdirSync(OCC_DIR, { recursive: true }); + const defaultHost: HostEntry = { + id: 'local-default', + type: 'local', + label: 'This Machine', + connection: { type: 'local' }, + default: true, + createdAt: new Date().toISOString(), + }; + this.hosts.set(defaultHost.id, defaultHost); + this.save(); + } + + save(): void { + const data = { + version: 1, + activeHostId: this.getActiveHostId(), + hosts: Array.from(this.hosts.values()), + }; + fs.mkdirSync(OCC_DIR, { recursive: true }); + fs.writeFileSync(HOSTS_FILE, JSON.stringify(data, null, 2), 'utf-8'); + } + + addHost(entry: HostEntry): void { ... } + removeHost(id: string): void { ... } + updateHost(id: string, updates: Partial): void { ... } + getHost(id: string): HostEntry | undefined { ... } + getAllHosts(): HostEntry[] { ... } + getActiveHostId(): string { ... } + setActiveHostId(id: string): void { ... } +} +``` + +### Phase 3: New — `HostManager` Class + +```typescript +// src/hosts/manager.ts +export class HostManager implements vscode.Disposable { + private adapters: Map = new Map(); + private connections: Map = new Map(); + private activeHostId: string | undefined; + + private _onDidChangeActiveHost = new vscode.EventEmitter(); + readonly onDidChangeActiveHost = this._onDidChangeActiveHost.event; + + constructor( + private registry: HostRegistry, + private context: vscode.ExtensionContext, + ) { + // Auto-connect to the default host on startup + const activeId = registry.getActiveHostId(); + if (activeId) { + void this.setActiveHost(activeId); + } + } + + registerAdapter(adapter: HostAdapter): vscode.Disposable { + this.adapters.set(adapter.type, adapter); + // Auto-connect any hosts of this type that were waiting + for (const host of this.registry.getAllHosts()) { + if (host.type === adapter.type && host.id === this.activeHostId) { + void this.connectHost(host); + } + } + return new vscode.Disposable(() => this.adapters.delete(adapter.type)); + } + + async setActiveHost(id: string): Promise { + const entry = this.registry.getHost(id); + if (!entry) throw new Error(`Host not found: ${id}`); + + // Disconnect current + const current = this.connections.get(this.activeHostId ?? ''); + if (current) { current.dispose(); } + + this.activeHostId = id; + this.registry.setActiveHostId(id); + + await this.connectHost(entry); + this._onDidChangeActiveHost.fire(this.getActiveConnection()); + } + + private async connectHost(entry: HostEntry): Promise { + const adapter = this.adapters.get(entry.type); + if (!adapter) { + // Adapter not installed — prompt user + void vscode.window.showWarningMessage( + `Install the OpenClaw ${entry.type} extension to connect to "${entry.label}"`, + 'Install' + ).then(choice => { + if (choice === 'Install') { + vscode.commands.executeCommand('workbench.extensions.search', `occ.openclaw-${entry.type}`); + } + }); + return; + } + + const connection = await adapter.connect(entry.connection); + this.connections.set(entry.id, connection); + } + + getActiveConnection(): HostConnection | undefined { + return this.connections.get(this.activeHostId ?? ''); + } +} +``` + +### Phase 4: Refactor `home.ts` + +The biggest file (4,502 lines). Key changes: + +```typescript +// BEFORE (home.ts) +cp.spawn('openclaw', ['gateway', 'start'], { ... }); + +// AFTER +const host = this.hostManager.getActiveConnection(); +if (host) { + const result = await host.gatewayStart(); + // ... handle result +} else { + // Show "no host connected" state +} +``` + +```typescript +// BEFORE +function isInstalled(): boolean { + try { cp.execSync('which openclaw', ...); return true; } catch { return false; } +} + +// AFTER +async function isInstalled(host: HostConnection): Promise { + const result = await host.exec('which', ['openclaw']); + return result.exitCode === 0; +} +``` + +```typescript +// BEFORE +const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); +const raw = fs.readFileSync(configPath, 'utf-8'); + +// AFTER +const config = await host.readConfig(); +``` + +### What Stays in Core (Does NOT Move to Adapters) + +- **OCC account/auth** (`occAuthProvider.ts`, JWT management) — this is account-level, not host-level +- **Balance bar** — shows user's OCC balance, independent of which host is active +- **Onboarding flow** — account creation at occ.mba.sh +- **Model configuration** — BYOK settings, LLM provider config + +### What Gets Abstracted (Host-Dependent) + +- **Gateway start/stop/restart/status** +- **OpenClaw install/update/uninstall** +- **Config file read/write** +- **CLI command execution** (doctor, channels, etc.) +- **Log file reading** +- **File system operations** (workspace files, AGENTS.md, etc.) + +## New Commands + +```json +{ + "commands": [ + { "command": "occ.hosts.pick", "title": "OCC: Switch Host" }, + { "command": "occ.hosts.add", "title": "OCC: Add Host" }, + { "command": "occ.hosts.remove", "title": "OCC: Remove Host" }, + { "command": "occ.hosts.edit", "title": "OCC: Edit Host" }, + { "command": "occ.hosts.refresh", "title": "OCC: Refresh Host Status" }, + { "command": "occ.hosts.refreshAll", "title": "OCC: Refresh All Hosts" }, + { "command": "occ.hosts.openExplorer", "title": "OCC: Open Host in Explorer" }, + { "command": "occ.hosts.openTerminal", "title": "OCC: Open Host Terminal" }, + { "command": "occ.hosts.discover", "title": "OCC: Discover Available Hosts" } + ] +} +``` + +## New Contribution Points + +```json +{ + "viewsContainers": { + "activitybar": [ + { + "id": "occ-hosts", + "title": "OpenClaw Hosts", + "icon": "$(server)" + } + ] + }, + "views": { + "occ-hosts": [ + { + "id": "occ.hostsView", + "name": "Hosts", + "icon": "$(server)", + "contextualTitle": "OpenClaw Hosts" + } + ] + } +} +``` diff --git a/docs/plans/multihost/04-local-adapter.md b/docs/plans/multihost/04-local-adapter.md new file mode 100644 index 00000000..db29806a --- /dev/null +++ b/docs/plans/multihost/04-local-adapter.md @@ -0,0 +1,348 @@ +# Local Host Adapter — `openclaw-local` + +## Purpose + +The simplest adapter — manages an OpenClaw installation on the same machine running OCCode. This is where 90% of the existing `home.ts` code lands after the refactor. + +## Ships Bundled + +This extension **always ships with OCCode**. It's the default experience. Users who never touch Docker or SSH will only ever interact with this adapter. + +## Implementation + +### `LocalHostAdapter` + +```typescript +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export class LocalHostAdapter implements HostAdapter { + readonly type = 'local'; + readonly displayName = 'Local Machine'; + readonly icon = new vscode.ThemeIcon('device-desktop'); + + async discover(): Promise { + // Check if OpenClaw is installed locally + const installed = await this.isOpenClawInstalled(); + if (!installed) return []; + + return [{ + suggestedId: 'local-default', + suggestedLabel: os.hostname(), + connection: { type: 'local' }, + metadata: { + os: process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux', + hostname: os.hostname(), + }, + }]; + } + + async connect(config: LocalConnection): Promise { + return new LocalHostConnection(config); + } + + async testConnection(config: LocalConnection): Promise { + const installed = await this.isOpenClawInstalled(); + if (!installed) { + return { success: false, message: 'OpenClaw CLI not found in PATH', details: { openclawInstalled: false } }; + } + const version = await this.getVersion(); + const gatewayRunning = await this.isGatewayRunning(); + return { + success: true, + message: `OpenClaw ${version} installed${gatewayRunning ? ', gateway running' : ''}`, + details: { + openclawInstalled: true, + openclawVersion: version, + gatewayRunning, + os: process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux', + }, + }; + } + + getConfigFields(): ConfigField[] { + return [ + { + id: 'configPath', + label: 'Config Path', + type: 'text', + placeholder: '~/.openclaw/openclaw.json', + defaultValue: path.join(os.homedir(), '.openclaw', 'openclaw.json'), + required: false, + helpText: 'Override the default OpenClaw config location', + group: 'Advanced', + }, + ]; + } + + validateConfig(config: LocalConnection): ConfigValidationResult { + return { valid: true, errors: [] }; // Local always valid + } + + private async isOpenClawInstalled(): Promise { + return new Promise(resolve => { + const cmd = process.platform === 'win32' ? 'where' : 'which'; + cp.exec(`${cmd} openclaw`, { timeout: 5000 }, err => resolve(!err)); + }); + } + + private async getVersion(): Promise { + return new Promise(resolve => { + cp.exec('openclaw --version', { timeout: 5000 }, (err, stdout) => { + resolve(err ? 'unknown' : stdout.trim()); + }); + }); + } + + private async isGatewayRunning(): Promise { + return new Promise(resolve => { + const port = this.getConfiguredPort(); + const req = require('http').get(`http://localhost:${port}/`, { timeout: 3000 }, (res: any) => { + res.resume(); + resolve(res.statusCode < 500); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); + } + + private getConfiguredPort(): number { + try { + const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); + const raw = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(raw); + const p = config?.gateway?.port ?? config?.port ?? 18789; + return typeof p === 'number' && p > 0 && p < 65536 ? p : 18789; + } catch { + return 18789; + } + } +} +``` + +### `LocalHostConnection` + +```typescript +export class LocalHostConnection implements HostConnection { + readonly hostId: string; + readonly hostEntry: HostEntry; + readonly adapter: HostAdapter; + private _status: HostStatus = 'connected'; + + private _onDidChangeStatus = new vscode.EventEmitter(); + readonly onDidChangeStatus = this._onDidChangeStatus.event; + + get status(): HostStatus { return this._status; } + + constructor(private config: LocalConnection, hostEntry: HostEntry, adapter: HostAdapter) { + this.hostId = hostEntry.id; + this.hostEntry = hostEntry; + this.adapter = adapter; + } + + // ── Command Execution ── + async exec(command: string, args: string[] = [], options: ExecOptions = {}): Promise { + return new Promise((resolve) => { + const child = cp.spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + shell: options.shell ?? true, + timeout: options.timeout, + windowsHide: true, + }); + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (d) => stdout += d.toString()); + child.stderr?.on('data', (d) => stderr += d.toString()); + + if (options.stdin) { + child.stdin?.write(options.stdin); + child.stdin?.end(); + } + + child.on('close', (code) => { + resolve({ exitCode: code ?? 1, stdout, stderr }); + }); + + child.on('error', (err) => { + resolve({ exitCode: 1, stdout, stderr: stderr + '\n' + err.message }); + }); + }); + } + + execStream(command: string, args: string[] = [], options: ExecOptions = {}): ExecStream { + const child = cp.spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + shell: options.shell ?? true, + windowsHide: true, + }); + + const _stdout = new vscode.EventEmitter(); + const _stderr = new vscode.EventEmitter(); + const _onDidExit = new vscode.EventEmitter(); + + child.stdout?.on('data', (d) => _stdout.fire(d.toString())); + child.stderr?.on('data', (d) => _stderr.fire(d.toString())); + child.on('close', (code) => _onDidExit.fire(code ?? 1)); + + return { + stdout: _stdout.event, + stderr: _stderr.event, + onDidExit: _onDidExit.event, + write: (data) => child.stdin?.write(data), + kill: (signal) => child.kill(signal as any), + }; + } + + // ── File Operations ── + async readFile(remotePath: string): Promise { + return fs.promises.readFile(this.expandPath(remotePath)); + } + + async readTextFile(remotePath: string, encoding = 'utf-8'): Promise { + return fs.promises.readFile(this.expandPath(remotePath), encoding as BufferEncoding); + } + + async writeFile(remotePath: string, content: Uint8Array): Promise { + const p = this.expandPath(remotePath); + await fs.promises.mkdir(path.dirname(p), { recursive: true }); + await fs.promises.writeFile(p, content); + } + + async writeTextFile(remotePath: string, content: string): Promise { + const p = this.expandPath(remotePath); + await fs.promises.mkdir(path.dirname(p), { recursive: true }); + await fs.promises.writeFile(p, content, 'utf-8'); + } + + async stat(remotePath: string): Promise { + try { + const s = await fs.promises.stat(this.expandPath(remotePath)); + return { + type: s.isDirectory() ? 'directory' : s.isSymbolicLink() ? 'symlink' : 'file', + size: s.size, + mtime: s.mtimeMs, + ctime: s.ctimeMs, + }; + } catch { return null; } + } + + async readDirectory(remotePath: string): Promise { + const entries = await fs.promises.readdir(this.expandPath(remotePath), { withFileTypes: true }); + return entries.map(e => ({ + name: e.name, + type: e.isDirectory() ? 'directory' : e.isSymbolicLink() ? 'symlink' : 'file', + })); + } + + async mkdir(remotePath: string, recursive = true): Promise { + await fs.promises.mkdir(this.expandPath(remotePath), { recursive }); + } + + async delete(remotePath: string, recursive = false): Promise { + await fs.promises.rm(this.expandPath(remotePath), { recursive, force: true }); + } + + // ── OpenClaw Specific ── + async readConfig(): Promise { + const configPath = this.hostEntry.configPath || path.join(os.homedir(), '.openclaw', 'openclaw.json'); + const raw = await this.readTextFile(configPath); + return JSON.parse(raw); + } + + async writeConfig(config: any): Promise { + const configPath = this.hostEntry.configPath || path.join(os.homedir(), '.openclaw', 'openclaw.json'); + await this.writeTextFile(configPath, JSON.stringify(config, null, 2)); + } + + getGatewayUrl(): string { + const port = 18789; // TODO: read from config + return `http://localhost:${port}`; + } + + async gatewayHealthCheck(): Promise { + // Existing isWebServerReachable() logic + // Plus: openclaw status --json for detailed info + const result = await this.exec('openclaw', ['status', '--json']); + if (result.exitCode === 0) { + try { + const status = JSON.parse(result.stdout); + return { running: true, version: status.version, ...status }; + } catch { return { running: true }; } + } + return { running: false }; + } + + async gatewayStart(): Promise { + return this.exec('openclaw', ['gateway', 'start']); + } + + async gatewayStop(): Promise { + return this.exec('openclaw', ['gateway', 'stop']); + } + + async gatewayRestart(): Promise { + return this.exec('openclaw', ['gateway', 'restart']); + } + + // ── VS Code Integration ── + async openExplorer(p?: string): Promise { + const folderPath = p || this.hostEntry.configPath + ? path.dirname(this.hostEntry.configPath!) + : path.join(os.homedir(), '.openclaw'); + await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(folderPath)); + } + + async openTerminal(name?: string): Promise { + const terminal = vscode.window.createTerminal(name || `Local: ${this.hostEntry.label}`); + terminal.show(); + return terminal; + } + + dispose(): void { + this._onDidChangeStatus.dispose(); + } + + private expandPath(p: string): string { + return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p; + } +} +``` + +## What Moves From `home.ts` Into This Adapter + +| Current `home.ts` Function | New Location | +|---|---| +| `cp.spawn('openclaw', ...)` calls | `LocalHostConnection.exec()` | +| `fs.readFileSync(configPath)` | `LocalHostConnection.readTextFile()` | +| `isWebServerReachable()` | `LocalHostConnection.gatewayHealthCheck()` | +| `_runSilentInstall()` | Keep in home.ts but call through `activeHost.exec('npm', ['install', '-g', 'openclaw'])` | +| `getConfiguredGatewayPort()` | `LocalHostConnection.getGatewayUrl()` | +| `getOpenClawWorkspaceDir()` | `LocalHostConnection.readConfig()` → extract workspace | + +## Package.json + +```json +{ + "name": "openclaw-local", + "displayName": "OpenClaw Local", + "description": "Manage OpenClaw installations on your local machine", + "version": "1.0.0", + "publisher": "occ", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["occ.openclaw"], + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { "command": "occ.local.install", "title": "OCC Local: Install OpenClaw" }, + { "command": "occ.local.uninstall", "title": "OCC Local: Uninstall OpenClaw" } + ] + } +} +``` diff --git a/docs/plans/multihost/05-docker-adapter.md b/docs/plans/multihost/05-docker-adapter.md new file mode 100644 index 00000000..d741046d --- /dev/null +++ b/docs/plans/multihost/05-docker-adapter.md @@ -0,0 +1,443 @@ +# Docker Host Adapter — `openclaw-docker` + +## Purpose + +Manage OpenClaw installations running inside Docker containers. Supports: +- Standalone containers (`docker run`) +- Docker Compose services +- Container discovery (find OpenClaw containers automatically) +- Port mapping for gateway access + +## Prerequisites + +- Docker CLI installed on the user's machine +- Docker daemon running +- Container must have `openclaw` CLI installed inside it + +## Container Discovery + +```typescript +export class DockerHostAdapter implements HostAdapter { + readonly type = 'docker'; + readonly displayName = 'Docker Container'; + readonly icon = new vscode.ThemeIcon('package'); // container icon + + async discover(): Promise { + const hosts: DiscoveredHost[] = []; + + // Method 1: Find containers with openclaw label + const labeled = await this.exec('docker', [ + 'ps', '--filter', 'label=com.openclaw', '--format', '{{json .}}' + ]); + + // Method 2: Find containers with "openclaw" in the image name + const byImage = await this.exec('docker', [ + 'ps', '--filter', 'ancestor=*openclaw*', '--format', '{{json .}}' + ]); + + // Method 3: Find containers that have openclaw binary + // (slower — runs `docker exec` on each running container) + const allContainers = await this.exec('docker', [ + 'ps', '--format', '{{.ID}} {{.Names}} {{.Image}}' + ]); + + for (const line of allContainers.stdout.trim().split('\n')) { + if (!line.trim()) continue; + const [id, name, image] = line.split(/\s+/); + + // Quick check: does openclaw exist in the container? + const check = await this.exec('docker', ['exec', id, 'which', 'openclaw']); + if (check.exitCode !== 0) continue; + + // Get version + const version = await this.exec('docker', ['exec', id, 'openclaw', '--version']); + + // Get port mapping + const ports = await this.exec('docker', [ + 'port', id, '18789' + ]); + + hosts.push({ + suggestedId: `docker-${name}`, + suggestedLabel: `${name} (${image})`, + connection: { + type: 'docker', + containerId: id, + containerName: name, + }, + metadata: { + image, + openclawVersion: version.stdout.trim(), + portMapping: ports.stdout.trim(), + }, + }); + } + + return hosts; + } + + getConfigFields(): ConfigField[] { + return [ + { + id: 'containerId', + label: 'Container ID or Name', + type: 'text', + placeholder: 'openclaw-pod-1', + required: true, + helpText: 'The Docker container ID or name. Use "Discover" to find containers automatically.', + }, + { + id: 'composeService', + label: 'Docker Compose Service', + type: 'text', + placeholder: 'openclaw', + required: false, + helpText: 'If using Docker Compose, specify the service name instead of container ID', + group: 'Docker Compose', + }, + { + id: 'composeFile', + label: 'Compose File Path', + type: 'file', + placeholder: './docker-compose.yml', + required: false, + helpText: 'Path to docker-compose.yml (default: ./docker-compose.yml)', + group: 'Docker Compose', + }, + { + id: 'dockerHost', + label: 'Docker Host', + type: 'text', + placeholder: 'unix:///var/run/docker.sock', + required: false, + helpText: 'Override DOCKER_HOST (e.g., tcp://remote:2376 or ssh://user@host)', + group: 'Advanced', + }, + { + id: 'shell', + label: 'Shell', + type: 'select', + defaultValue: '/bin/sh', + options: [ + { label: '/bin/sh', value: '/bin/sh' }, + { label: '/bin/bash', value: '/bin/bash' }, + { label: '/bin/zsh', value: '/bin/zsh' }, + ], + required: false, + group: 'Advanced', + }, + { + id: 'configPath', + label: 'Config Path (inside container)', + type: 'text', + placeholder: '/root/.openclaw/openclaw.json', + defaultValue: '/root/.openclaw/openclaw.json', + required: false, + group: 'Advanced', + }, + { + id: 'gatewayPort', + label: 'Gateway Port (host-side)', + type: 'number', + placeholder: '18789', + required: false, + helpText: 'The host port mapped to the container gateway. Auto-detected from docker port.', + group: 'Advanced', + validation: { min: 1, max: 65535 }, + }, + ]; + } +} +``` + +## Docker Host Connection + +```typescript +export class DockerHostConnection implements HostConnection { + private containerId: string; + private composePrefix: string[] = []; + + constructor( + private config: DockerConnection, + public readonly hostEntry: HostEntry, + public readonly adapter: HostAdapter, + ) { + // Resolve container ID + if (config.composeService && config.composeFile) { + this.composePrefix = ['docker', 'compose', '-f', config.composeFile]; + this.containerId = config.composeService; + } else { + this.containerId = config.containerId || config.containerName || ''; + } + } + + // ── Command Execution ── + async exec(command: string, args: string[] = [], options: ExecOptions = {}): Promise { + const dockerArgs = this.buildDockerExecArgs(command, args, options); + return this.localExec('docker', dockerArgs, options); + } + + private buildDockerExecArgs(command: string, args: string[], options: ExecOptions): string[] { + const execArgs = ['exec']; + + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + execArgs.push('-e', `${key}=${value}`); + } + } + + if (options.cwd) { + execArgs.push('-w', options.cwd); + } + + execArgs.push(this.containerId); + + if (options.shell) { + const shell = this.config.shell || '/bin/sh'; + execArgs.push(shell, '-c', `${command} ${args.join(' ')}`); + } else { + execArgs.push(command, ...args); + } + + return execArgs; + } + + execStream(command: string, args: string[] = [], options: ExecOptions = {}): ExecStream { + const dockerArgs = this.buildDockerExecArgs(command, args, { ...options, shell: true }); + + // For Compose services + if (this.composePrefix.length) { + const composeArgs = [...this.composePrefix.slice(1), 'exec', '-T', this.containerId, command, ...args]; + return this.localExecStream(this.composePrefix[0], composeArgs); + } + + return this.localExecStream('docker', dockerArgs); + } + + // ── File Operations ── + async readFile(remotePath: string): Promise { + // Use docker cp for binary files + const tmpFile = path.join(os.tmpdir(), `occ-docker-${Date.now()}`); + await this.localExec('docker', ['cp', `${this.containerId}:${remotePath}`, tmpFile]); + const content = await fs.promises.readFile(tmpFile); + await fs.promises.unlink(tmpFile); + return content; + } + + async readTextFile(remotePath: string): Promise { + const result = await this.exec('cat', [remotePath], { shell: false }); + if (result.exitCode !== 0) { + throw new Error(`Failed to read ${remotePath}: ${result.stderr}`); + } + return result.stdout; + } + + async writeFile(remotePath: string, content: Uint8Array): Promise { + const tmpFile = path.join(os.tmpdir(), `occ-docker-${Date.now()}`); + await fs.promises.writeFile(tmpFile, content); + await this.localExec('docker', ['cp', tmpFile, `${this.containerId}:${remotePath}`]); + await fs.promises.unlink(tmpFile); + } + + async writeTextFile(remotePath: string, content: string): Promise { + // Use docker exec with stdin + const result = await this.localExec('docker', [ + 'exec', '-i', this.containerId, 'tee', remotePath + ], { stdin: content }); + if (result.exitCode !== 0) { + throw new Error(`Failed to write ${remotePath}: ${result.stderr}`); + } + } + + async stat(remotePath: string): Promise { + const result = await this.exec('stat', ['-c', '%F %s %Y %Z', remotePath], { shell: false }); + if (result.exitCode !== 0) return null; + const [type, size, mtime, ctime] = result.stdout.trim().split(' '); + return { + type: type === 'directory' ? 'directory' : type === 'symbolic link' ? 'symlink' : 'file', + size: parseInt(size), + mtime: parseInt(mtime) * 1000, + ctime: parseInt(ctime) * 1000, + }; + } + + async readDirectory(remotePath: string): Promise { + const result = await this.exec('ls', ['-1F', remotePath], { shell: false }); + if (result.exitCode !== 0) return []; + return result.stdout.trim().split('\n').filter(Boolean).map(entry => { + const isDir = entry.endsWith('/'); + const isLink = entry.endsWith('@'); + const name = entry.replace(/[/@*]$/, ''); + return { name, type: isDir ? 'directory' : isLink ? 'symlink' : 'file' }; + }); + } + + async mkdir(remotePath: string, recursive = true): Promise { + await this.exec('mkdir', recursive ? ['-p', remotePath] : [remotePath], { shell: false }); + } + + async delete(remotePath: string, recursive = false): Promise { + const flags = recursive ? ['-rf'] : ['-f']; + await this.exec('rm', [...flags, remotePath], { shell: false }); + } + + // ── OpenClaw Specific ── + async readConfig(): Promise { + const configPath = this.config.configPath || '/root/.openclaw/openclaw.json'; + const raw = await this.readTextFile(configPath); + return JSON.parse(raw); + } + + async writeConfig(config: any): Promise { + const configPath = this.config.configPath || '/root/.openclaw/openclaw.json'; + await this.writeTextFile(configPath, JSON.stringify(config, null, 2)); + } + + getGatewayUrl(): string { + // If port mapping is configured, use host-side port + if (this.config.portMappings?.gateway) { + return `http://localhost:${this.config.portMappings.gateway}`; + } + // Otherwise try to auto-detect from docker port + return `http://localhost:18789`; + } + + async gatewayHealthCheck(): Promise { + const result = await this.exec('openclaw', ['status', '--json']); + if (result.exitCode === 0) { + try { return { running: true, ...JSON.parse(result.stdout) }; } catch {} + } + return { running: false }; + } + + async gatewayStart(): Promise { return this.exec('openclaw', ['gateway', 'start']); } + async gatewayStop(): Promise { return this.exec('openclaw', ['gateway', 'stop']); } + async gatewayRestart(): Promise { return this.exec('openclaw', ['gateway', 'restart']); } + + // ── VS Code Integration ── + async openExplorer(p?: string): Promise { + // Attach to container using Dev Containers pattern + // This opens a new VS Code window connected to the container + await vscode.commands.executeCommand( + 'remote-containers.attachToRunningContainer', + this.containerId + ); + } + + async openTerminal(name?: string): Promise { + const terminal = vscode.window.createTerminal({ + name: name || `Docker: ${this.hostEntry.label}`, + shellPath: 'docker', + shellArgs: ['exec', '-it', this.containerId, this.config.shell || '/bin/sh'], + }); + terminal.show(); + return terminal; + } + + dispose(): void {} + + // ── Helpers ── + private async localExec(cmd: string, args: string[], options: ExecOptions = {}): Promise { + return new Promise(resolve => { + const env = { ...process.env }; + if (this.config.dockerHost) env.DOCKER_HOST = this.config.dockerHost; + + const child = cp.spawn(cmd, args, { env, timeout: options.timeout, windowsHide: true }); + let stdout = '', stderr = ''; + child.stdout?.on('data', d => stdout += d); + child.stderr?.on('data', d => stderr += d); + if (options.stdin) { child.stdin?.write(options.stdin); child.stdin?.end(); } + child.on('close', code => resolve({ exitCode: code ?? 1, stdout, stderr })); + child.on('error', err => resolve({ exitCode: 1, stdout, stderr: err.message })); + }); + } + + private localExecStream(cmd: string, args: string[]): ExecStream { + const env = { ...process.env }; + if (this.config.dockerHost) env.DOCKER_HOST = this.config.dockerHost; + + const child = cp.spawn(cmd, args, { env, windowsHide: true }); + const _stdout = new vscode.EventEmitter(); + const _stderr = new vscode.EventEmitter(); + const _exit = new vscode.EventEmitter(); + child.stdout?.on('data', d => _stdout.fire(d.toString())); + child.stderr?.on('data', d => _stderr.fire(d.toString())); + child.on('close', code => _exit.fire(code ?? 1)); + return { + stdout: _stdout.event, + stderr: _stderr.event, + onDidExit: _exit.event, + write: d => child.stdin?.write(d), + kill: s => child.kill(s as any), + }; + } +} +``` + +## Docker Compose Support + +When users configure a Compose service instead of a raw container, commands route through `docker compose`: + +```typescript +// Instead of: docker exec openclaw gateway start +// Uses: docker compose -f compose.yml exec openclaw openclaw gateway start + +// Container lifecycle (not available with raw containers): +async composeUp(): Promise { + return this.localExec('docker', ['compose', '-f', this.config.composeFile!, 'up', '-d', this.containerId]); +} + +async composeDown(): Promise { + return this.localExec('docker', ['compose', '-f', this.config.composeFile!, 'down']); +} + +async composeLogs(follow = false): Promise { + const args = ['compose', '-f', this.config.composeFile!, 'logs']; + if (follow) args.push('-f'); + args.push(this.containerId); + return this.localExecStream('docker', args); +} +``` + +## MoltPod Docker Integration + +For MoltPod pods that are Docker-based, the Docker adapter gains special awareness: + +```typescript +// Detect MoltPod containers by label +const MOLTPOD_LABEL = 'com.moltpod.pod-id'; + +async discoverMoltPodContainers(): Promise { + const result = await this.localExec('docker', [ + 'ps', '--filter', `label=${MOLTPOD_LABEL}`, '--format', '{{json .}}' + ]); + // Parse and return with MoltPod-specific metadata +} +``` + +## Package.json + +```json +{ + "name": "openclaw-docker", + "displayName": "OpenClaw Docker", + "description": "Manage OpenClaw installations in Docker containers", + "version": "1.0.0", + "publisher": "occ", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["occ.openclaw"], + "categories": ["Other"], + "activationEvents": [ + "onCommand:occ.docker.discover", + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { "command": "occ.docker.discover", "title": "OCC Docker: Discover Containers" }, + { "command": "occ.docker.attach", "title": "OCC Docker: Attach to Container" }, + { "command": "occ.docker.logs", "title": "OCC Docker: View Container Logs" } + ] + } +} +``` diff --git a/docs/plans/multihost/06-ssh-adapter.md b/docs/plans/multihost/06-ssh-adapter.md new file mode 100644 index 00000000..80e79bd3 --- /dev/null +++ b/docs/plans/multihost/06-ssh-adapter.md @@ -0,0 +1,528 @@ +# SSH Host Adapter — `openclaw-ssh` + +## Purpose + +Manage OpenClaw installations on remote servers via SSH. This is the power-user adapter for VPS, cloud instances, and remote dev machines. + +## Strategy: Leverage `open-remote-ssh` + +OCCode already ships the full Remote SSH extension (`apps/editor/extensions/open-remote-ssh/`). Rather than reimplanting SSH connection logic, this adapter: + +1. Uses `ssh2` library for lightweight operations (exec, file read/write, health checks) +2. Delegates to `open-remote-ssh` for heavy operations (open explorer, full remote development) + +This gives us the best of both worlds: +- **Fast command execution** without opening a full remote window +- **Full VS Code Remote experience** when the user wants to browse files or edit code + +## SSH Connection Management + +```typescript +import { Client as SSHClient, ConnectConfig } from 'ssh2'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export class SSHHostAdapter implements HostAdapter { + readonly type = 'ssh'; + readonly displayName = 'SSH Remote'; + readonly icon = new vscode.ThemeIcon('remote'); + + async discover(): Promise { + const hosts: DiscoveredHost[] = []; + + // Parse ~/.ssh/config for known hosts + const sshConfigPath = path.join(os.homedir(), '.ssh', 'config'); + if (!fs.existsSync(sshConfigPath)) return hosts; + + const configText = fs.readFileSync(sshConfigPath, 'utf-8'); + const hostBlocks = this.parseSSHConfig(configText); + + for (const block of hostBlocks) { + // Skip wildcards and generic patterns + if (block.host.includes('*') || block.host === '*') continue; + + hosts.push({ + suggestedId: `ssh-${block.host}`, + suggestedLabel: block.host, + connection: { + type: 'ssh', + host: block.hostname || block.host, + port: block.port ? parseInt(block.port) : 22, + user: block.user || os.userInfo().username, + authMethod: 'agent', // Default to agent, will fall back + sshConfigHost: block.host, + }, + metadata: { + sshConfigHost: block.host, + hostname: block.hostname || block.host, + }, + }); + } + + return hosts; + } + + async testConnection(config: SSHConnection): Promise { + const conn = new SSHClient(); + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + conn.end(); + resolve({ success: false, message: 'Connection timed out after 15s' }); + }, 15000); + + conn.on('ready', async () => { + clearTimeout(timeout); + + // Check if OpenClaw is installed + const ocCheck = await this.remoteExec(conn, 'which openclaw'); + const installed = ocCheck.exitCode === 0; + + let version: string | undefined; + let gatewayRunning = false; + let osName: string | undefined; + + if (installed) { + const versionResult = await this.remoteExec(conn, 'openclaw --version'); + version = versionResult.stdout.trim(); + + const statusResult = await this.remoteExec(conn, `curl -s -o /dev/null -w "%{http_code}" http://localhost:${config.gatewayPort || 18789}/`); + gatewayRunning = statusResult.stdout.trim() !== '000'; + } + + const osResult = await this.remoteExec(conn, 'uname -s'); + osName = osResult.stdout.trim(); + + conn.end(); + resolve({ + success: true, + message: installed + ? `Connected. OpenClaw ${version}${gatewayRunning ? ', gateway running' : ''}` + : 'Connected but OpenClaw not installed', + details: { openclawInstalled: installed, openclawVersion: version, gatewayRunning, os: osName }, + }); + }); + + conn.on('error', (err) => { + clearTimeout(timeout); + resolve({ success: false, message: `SSH error: ${err.message}` }); + }); + + conn.connect(this.buildConnectConfig(config)); + }); + } + + getConfigFields(): ConfigField[] { + return [ + // ── Basic ── + { + id: 'sshConfigHost', + label: 'SSH Config Host', + type: 'text', + placeholder: 'my-vps', + required: false, + helpText: 'Name from ~/.ssh/config. If set, overrides host/port/user/key below.', + }, + { + id: 'host', + label: 'Hostname / IP', + type: 'text', + placeholder: '192.168.1.50 or myserver.com', + required: true, + }, + { + id: 'port', + label: 'Port', + type: 'number', + defaultValue: 22, + required: false, + validation: { min: 1, max: 65535 }, + }, + { + id: 'user', + label: 'Username', + type: 'text', + placeholder: 'debian', + required: true, + }, + // ── Authentication ── + { + id: 'authMethod', + label: 'Auth Method', + type: 'select', + defaultValue: 'key', + options: [ + { label: 'SSH Key', value: 'key' }, + { label: 'SSH Agent', value: 'agent' }, + { label: 'Password', value: 'password' }, + ], + required: true, + group: 'Authentication', + }, + { + id: 'keyPath', + label: 'Private Key Path', + type: 'file', + placeholder: '~/.ssh/id_ed25519', + required: false, + helpText: 'Path to SSH private key. Only used with "SSH Key" auth method.', + group: 'Authentication', + }, + // ── Advanced ── + { + id: 'jumpHost', + label: 'Jump Host (Bastion)', + type: 'text', + placeholder: 'user@bastion.example.com', + required: false, + helpText: 'ProxyJump through a bastion host', + group: 'Advanced', + }, + { + id: 'gatewayPort', + label: 'Gateway Port', + type: 'number', + defaultValue: 18789, + required: false, + helpText: 'OpenClaw gateway port on the remote host', + group: 'Advanced', + validation: { min: 1, max: 65535 }, + }, + { + id: 'configPath', + label: 'Config Path', + type: 'text', + placeholder: '~/.openclaw/openclaw.json', + required: false, + group: 'Advanced', + }, + ]; + } + + validateConfig(config: SSHConnection): ConfigValidationResult { + const errors: { fieldId: string; message: string }[] = []; + if (!config.sshConfigHost && !config.host) { + errors.push({ fieldId: 'host', message: 'Either SSH Config Host or Hostname is required' }); + } + if (!config.sshConfigHost && !config.user) { + errors.push({ fieldId: 'user', message: 'Username is required when not using SSH Config' }); + } + if (config.authMethod === 'key' && config.keyPath && !fs.existsSync(config.keyPath.replace('~', os.homedir()))) { + errors.push({ fieldId: 'keyPath', message: 'Key file not found' }); + } + return { valid: errors.length === 0, errors }; + } + + async connect(config: SSHConnection): Promise { + return new SSHHostConnection(config, this); + } + + private buildConnectConfig(config: SSHConnection): ConnectConfig { + const connectConfig: ConnectConfig = { + host: config.host, + port: config.port || 22, + username: config.user, + readyTimeout: 15000, + keepaliveInterval: 10000, + }; + + if (config.authMethod === 'key' && config.keyPath) { + const keyPath = config.keyPath.replace('~', os.homedir()); + connectConfig.privateKey = fs.readFileSync(keyPath); + } else if (config.authMethod === 'agent') { + connectConfig.agent = process.env.SSH_AUTH_SOCK; + } + // Password auth handled via interactive prompt + + return connectConfig; + } + + private async remoteExec(conn: SSHClient, command: string): Promise { + return new Promise((resolve) => { + conn.exec(command, (err, stream) => { + if (err) { resolve({ exitCode: 1, stdout: '', stderr: err.message }); return; } + let stdout = '', stderr = ''; + stream.on('data', (d: Buffer) => stdout += d.toString()); + stream.stderr.on('data', (d: Buffer) => stderr += d.toString()); + stream.on('close', (code: number) => resolve({ exitCode: code, stdout, stderr })); + }); + }); + } + + private parseSSHConfig(text: string): Array<{ host: string; hostname?: string; port?: string; user?: string }> { + const blocks: Array<{ host: string; hostname?: string; port?: string; user?: string }> = []; + let current: any = null; + + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const match = trimmed.match(/^(\w+)\s+(.+)$/); + if (!match) continue; + + const [, key, value] = match; + if (key.toLowerCase() === 'host') { + if (current) blocks.push(current); + current = { host: value }; + } else if (current) { + current[key.toLowerCase()] = value; + } + } + if (current) blocks.push(current); + return blocks; + } +} +``` + +## SSH Host Connection + +```typescript +export class SSHHostConnection implements HostConnection { + private conn: SSHClient | undefined; + private _status: HostStatus = 'disconnected'; + private reconnectTimer: NodeJS.Timeout | undefined; + + // Port forwarding state + private forwardedPorts: Map = new Map(); + + constructor( + private config: SSHConnection, + public readonly adapter: SSHHostAdapter, + ) {} + + async connect(): Promise { + this._status = 'connecting'; + this.conn = new SSHClient(); + + return new Promise((resolve, reject) => { + this.conn!.on('ready', () => { + this._status = 'connected'; + this.setupKeepalive(); + resolve(); + }); + + this.conn!.on('error', (err) => { + this._status = 'error'; + this.scheduleReconnect(); + reject(err); + }); + + this.conn!.on('close', () => { + this._status = 'disconnected'; + this.scheduleReconnect(); + }); + + this.conn!.connect(this.adapter.buildConnectConfig(this.config)); + }); + } + + // ── Command Execution ── + async exec(command: string, args: string[] = [], options: ExecOptions = {}): Promise { + if (!this.conn) await this.connect(); + + const fullCommand = args.length + ? `${command} ${args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}` + : command; + + const cmdWithCwd = options.cwd + ? `cd '${options.cwd}' && ${fullCommand}` + : fullCommand; + + const cmdWithEnv = options.env + ? Object.entries(options.env).map(([k, v]) => `${k}='${v}'`).join(' ') + ' ' + cmdWithCwd + : cmdWithCwd; + + return new Promise((resolve) => { + const timeout = options.timeout + ? setTimeout(() => resolve({ exitCode: 124, stdout: '', stderr: 'Command timed out', timedOut: true }), options.timeout) + : null; + + this.conn!.exec(cmdWithEnv, (err, stream) => { + if (err) { + if (timeout) clearTimeout(timeout); + resolve({ exitCode: 1, stdout: '', stderr: err.message }); + return; + } + + let stdout = '', stderr = ''; + stream.on('data', (d: Buffer) => stdout += d.toString()); + stream.stderr.on('data', (d: Buffer) => stderr += d.toString()); + + if (options.stdin) { + stream.write(options.stdin); + stream.end(); + } + + stream.on('close', (code: number) => { + if (timeout) clearTimeout(timeout); + resolve({ exitCode: code, stdout, stderr }); + }); + }); + }); + } + + // ── File Operations ── + async readTextFile(remotePath: string): Promise { + const result = await this.exec('cat', [remotePath]); + if (result.exitCode !== 0) throw new Error(`Failed to read ${remotePath}: ${result.stderr}`); + return result.stdout; + } + + async writeTextFile(remotePath: string, content: string): Promise { + // Use SFTP for reliable file writes + if (!this.conn) await this.connect(); + + return new Promise((resolve, reject) => { + this.conn!.sftp((err, sftp) => { + if (err) { reject(err); return; } + const stream = sftp.createWriteStream(remotePath); + stream.on('close', () => { sftp.end(); resolve(); }); + stream.on('error', (e: Error) => { sftp.end(); reject(e); }); + stream.write(content); + stream.end(); + }); + }); + } + + async readFile(remotePath: string): Promise { + if (!this.conn) await this.connect(); + + return new Promise((resolve, reject) => { + this.conn!.sftp((err, sftp) => { + if (err) { reject(err); return; } + const chunks: Buffer[] = []; + const stream = sftp.createReadStream(remotePath); + stream.on('data', (d: Buffer) => chunks.push(d)); + stream.on('end', () => { sftp.end(); resolve(Buffer.concat(chunks)); }); + stream.on('error', (e: Error) => { sftp.end(); reject(e); }); + }); + }); + } + + async writeFile(remotePath: string, content: Uint8Array): Promise { + if (!this.conn) await this.connect(); + + return new Promise((resolve, reject) => { + this.conn!.sftp((err, sftp) => { + if (err) { reject(err); return; } + const stream = sftp.createWriteStream(remotePath); + stream.on('close', () => { sftp.end(); resolve(); }); + stream.on('error', (e: Error) => { sftp.end(); reject(e); }); + stream.write(Buffer.from(content)); + stream.end(); + }); + }); + } + + // ── Port Forwarding ── + async forwardGatewayPort(): Promise { + const remotePort = this.config.gatewayPort || 18789; + const localPort = await findFreePort(); + + if (!this.conn) await this.connect(); + + const server = net.createServer((localSocket) => { + this.conn!.forwardOut('127.0.0.1', localPort, '127.0.0.1', remotePort, (err, stream) => { + if (err) { localSocket.end(); return; } + localSocket.pipe(stream); + stream.pipe(localSocket); + }); + }); + + await new Promise((resolve) => server.listen(localPort, '127.0.0.1', resolve)); + this.forwardedPorts.set(localPort, server); + + return localPort; + } + + getGatewayUrl(): string { + // If we have a forwarded port, use it + for (const [port] of this.forwardedPorts) { + return `http://localhost:${port}`; + } + // Otherwise return the remote URL (may not be accessible directly) + return `http://${this.config.host}:${this.config.gatewayPort || 18789}`; + } + + // ── VS Code Integration ── + async openExplorer(p?: string): Promise { + // Delegate to the built-in Remote SSH extension + const sshHost = this.config.sshConfigHost || `${this.config.user}@${this.config.host}`; + await vscode.commands.executeCommand('openssh.openEmptyWindow', { host: sshHost }); + } + + async openTerminal(name?: string): Promise { + const sshHost = this.config.sshConfigHost || `${this.config.user}@${this.config.host}`; + const sshArgs = [sshHost]; + if (this.config.keyPath) sshArgs.unshift('-i', this.config.keyPath.replace('~', os.homedir())); + if (this.config.port && this.config.port !== 22) sshArgs.unshift('-p', String(this.config.port)); + + const terminal = vscode.window.createTerminal({ + name: name || `SSH: ${this.hostEntry.label}`, + shellPath: 'ssh', + shellArgs: sshArgs, + }); + terminal.show(); + return terminal; + } + + // ── Lifecycle ── + private setupKeepalive(): void { + // ssh2 handles keepalive via connectConfig.keepaliveInterval + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = undefined; + try { await this.connect(); } catch { /* will retry on next operation */ } + }, 5000); + } + + dispose(): void { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + for (const [, server] of this.forwardedPorts) server.close(); + this.forwardedPorts.clear(); + this.conn?.end(); + } +} +``` + +## Security Considerations + +- **Private keys**: Never copied or read into memory beyond what ssh2 needs. Path referenced, not stored in hosts.json. +- **Passwords**: Prompted via VS Code input box with `password: true`, never stored. +- **Passphrases**: Same — prompted when needed, stored in VS Code SecretStorage if user opts in. +- **Known hosts**: Verified against `~/.ssh/known_hosts`. Unknown hosts trigger a fingerprint confirmation dialog. +- **Agent forwarding**: Disabled by default. Can be enabled per-host in config. + +## Package.json + +```json +{ + "name": "openclaw-ssh", + "displayName": "OpenClaw SSH", + "description": "Manage OpenClaw installations on remote servers via SSH", + "version": "1.0.0", + "publisher": "occ", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["occ.openclaw"], + "categories": ["Other"], + "activationEvents": [ + "onCommand:occ.ssh.discover", + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { "command": "occ.ssh.discover", "title": "OCC SSH: Discover Hosts from SSH Config" }, + { "command": "occ.ssh.connect", "title": "OCC SSH: Connect to Host" }, + { "command": "occ.ssh.forwardGateway", "title": "OCC SSH: Forward Gateway Port" } + ] + }, + "dependencies": { + "ssh2": "^1.15.0" + } +} +``` diff --git a/docs/plans/multihost/07-cloud-adapter.md b/docs/plans/multihost/07-cloud-adapter.md new file mode 100644 index 00000000..517e3a76 --- /dev/null +++ b/docs/plans/multihost/07-cloud-adapter.md @@ -0,0 +1,478 @@ +# Cloud Host Adapter — `openclaw-cloud` (MoltPod) + +## Purpose + +Manage OpenClaw pods hosted on MoltPod's infrastructure via REST API. No SSH keys, no Docker CLI — just authenticate with your MoltPod account and manage pods from the desktop. + +This is the **zero-friction** adapter: sign in, see your pods, manage them. + +## Architecture + +``` +OCCode Desktop + │ + ├── openclaw-cloud extension + │ │ + │ ├── Auth: OAuth2 / API token → MoltPod + │ ├── Pods: GET /api/v1/pods → list user's pods + │ ├── Exec: POST /api/v1/pods/{id}/exec → run commands + │ ├── Files: GET/PUT /api/v1/pods/{id}/files → file operations + │ └── Gateway: GET /api/v1/pods/{id}/gateway → proxied gateway access + │ + └── MoltPod API (api.moltpod.com) + │ + ├── Pod 1 (OpenClaw instance) + ├── Pod 2 (OpenClaw instance) + └── Pod 3 (OpenClaw instance) +``` + +## MoltPod API Surface (Required) + +These endpoints need to exist on the MoltPod backend for the adapter to work: + +### Pod Management + +``` +GET /api/v1/pods # List user's pods +GET /api/v1/pods/{id} # Get pod details +POST /api/v1/pods # Create a new pod +DELETE /api/v1/pods/{id} # Delete a pod +PATCH /api/v1/pods/{id} # Update pod config (name, plan, etc.) +POST /api/v1/pods/{id}/start # Start a stopped pod +POST /api/v1/pods/{id}/stop # Stop a running pod +POST /api/v1/pods/{id}/restart # Restart a pod +``` + +### Command Execution + +``` +POST /api/v1/pods/{id}/exec # Execute a command + Body: { "command": "openclaw", "args": ["gateway", "start"], "timeout": 30 } + Response: { "exitCode": 0, "stdout": "...", "stderr": "..." } + +POST /api/v1/pods/{id}/exec/stream # WebSocket: streamed exec + → Send: { "command": "openclaw", "args": ["logs", "--follow"] } + ← Receive: { "type": "stdout", "data": "..." } / { "type": "exit", "code": 0 } +``` + +### File Operations + +``` +GET /api/v1/pods/{id}/files?path=/root/.openclaw/openclaw.json + Response: { "content": "...", "encoding": "utf-8", "size": 1234, "mtime": "..." } + +PUT /api/v1/pods/{id}/files?path=/root/.openclaw/openclaw.json + Body: { "content": "...", "encoding": "utf-8" } + +GET /api/v1/pods/{id}/files/list?path=/root/.openclaw/ + Response: { "entries": [{ "name": "openclaw.json", "type": "file", "size": 1234 }, ...] } + +GET /api/v1/pods/{id}/files/stat?path=/root/.openclaw/openclaw.json + Response: { "type": "file", "size": 1234, "mtime": "...", "ctime": "..." } + +DELETE /api/v1/pods/{id}/files?path=/root/tmp/old-file.txt + +POST /api/v1/pods/{id}/files/mkdir?path=/root/.openclaw/workspace +``` + +### Gateway Proxy + +``` +GET /api/v1/pods/{id}/gateway/status + Response: { "running": true, "version": "2026.3.19", "port": 18789, "proxyUrl": "https://pod-abc.gateway.moltpod.com" } + +# The proxy URL allows the desktop client to access the gateway dashboard +# without SSH port forwarding — MoltPod handles the tunnel +``` + +### Pod Info + +``` +GET /api/v1/pods/{id}/info + Response: { + "id": "pod-abc123", + "name": "My Agent Pod", + "status": "running", + "plan": "starter", + "region": "us-east", + "os": "Linux", + "openclawVersion": "2026.3.19", + "ip": "10.0.0.5", + "createdAt": "2026-01-15T00:00:00Z", + "resources": { + "cpu": "2 vCPU", + "memory": "4 GB", + "disk": "40 GB", + "diskUsed": "12 GB" + }, + "agents": ["main", "cody"], + "channels": ["whatsapp", "telegram"], + "gatewayUrl": "https://pod-abc.gateway.moltpod.com" + } +``` + +## Cloud Host Adapter + +```typescript +export class CloudHostAdapter implements HostAdapter { + readonly type = 'cloud'; + readonly displayName = 'MoltPod Cloud'; + readonly icon = new vscode.ThemeIcon('cloud'); + + private apiClient: MoltPodAPIClient | undefined; + + async discover(): Promise { + const client = await this.getAuthenticatedClient(); + if (!client) return []; + + const pods = await client.listPods(); + return pods.map(pod => ({ + suggestedId: `cloud-${pod.id}`, + suggestedLabel: `${pod.name} (${pod.region})`, + connection: { + type: 'cloud', + provider: 'moltpod', + podId: pod.id, + apiEndpoint: client.baseUrl, + }, + metadata: { + plan: pod.plan, + status: pod.status, + region: pod.region, + openclawVersion: pod.openclawVersion, + }, + })); + } + + async connect(config: CloudConnection): Promise { + const client = await this.getAuthenticatedClient(); + if (!client) throw new Error('Not authenticated. Please sign in to MoltPod.'); + return new CloudHostConnection(config, client, this); + } + + async testConnection(config: CloudConnection): Promise { + const client = await this.getAuthenticatedClient(); + if (!client) return { success: false, message: 'Not authenticated' }; + + try { + const info = await client.getPodInfo(config.podId); + return { + success: true, + message: `Connected to ${info.name} (${info.status})`, + details: { + openclawInstalled: true, + openclawVersion: info.openclawVersion, + gatewayRunning: info.status === 'running', + os: info.os, + }, + }; + } catch (err: any) { + return { success: false, message: err.message }; + } + } + + getConfigFields(): ConfigField[] { + return [ + { + id: 'podId', + label: 'Pod', + type: 'select', + required: true, + helpText: 'Select a MoltPod pod to manage. Use "Discover" to refresh the list.', + options: [], // Populated dynamically via discover() + }, + { + id: 'apiEndpoint', + label: 'API Endpoint', + type: 'text', + defaultValue: 'https://api.moltpod.com', + required: false, + helpText: 'Override for self-hosted MoltPod installations', + group: 'Advanced', + }, + ]; + } + + private async getAuthenticatedClient(): Promise { + if (this.apiClient) return this.apiClient; + + // Try to get stored token from VS Code SecretStorage + const token = await vscode.authentication.getSession('moltpod', ['pods:manage'], { createIfNone: false }); + if (token) { + this.apiClient = new MoltPodAPIClient(token.accessToken); + return this.apiClient; + } + + // No token — prompt user to sign in + const session = await vscode.authentication.getSession('moltpod', ['pods:manage'], { createIfNone: true }); + if (session) { + this.apiClient = new MoltPodAPIClient(session.accessToken); + return this.apiClient; + } + + return undefined; + } +} +``` + +## Cloud Host Connection + +```typescript +export class CloudHostConnection implements HostConnection { + constructor( + private config: CloudConnection, + private client: MoltPodAPIClient, + public readonly adapter: CloudHostAdapter, + ) {} + + // ── Command Execution ── + async exec(command: string, args: string[] = [], options: ExecOptions = {}): Promise { + return this.client.exec(this.config.podId, { + command, + args, + cwd: options.cwd, + env: options.env, + timeout: options.timeout ? options.timeout / 1000 : 30, + }); + } + + execStream(command: string, args: string[] = [], options: ExecOptions = {}): ExecStream { + return this.client.execStream(this.config.podId, { command, args, cwd: options.cwd }); + } + + // ── File Operations ── + async readTextFile(remotePath: string): Promise { + const response = await this.client.readFile(this.config.podId, remotePath); + return response.content; + } + + async writeTextFile(remotePath: string, content: string): Promise { + await this.client.writeFile(this.config.podId, remotePath, content); + } + + async readFile(remotePath: string): Promise { + const response = await this.client.readFile(this.config.podId, remotePath); + if (response.encoding === 'base64') { + return Buffer.from(response.content, 'base64'); + } + return Buffer.from(response.content, 'utf-8'); + } + + async writeFile(remotePath: string, content: Uint8Array): Promise { + await this.client.writeFile(this.config.podId, remotePath, Buffer.from(content).toString('base64'), 'base64'); + } + + async stat(remotePath: string): Promise { + try { + return await this.client.stat(this.config.podId, remotePath); + } catch { return null; } + } + + async readDirectory(remotePath: string): Promise { + return this.client.listFiles(this.config.podId, remotePath); + } + + async mkdir(remotePath: string): Promise { + await this.client.mkdir(this.config.podId, remotePath); + } + + async delete(remotePath: string, recursive = false): Promise { + await this.client.deleteFile(this.config.podId, remotePath, recursive); + } + + // ── Gateway ── + getGatewayUrl(): string { + // MoltPod provides a proxy URL — no port forwarding needed + return `https://pod-${this.config.podId}.gateway.moltpod.com`; + } + + async gatewayHealthCheck(): Promise { + return this.client.getGatewayStatus(this.config.podId); + } + + async gatewayStart(): Promise { return this.exec('openclaw', ['gateway', 'start']); } + async gatewayStop(): Promise { return this.exec('openclaw', ['gateway', 'stop']); } + async gatewayRestart(): Promise { return this.exec('openclaw', ['gateway', 'restart']); } + + // ── VS Code Integration ── + async openExplorer(): Promise { + // Register a FileSystemProvider for this pod + // URI scheme: occ-cloud://pod-id/path/to/file + const uri = vscode.Uri.parse(`occ-cloud://${this.config.podId}/root`); + await vscode.commands.executeCommand('vscode.openFolder', uri); + } + + async openTerminal(name?: string): Promise { + // Use a pseudo-terminal backed by WebSocket exec + const pty = new CloudPseudoTerminal(this.client, this.config.podId); + const terminal = vscode.window.createTerminal({ + name: name || `Cloud: ${this.hostEntry.label}`, + pty, + }); + terminal.show(); + return terminal; + } + + dispose(): void {} +} +``` + +## FileSystemProvider for Cloud Pods + +```typescript +// Register occ-cloud:// URI scheme for browsing pod files in the explorer +export class CloudFileSystemProvider implements vscode.FileSystemProvider { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + constructor(private client: MoltPodAPIClient) {} + + // Parse URI: occ-cloud://pod-id/path/to/file + private parse(uri: vscode.Uri): { podId: string; path: string } { + return { podId: uri.authority, path: uri.path }; + } + + async stat(uri: vscode.Uri): Promise { + const { podId, path } = this.parse(uri); + const stat = await this.client.stat(podId, path); + return { + type: stat.type === 'directory' ? vscode.FileType.Directory : vscode.FileType.File, + ctime: stat.ctime, + mtime: stat.mtime, + size: stat.size, + }; + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const { podId, path } = this.parse(uri); + const entries = await this.client.listFiles(podId, path); + return entries.map(e => [ + e.name, + e.type === 'directory' ? vscode.FileType.Directory : vscode.FileType.File + ]); + } + + async readFile(uri: vscode.Uri): Promise { + const { podId, path } = this.parse(uri); + const response = await this.client.readFile(podId, path); + return Buffer.from(response.content, response.encoding === 'base64' ? 'base64' : 'utf-8'); + } + + async writeFile(uri: vscode.Uri, content: Uint8Array): Promise { + const { podId, path } = this.parse(uri); + await this.client.writeFile(podId, path, Buffer.from(content).toString('utf-8')); + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + + async createDirectory(uri: vscode.Uri): Promise { + const { podId, path } = this.parse(uri); + await this.client.mkdir(podId, path); + } + + async delete(uri: vscode.Uri, options: { recursive: boolean }): Promise { + const { podId, path } = this.parse(uri); + await this.client.deleteFile(podId, path, options.recursive); + } + + async rename(oldUri: vscode.Uri, newUri: vscode.Uri): Promise { + const old = this.parse(oldUri); + const newP = this.parse(newUri); + await this.client.exec(old.podId, { command: 'mv', args: [old.path, newP.path] }); + } + + watch(): vscode.Disposable { + // TODO: WebSocket-based file watching + return new vscode.Disposable(() => {}); + } +} +``` + +## MoltPod Authentication Provider + +```typescript +// Register as a VS Code authentication provider so users can sign in +export class MoltPodAuthProvider implements vscode.AuthenticationProvider { + readonly id = 'moltpod'; + readonly label = 'MoltPod'; + + private _onDidChangeSessions = new vscode.EventEmitter(); + readonly onDidChangeSessions = this._onDidChangeSessions.event; + + async getSessions(): Promise { + // Read token from SecretStorage + const token = await this.secretStorage.get('moltpod-token'); + if (!token) return []; + + // Verify token is still valid + const user = await this.fetchUser(token); + if (!user) return []; + + return [{ + id: 'moltpod-session', + accessToken: token, + account: { id: user.email, label: user.name || user.email }, + scopes: ['pods:manage'], + }]; + } + + async createSession(): Promise { + // Open browser to MoltPod OAuth flow + const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse('vscode://occ.openclaw-cloud/auth/callback')); + + await vscode.env.openExternal( + vscode.Uri.parse(`https://moltpod.com/oauth/authorize?client_id=occ-desktop&redirect_uri=${encodeURIComponent(callbackUri.toString())}&scope=pods:manage`) + ); + + // Wait for callback with token + return new Promise((resolve) => { + // URI handler registered in extension activation + this.pendingAuthResolve = resolve; + }); + } + + async removeSession(): Promise { + await this.secretStorage.delete('moltpod-token'); + this._onDidChangeSessions.fire({ added: [], removed: ['moltpod-session'], changed: [] }); + } +} +``` + +## Why This Matters for MoltPod + +This adapter is the **product integration layer** between MoltPod (the hosting business) and OCCode (the desktop client). It: + +1. **Drives adoption** — users who install OCCode discover MoltPod as a one-click hosting option +2. **Reduces churn** — managing pods from the IDE is frictionless compared to SSH +3. **Enables upsell** — show resource usage, suggest plan upgrades, surface billing in the sidebar +4. **Creates lock-in** — the FileSystemProvider + pseudo-terminal make MoltPod pods feel native + +## Package.json + +```json +{ + "name": "openclaw-cloud", + "displayName": "OpenClaw Cloud (MoltPod)", + "description": "Manage MoltPod-hosted OpenClaw pods from your desktop", + "version": "1.0.0", + "publisher": "occ", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["occ.openclaw"], + "categories": ["Other"], + "activationEvents": [ + "onCommand:occ.cloud.signIn", + "onFileSystem:occ-cloud", + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "authentication": [ + { "id": "moltpod", "label": "MoltPod" } + ], + "commands": [ + { "command": "occ.cloud.signIn", "title": "OCC Cloud: Sign In to MoltPod" }, + { "command": "occ.cloud.discover", "title": "OCC Cloud: List Pods" }, + { "command": "occ.cloud.createPod", "title": "OCC Cloud: Create New Pod" } + ] + } +} +``` diff --git a/docs/plans/multihost/08-ui-design.md b/docs/plans/multihost/08-ui-design.md new file mode 100644 index 00000000..4c9bbb0a --- /dev/null +++ b/docs/plans/multihost/08-ui-design.md @@ -0,0 +1,406 @@ +# UI Design — MultiHost Experience + +## 1. Status Bar Host Picker + +The most-used UI element. Shows the active host in the VS Code status bar. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ File Edit Selection View ... │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ (editor area) │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ ● My Laptop (Local) │ main │ ▶ Gateway Running │ $4.32 │ +└─────────────────────────────────────────────────────────────────┘ + ↑ ↑ ↑ ↑ + Host picker Branch Gateway status Balance +``` + +### Clicking the Host Picker → Quick Pick + +``` +┌─────────────────────────────────────────────────────────┐ +│ 🔍 Select OpenClaw Host │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ Active │ +│ ────── │ +│ 🖥️ My Laptop Local ● Online │ +│ │ +│ Available │ +│ ───────── │ +│ 🐳 MoltPod Dev Docker ● Online │ +│ 🌐 Production VPS SSH ● Online │ +│ ☁️ Staging Pod Cloud ○ Offline │ +│ │ +│ ────────────────────────────────────────────── │ +│ ➕ Add New Host... │ +│ 🔄 Refresh All │ +│ ⚙️ Manage Hosts... │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Implementation + +```typescript +export class HostStatusBarItem { + private item: vscode.StatusBarItem; + + constructor( + private hostManager: HostManager, + private registry: HostRegistry, + ) { + this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + this.item.command = 'occ.hosts.pick'; + this.update(); + + hostManager.onDidChangeActiveHost(() => this.update()); + hostManager.onDidChangeHostStatus(() => this.update()); + } + + private update(): void { + const host = this.hostManager.getActiveConnection(); + if (!host) { + this.item.text = '$(server) No Host'; + this.item.tooltip = 'Click to select an OpenClaw host'; + this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + } else { + const icon = this.getTypeIcon(host.hostEntry.type); + const status = host.status === 'connected' ? '●' : '○'; + this.item.text = `${icon} ${host.hostEntry.label}`; + this.item.tooltip = `${host.hostEntry.label} (${host.hostEntry.type}) — ${host.status}`; + this.item.backgroundColor = host.status === 'error' + ? new vscode.ThemeColor('statusBarItem.errorBackground') + : undefined; + } + this.item.show(); + } + + private getTypeIcon(type: string): string { + switch (type) { + case 'local': return '$(device-desktop)'; + case 'docker': return '$(package)'; + case 'ssh': return '$(remote)'; + case 'cloud': return '$(cloud)'; + default: return '$(server)'; + } + } +} +``` + +## 2. Sidebar Tree View — Hosts Panel + +A dedicated sidebar section showing all registered hosts with real-time status. + +``` +┌───────────────────────────────────┐ +│ OPENCLAW HOSTS 🔄 │ +├───────────────────────────────────┤ +│ │ +│ ▼ 🖥️ My Laptop ● ✓ │ +│ ├── Gateway: Running v2026.3 │ +│ ├── Agents: main, cody │ +│ ├── Channels: 3 connected │ +│ └── ⚡ Open Terminal │ +│ │ +│ ▶ 🐳 MoltPod Dev ● │ +│ │ +│ ▶ 🌐 Production VPS ● │ +│ │ +│ ▶ ☁️ Staging Pod ○ │ +│ └── ⚠️ Pod stopped │ +│ │ +│ ───────────────────────── │ +│ ➕ Add Host │ +│ │ +└───────────────────────────────────┘ +``` + +### Context Menus + +Right-click a host: +``` +┌─────────────────────────────┐ +│ Set as Active │ +│ ───────────── │ +│ Open Explorer │ +│ Open Terminal │ +│ Open Gateway Dashboard │ +│ ───────────── │ +│ Start Gateway │ +│ Stop Gateway │ +│ Restart Gateway │ +│ ───────────── │ +│ Edit Host │ +│ Refresh Status │ +│ ───────────── │ +│ Remove Host │ +└─────────────────────────────┘ +``` + +### Implementation + +```typescript +export class HostTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private registry: HostRegistry, + private hostManager: HostManager, + ) { + registry.onDidChange(() => this.refresh()); + hostManager.onDidChangeActiveHost(() => this.refresh()); + hostManager.onDidChangeHostStatus(() => this.refresh()); + } + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: HostTreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: HostTreeItem): Promise { + if (!element) { + // Root level — show all hosts + const hosts = this.registry.getAllHosts(); + const activeId = this.hostManager.getActiveConnection()?.hostId; + + return hosts.map(host => { + const isActive = host.id === activeId; + const cache = this.readCache(host.id); + + return new HostTreeItem( + host, + isActive, + cache, + vscode.TreeItemCollapsibleState.Collapsed, + ); + }); + } + + // Child level — show host details + const cache = this.readCache(element.hostEntry.id); + if (!cache) return []; + + const children: HostTreeItem[] = []; + + if (cache.gateway) { + children.push(new HostDetailItem( + `Gateway: ${cache.gateway.running ? `Running v${cache.gateway.version}` : 'Stopped'}`, + cache.gateway.running ? 'pass' : 'error', + )); + } + + if (cache.agents) { + children.push(new HostDetailItem( + `Agents: ${cache.agents.names.join(', ')}`, + 'info', + )); + } + + if (cache.channels) { + children.push(new HostDetailItem( + `Channels: ${cache.channels.connected.length} connected`, + cache.channels.connected.length > 0 ? 'pass' : 'warning', + )); + } + + // Quick action items + children.push(new HostActionItem('Open Terminal', 'occ.hosts.openTerminal', element.hostEntry.id)); + children.push(new HostActionItem('Open Explorer', 'occ.hosts.openExplorer', element.hostEntry.id)); + + return children; + } +} +``` + +## 3. Add Host Wizard + +A multi-step Quick Input wizard that guides users through adding a new host. + +### Step 1: Choose Host Type + +``` +┌─────────────────────────────────────────────────────────┐ +│ 🔍 What kind of host? │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ 🖥️ Local Machine │ +│ OpenClaw on this computer │ +│ │ +│ 🐳 Docker Container │ +│ OpenClaw running in a Docker container │ +│ │ +│ 🌐 SSH Remote │ +│ OpenClaw on a remote server via SSH │ +│ │ +│ ☁️ MoltPod Cloud │ +│ Managed OpenClaw pod on MoltPod │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Step 2: Discovery (optional) + +``` +┌─────────────────────────────────────────────────────────┐ +│ 🔍 Found 3 Docker containers with OpenClaw │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ 🐳 openclaw-pod-1 (openclaw/pod:latest) ● Running │ +│ 🐳 openclaw-dev (openclaw/pod:dev) ● Running │ +│ 🐳 test-pod (ubuntu:22.04) ● Running │ +│ │ +│ ───────────────────────────────────────── │ +│ ✏️ Configure manually... │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Step 3: Configuration Fields + +Dynamic form based on adapter's `getConfigFields()`: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Configure SSH Host Step 3 of 4 │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ Label: [Production VPS________________] │ +│ Hostname: [45.76.229.198_________________] │ +│ Port: [22___] │ +│ Username: [debian________________________] │ +│ Auth: [SSH Key ▼] │ +│ Key Path: [~/.ssh/id_ed25519_____________] │ +│ │ +│ ▶ Advanced │ +│ │ +│ [Test Connection] [Back] [Add Host] │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Step 4: Test & Confirm + +``` +┌─────────────────────────────────────────────────────────┐ +│ ✅ Connection Successful │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ Host: 45.76.229.198 │ +│ OS: Linux (Debian 13) │ +│ OpenClaw: v2026.3.19 │ +│ Gateway: Running on port 18789 │ +│ Agents: main, cody │ +│ │ +│ [Add Host and Set Active] [Add Host] [Cancel] │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +## 4. Control Center — Multi-Host Dashboard + +When no specific host is selected, the control center shows an overview of all hosts: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ● Control Center │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ +│ │ 4 Hosts │ │ 3 Online │ │ 7 Agents │ │ 12 Channels│ │ +│ │ │ │ │ │ │ │ │ │ +│ └──────────┘ └──────────┘ └──────────┘ └────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Host Type Status Agents GW │ │ +│ ├─────────────────────────────────────────────────────┤ │ +│ │ My Laptop Local Online 2 ▶ │ │ +│ │ MoltPod Dev Docker Online 1 ▶ │ │ +│ │ Production VPS SSH Online 3 ▶ │ │ +│ │ Staging Pod Cloud Offline 1 ■ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Recent Activity │ +│ ─────────────── │ +│ 10:15 Production VPS Gateway restarted │ +│ 10:02 My Laptop Agent "cody" heartbeat │ +│ 09:45 MoltPod Dev Channel WhatsApp connected │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 5. Visual Differentiation + +Each host type gets a unique visual treatment so users always know which host they're working with: + +### Color Coding (optional per-host) + +```typescript +const hostColors: Record = { + local: '#22c55e', // Green — home base + docker: '#3b82f6', // Blue — containers + ssh: '#f59e0b', // Amber — remote + cloud: '#8b5cf6', // Purple — managed +}; +``` + +### Status Bar Color Changes + +When connected to a non-local host, the status bar background can change to indicate remote context (similar to VS Code Remote): + +```typescript +// Set the remote name indicator (bottom-left corner) +function updateRemoteIndicator(host: HostEntry): void { + if (host.type !== 'local') { + vscode.commands.executeCommand('setContext', 'occ.remoteHost', host.label); + // The remote indicator shows in the bottom-left: + // [🌐 Production VPS] ← amber background + } +} +``` + +## 6. Notifications & Alerts + +### Host Goes Offline + +``` +┌────────────────────────────────────────────┐ +│ ⚠️ Production VPS is offline │ +│ │ +│ The SSH connection to 45.76.229.198 was │ +│ lost. Last seen 2 minutes ago. │ +│ │ +│ [Reconnect] [Switch Host] [Dismiss] │ +└────────────────────────────────────────────┘ +``` + +### Gateway Crash on Active Host + +``` +┌────────────────────────────────────────────┐ +│ 🔴 Gateway stopped on My Laptop │ +│ │ +│ The OpenClaw gateway is no longer running. │ +│ Agents and channels are offline. │ +│ │ +│ [Restart Gateway] [View Logs] [Dismiss] │ +└────────────────────────────────────────────┘ +``` + +### New Host Discovered + +``` +┌────────────────────────────────────────────┐ +│ 🐳 New Docker container detected │ +│ │ +│ "openclaw-pod-2" has OpenClaw installed. │ +│ │ +│ [Add as Host] [Ignore] │ +└────────────────────────────────────────────┘ +``` diff --git a/docs/plans/multihost/09-migration.md b/docs/plans/multihost/09-migration.md new file mode 100644 index 00000000..5eabe484 --- /dev/null +++ b/docs/plans/multihost/09-migration.md @@ -0,0 +1,187 @@ +# Migration Path — Single-Host to MultiHost + +## Guiding Principle + +**Zero disruption for existing users.** The first release with MultiHost should feel identical to today for anyone who only uses local OpenClaw. The multi-host features are additive. + +## Migration Phases + +### Phase 0: Current State (Pre-Migration) + +``` +~/.openclaw/ +├── openclaw.json ← Runtime config (gateway owns this) +├── workspace/ ← Agent workspace +└── agents/ ← Agent sessions + +~/.occ/ ← Does not exist +``` + +The OpenClaw extension reads `~/.openclaw/openclaw.json` directly via `fs.readFileSync()`. + +### Phase 1: Introduce `~/.occ/` (Transparent) + +On first launch after the MultiHost update: + +1. **Create `~/.occ/`** directory +2. **Seed `hosts.json`** with a single local-default entry: + ```json + { + "version": 1, + "activeHostId": "local-default", + "hosts": [{ + "id": "local-default", + "type": "local", + "label": "", + "connection": { "type": "local" }, + "default": true, + "createdAt": "" + }] + } + ``` +3. **All existing code paths continue to work** — the local adapter wraps the same `fs.readFileSync` and `cp.spawn` calls + +**User impact: None.** They don't see `~/.occ/` unless they look for it. The UI is identical — no host picker appears until they have 2+ hosts. + +### Phase 2: Show Host Picker (Conditional) + +The status bar host picker only appears when: +- User has 2+ hosts registered, OR +- User explicitly runs "OCC: Add Host", OR +- Docker containers with OpenClaw are detected (if openclaw-docker is installed) + +```typescript +function shouldShowHostPicker(): boolean { + const hosts = registry.getAllHosts(); + return hosts.length > 1; +} + +// Status bar item: +if (shouldShowHostPicker()) { + hostPickerItem.show(); +} else { + hostPickerItem.hide(); +} +``` + +### Phase 3: Sidebar Tree (Optional) + +The Hosts sidebar panel is registered but hidden by default. It appears when: +- User has 2+ hosts, OR +- User manually enables it via View → Sidebar + +```json +// package.json contribution +{ + "views": { + "occ-hosts": [{ + "id": "occ.hostsView", + "name": "Hosts", + "when": "occ.multiHostEnabled" + }] + } +} +``` + +```typescript +// Set context when multi-host is active +vscode.commands.executeCommand('setContext', 'occ.multiHostEnabled', hosts.length > 1); +``` + +## Breaking Change Avoidance + +### Config File Locations + +| File | Owner | Migration | +|---|---|---| +| `~/.openclaw/openclaw.json` | OpenClaw runtime | **Untouched.** Adapters read this via `HostConnection.readConfig()` | +| `~/.occ/hosts.json` | OCCode client | **New file.** Created on first launch. | +| `~/.occ/settings.json` | OCCode client | **New file.** Created when user changes app settings. | + +### Extension Settings + +Existing VS Code settings (if any) continue to work. New settings use the `occ.` prefix: + +```json +{ + "occ.hosts.showPicker": true, + "occ.hosts.healthCheckInterval": 30000, + "occ.hosts.showOfflineHosts": true +} +``` + +### Command IDs + +All existing commands keep their IDs. New commands use the `occ.hosts.` prefix: + +``` +// Existing (unchanged) +openclaw.openHome +openclaw.openWorkspace +openclaw.openConfig +openclaw.status + +// New +occ.hosts.pick +occ.hosts.add +occ.hosts.remove +occ.hosts.openTerminal +occ.hosts.openExplorer +``` + +## Data Recovery + +If `~/.occ/hosts.json` gets corrupted or deleted: +1. On next launch, the registry detects the missing file +2. Re-creates it with the local-default entry +3. Other hosts are lost but can be re-added manually +4. A warning notification is shown: "Host registry was reset. Your local host is still available." + +If `~/.occ/` is entirely missing (fresh install, wiped, etc.): +1. Same as above — seed with local-default +2. No data loss because `~/.openclaw/` (the runtime data) is untouched + +## Version Compatibility + +The `hosts.json` includes a `version` field. If the schema changes: + +```typescript +function migrateHostsFile(data: any): HostsFile { + if (data.version === 1) { + // Current version — no migration needed + return data; + } + + // Future: handle v1 → v2 migration + // if (data.version === 1) { return migrateV1ToV2(data); } + + throw new Error(`Unknown hosts.json version: ${data.version}`); +} +``` + +## Rollback + +If a user wants to disable MultiHost and go back to single-host behavior: + +1. Delete `~/.occ/` directory +2. The extension falls back to direct local access (same as pre-MultiHost) +3. Or: keep `~/.occ/hosts.json` with only the local-default entry — functionally identical to single-host + +## Testing the Migration + +```bash +# Simulate fresh install +rm -rf ~/.occ/ +# Launch OCCode → should auto-create ~/.occ/ with local-default +# Verify: no visible change in UI + +# Simulate existing user +echo '{"version":1,"activeHostId":"local-default","hosts":[...]}' > ~/.occ/hosts.json +# Launch OCCode → should read existing hosts.json +# Verify: hosts appear in picker if > 1 + +# Simulate corrupted file +echo 'garbage' > ~/.occ/hosts.json +# Launch OCCode → should show warning and recreate +# Verify: local-default is available, other hosts lost +``` diff --git a/docs/plans/multihost/10-build-order.md b/docs/plans/multihost/10-build-order.md new file mode 100644 index 00000000..6d5b70ca --- /dev/null +++ b/docs/plans/multihost/10-build-order.md @@ -0,0 +1,210 @@ +# Build Order — Phased Implementation + +## Overview + +``` +Phase 1 ──► Phase 2 ──► Phase 3 ──► Phase 4 ──► Phase 5 +Foundation Local Docker SSH Cloud +(2 weeks) (1 week) (2 weeks) (2 weeks) (3 weeks) +``` + +Total estimated: **10–12 weeks** for the full stack. Each phase is independently shippable. + +--- + +## Phase 1: Foundation (2 weeks) + +### Goal +Extract the `HostConnection` abstraction, create `~/.occ/` infrastructure, and refactor the core extension without changing any user-facing behavior. + +### Tasks + +| # | Task | Est | Risk | +|---|---|---|---| +| 1.1 | Define TypeScript interfaces (`HostAdapter`, `HostConnection`, `HostEntry`) in `src/hosts/types.ts` | 1d | Low | +| 1.2 | Implement `HostRegistry` class — read/write `~/.occ/hosts.json`, file watching, auto-seed local-default | 2d | Low | +| 1.3 | Implement `HostManager` class — adapter registration, active host state, connection lifecycle | 2d | Medium | +| 1.4 | Implement `OpenClawCoreAPI` exports — the extension-to-extension API surface | 1d | Low | +| 1.5 | Create `HostStatusBarItem` — status bar host picker (hidden when only 1 host) | 1d | Low | +| 1.6 | Create `HostTreeProvider` — sidebar tree view (hidden when only 1 host) | 2d | Low | +| 1.7 | Audit all `cp.spawn()` and `fs.readFileSync()` calls in `home.ts`, `config.ts`, `setup.ts`, `extension.ts` — document every callsite that needs abstraction | 1d | Low | + +### Deliverable +- `~/.occ/` directory created on first launch +- `hosts.json` seeded with local-default +- Interfaces defined and exported +- Host picker and tree view wired up (but hidden for single-host users) +- No behavior change for existing users + +### Exit Criteria +- `npm test` passes with existing tests +- Manual test: fresh install → `~/.occ/hosts.json` created → no UI change +- API types compile and are importable by external extensions + +--- + +## Phase 2: Local Adapter (1 week) + +### Goal +Extract all local filesystem and process operations from `home.ts` into `LocalHostConnection`, register it as the first adapter. + +### Tasks + +| # | Task | Est | Risk | +|---|---|---|---| +| 2.1 | Implement `LocalHostAdapter` (discover, connect, testConnection, configFields) | 1d | Low | +| 2.2 | Implement `LocalHostConnection` (exec, readFile, writeFile, stat, etc.) | 2d | Medium | +| 2.3 | Refactor `home.ts` — replace ~40 direct `cp.spawn()` calls with `activeHost.exec()` | 2d | High | +| 2.4 | Refactor `config.ts` and `setup.ts` — replace `fs.readFileSync()` with `activeHost.readTextFile()` | 1d | Medium | +| 2.5 | Refactor `extension.ts` — `isWebServerReachable()` uses `activeHost.gatewayHealthCheck()` | 0.5d | Low | +| 2.6 | Regression testing — every existing feature still works through the abstraction | 1d | Medium | + +### Deliverable +- All local operations go through `LocalHostConnection` +- The local adapter auto-registers on startup +- `home.ts` no longer directly calls `cp.spawn()` or `fs.readFileSync()` for host operations +- **Behavior is identical** — this is a pure refactor + +### Exit Criteria +- Install wizard works +- Gateway start/stop/restart works +- Config editing works +- Status panel works +- Balance bar works + +### Risk: `home.ts` Refactor + +`home.ts` is 4,502 lines with deeply interleaved UI + host logic. The refactor must be surgical: + +1. **Don't rewrite** — extract, don't restructure +2. **One callsite at a time** — replace `cp.spawn(...)` → `host.exec(...)` incrementally +3. **Keep the webview HTML** — don't refactor the webview at the same time +4. **Test each change** — the install flow, gateway management, and config editing are critical paths + +--- + +## Phase 3: Docker Adapter (2 weeks) + +### Goal +Ship `openclaw-docker` as a separate extension that manages OpenClaw in Docker containers. + +### Tasks + +| # | Task | Est | Risk | +|---|---|---|---| +| 3.1 | Scaffold `openclaw-docker` extension (package.json, activation, registration) | 0.5d | Low | +| 3.2 | Implement `DockerHostAdapter` — container discovery, connect, test | 2d | Medium | +| 3.3 | Implement `DockerHostConnection` — exec via `docker exec`, file ops via `docker cp` | 3d | Medium | +| 3.4 | Docker Compose support — `docker compose exec` routing | 1d | Low | +| 3.5 | Port mapping auto-detection — `docker port` parsing | 0.5d | Low | +| 3.6 | Add Host Wizard — Docker-specific discovery step and config fields | 1d | Low | +| 3.7 | Integration testing — real Docker containers with OpenClaw installed | 2d | High | + +### Deliverable +- `openclaw-docker` extension installable from marketplace (or bundled) +- Users can discover and add Docker containers as hosts +- Gateway management works through Docker +- Terminal opens into container +- Explorer can attach to container (via Dev Containers if installed) + +### Exit Criteria +- `docker run openclaw/pod` → discovered in OCCode → add as host → manage gateway +- Docker Compose service → same flow +- File read/write through container works +- Gateway health check through port mapping works + +--- + +## Phase 4: SSH Adapter (2 weeks) + +### Goal +Ship `openclaw-ssh` as a separate extension for managing remote OpenClaw instances. + +### Tasks + +| # | Task | Est | Risk | +|---|---|---|---| +| 4.1 | Scaffold `openclaw-ssh` extension | 0.5d | Low | +| 4.2 | Implement `SSHHostAdapter` — SSH config parsing, discovery, connect | 2d | Medium | +| 4.3 | Implement `SSHHostConnection` — exec over SSH, SFTP file operations | 3d | Medium | +| 4.4 | Port forwarding — tunnel gateway port to localhost | 1d | Medium | +| 4.5 | Authentication flows — key, agent, password, passphrase prompting | 2d | High | +| 4.6 | Integration with `open-remote-ssh` — delegate "Open Explorer" | 0.5d | Low | +| 4.7 | Reconnection logic — handle dropped connections gracefully | 1d | Medium | +| 4.8 | Jump host / bastion support | 0.5d | Medium | +| 4.9 | Integration testing — real SSH connections to test VPS | 1d | High | + +### Deliverable +- `openclaw-ssh` extension installable +- Users can add SSH hosts manually or discover from `~/.ssh/config` +- All host operations work over SSH +- Gateway dashboard accessible via port forwarding +- "Open Explorer" triggers VS Code Remote SSH window + +### Exit Criteria +- Add VPS via SSH → manage gateway → start/stop agents +- File editing via SFTP +- Port forwarding for gateway dashboard +- Survives network disconnection → reconnects + +--- + +## Phase 5: Cloud Adapter — MoltPod (3 weeks) + +### Goal +Ship `openclaw-cloud` with full MoltPod integration. **Requires MoltPod API endpoints to be built first.** + +### Dependencies +- MoltPod API: `/api/v1/pods/*` endpoints (exec, files, gateway proxy) +- MoltPod OAuth: authorization flow for desktop clients +- MoltPod gateway proxy: `pod-{id}.gateway.moltpod.com` routing + +### Tasks + +| # | Task | Est | Risk | +|---|---|---|---| +| 5.1 | Scaffold `openclaw-cloud` extension | 0.5d | Low | +| 5.2 | MoltPod API client library | 2d | Medium | +| 5.3 | MoltPod `AuthenticationProvider` — OAuth flow | 2d | High | +| 5.4 | Implement `CloudHostAdapter` — pod discovery, connect | 1d | Low | +| 5.5 | Implement `CloudHostConnection` — exec via API, file ops via API | 3d | Medium | +| 5.6 | `FileSystemProvider` for `occ-cloud://` — browse pod files in explorer | 2d | Medium | +| 5.7 | `PseudoTerminal` — WebSocket-backed terminal to pod | 2d | High | +| 5.8 | Gateway proxy integration — dashboard via MoltPod proxy URL | 1d | Low | +| 5.9 | Pod management UI — create, start, stop, delete pods from sidebar | 2d | Medium | +| 5.10 | Integration testing with live MoltPod pods | 1d | High | + +### Deliverable +- `openclaw-cloud` extension installable +- Sign in to MoltPod → see all pods → manage them +- File browsing in explorer without SSH +- Terminal to pods without SSH +- Pod lifecycle management (create, start, stop) + +--- + +## Phase 6: Polish & Documentation (1 week) + +| # | Task | Est | +|---|---|---| +| 6.1 | End-to-end testing across all 4 host types | 2d | +| 6.2 | Error message review — every error should tell the user what to do | 1d | +| 6.3 | Documentation — README, CONTRIBUTING, API docs for adapter authors | 2d | +| 6.4 | Marketplace listings — screenshots, descriptions for each extension | 0.5d | +| 6.5 | Performance profiling — ensure health check polling doesn't drain battery | 0.5d | + +--- + +## Summary Timeline + +``` +Week 1-2: Phase 1 — Foundation (interfaces, registry, manager) +Week 3: Phase 2 — Local adapter (refactor home.ts) +Week 4-5: Phase 3 — Docker adapter +Week 6-7: Phase 4 — SSH adapter +Week 8-10: Phase 5 — Cloud adapter (requires MoltPod API) +Week 11: Phase 6 — Polish & documentation +``` + +**Phases 1-4 can proceed independently of MoltPod API development.** +**Phase 5 can be parallelized** — the extension scaffold and API client can be built while MoltPod API endpoints are being developed. diff --git a/docs/plans/multihost/11-security.md b/docs/plans/multihost/11-security.md new file mode 100644 index 00000000..dcbeb946 --- /dev/null +++ b/docs/plans/multihost/11-security.md @@ -0,0 +1,297 @@ +# Security — Credential & Access Management + +## Threat Model + +The MultiHost feature introduces new attack surfaces: + +| Threat | Vector | Impact | Mitigation | +|---|---|---|---| +| Credential theft | `~/.occ/hosts.json` contains hostnames/IPs | Reconnaissance | File permissions `0600`, no passwords in JSON | +| SSH key exposure | Adapter reads private keys | Full host compromise | Never store key content — only paths. Keys stay in `~/.ssh/` | +| API token leak | MoltPod token in memory | Account takeover | Use VS Code `SecretStorage` (encrypted at rest) | +| MITM on SSH | First-connect trust | Command injection | Verify host fingerprint against `~/.ssh/known_hosts` | +| Container escape | Docker exec runs as root | Host compromise | Document risks, recommend rootless Docker | +| Malicious adapter | Third-party extension registers adapter | Arbitrary exec | Require `occ.openclaw` dependency, adapter sandboxing | +| Gateway proxy hijack | Forwarded port exposed | Data interception | Bind to `127.0.0.1` only, never `0.0.0.0` | + +## Credential Storage Rules + +### What Goes Where + +| Credential | Storage | Why | +|---|---|---| +| SSH key **path** | `~/.occ/hosts.json` | Just a reference, not the key itself | +| SSH key **content** | `~/.ssh/` (user's own) | Never copied elsewhere | +| SSH passphrase | VS Code `SecretStorage` | Encrypted at rest by VS Code, optional "remember" | +| SSH password | **Never stored** | Prompted every time (password auth discouraged) | +| MoltPod API token | VS Code `SecretStorage` | Encrypted, managed via `AuthenticationProvider` | +| OCC account JWT | VS Code `SecretStorage` | Already handled by `occAuthProvider.ts` | +| Docker credentials | Docker's own config | We never handle Docker auth directly | + +### `SecretStorage` Usage + +```typescript +// Store a secret +await context.secrets.store('occ.ssh.passphrase.ssh-vps-prod', passphrase); + +// Retrieve a secret +const passphrase = await context.secrets.get('occ.ssh.passphrase.ssh-vps-prod'); + +// Delete a secret +await context.secrets.delete('occ.ssh.passphrase.ssh-vps-prod'); + +// Key naming convention: +// occ.{adapter}.{type}.{hostId} +// Examples: +// occ.ssh.passphrase.ssh-my-vps +// occ.cloud.token.cloud-pod-abc +``` + +### What Never Leaves the Machine + +- SSH private keys — read from disk into `ssh2` library's memory, never transmitted to any server +- Passphrases — prompted via VS Code input, optionally cached in `SecretStorage`, never logged +- MoltPod tokens — stored encrypted, sent only to `api.moltpod.com` over HTTPS + +## SSH Security + +### Host Key Verification + +```typescript +async function verifyHostKey(host: string, key: Buffer, fingerprint: string): Promise { + // Check known_hosts first + const knownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts'); + if (isKnownHost(knownHostsPath, host, fingerprint)) { + return true; + } + + // Unknown host — prompt user + const choice = await vscode.window.showWarningMessage( + `The authenticity of host '${host}' can't be established.\n` + + `Key fingerprint is ${fingerprint}.\n` + + `Are you sure you want to continue connecting?`, + { modal: true }, + 'Yes, add to known_hosts', + 'Yes, just this once', + 'No' + ); + + if (choice === 'Yes, add to known_hosts') { + appendToKnownHosts(knownHostsPath, host, key); + return true; + } + return choice === 'Yes, just this once'; +} +``` + +### Key Authentication Order + +```typescript +// Try authentication methods in order of security: +const authMethods = [ + // 1. SSH Agent (most secure — keys never leave the agent) + tryAgentAuth, + // 2. Key file (secure if key has passphrase) + tryKeyAuth, + // 3. Password (least secure — discouraged) + tryPasswordAuth, +]; +``` + +### Agent Forwarding + +**Disabled by default.** Can be enabled per-host: + +```json +{ + "id": "ssh-my-vps", + "connection": { + "type": "ssh", + "host": "example.com", + "agentForward": true // ← explicit opt-in + } +} +``` + +Warning when enabled: +``` +⚠️ Agent forwarding is enabled for this host. This allows the remote server +to use your local SSH keys. Only enable this for trusted servers. +``` + +## Docker Security + +### Container Exec Permissions + +`docker exec` runs commands as the container's default user (often root). Document this: + +``` +⚠️ Commands run inside the container as the container's default user. +If the container runs as root, all file operations have root access +inside the container. +``` + +### Docker Socket Access + +The adapter accesses Docker via the local socket (`/var/run/docker.sock`). This is equivalent to root access on the host machine. The adapter: + +- Never exposes the Docker socket to remote connections +- Only executes commands the user explicitly triggers +- Never modifies container configuration (only `exec` and `cp`) + +### Remote Docker Hosts + +When `dockerHost` is set to a remote Docker daemon (`tcp://` or `ssh://`): + +```typescript +if (config.dockerHost && config.dockerHost.startsWith('tcp://')) { + const useTLS = config.dockerHost.includes(':2376'); + if (!useTLS) { + vscode.window.showWarningMessage( + 'Connecting to a remote Docker daemon over unencrypted TCP. ' + + 'This is insecure. Consider using ssh:// or enabling TLS.' + ); + } +} +``` + +## Cloud (MoltPod) Security + +### OAuth Flow + +``` +1. User clicks "Sign in to MoltPod" +2. OCCode opens browser: https://moltpod.com/oauth/authorize?... +3. User authenticates on moltpod.com (we never see the password) +4. MoltPod redirects to vscode://occ.openclaw-cloud/auth/callback?code=... +5. Extension exchanges code for access token +6. Token stored in SecretStorage +``` + +### API Token Scope + +Request minimal scopes: +- `pods:read` — list and view pods +- `pods:manage` — start, stop, exec, file access +- NOT `pods:delete` — require explicit re-auth for destructive actions +- NOT `billing:*` — billing managed via web dashboard + +### Token Refresh + +```typescript +class MoltPodAPIClient { + private async request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + + if (response.status === 401) { + // Token expired — trigger re-auth + await this.refreshToken(); + // Retry once + return fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + } + + return response; + } +} +``` + +## File Permission Hardening + +```typescript +// On first launch and after any write to ~/.occ/ +function hardenPermissions(): void { + const occDir = path.join(os.homedir(), '.occ'); + + if (process.platform !== 'win32') { + // Directory: owner-only + fs.chmodSync(occDir, 0o700); + + // hosts.json: owner-only (contains hostnames/IPs) + const hostsFile = path.join(occDir, 'hosts.json'); + if (fs.existsSync(hostsFile)) { + fs.chmodSync(hostsFile, 0o600); + } + + // Cache files: owner-only (may contain agent names, channel info) + const hostsDir = path.join(occDir, 'hosts'); + if (fs.existsSync(hostsDir)) { + for (const dir of fs.readdirSync(hostsDir)) { + const cachePath = path.join(hostsDir, dir, 'cache.json'); + if (fs.existsSync(cachePath)) { + fs.chmodSync(cachePath, 0o600); + } + } + } + } +} +``` + +## Adapter Trust Model + +Third-party extensions can register as host adapters. Security controls: + +1. **Extension dependency required** — must declare `"extensionDependencies": ["occ.openclaw"]` +2. **User consent** — VS Code shows a trust dialog when an extension wants to register as an adapter +3. **Sandboxed registration** — adapters can only: + - Register themselves as a host type + - Respond to connect/discover/test calls + - They cannot access other adapters' connections or the host registry directly +4. **Audit logging** — all adapter registrations logged to `~/.occ/logs/` + +```typescript +registerHostAdapter(adapter: HostAdapter): vscode.Disposable { + // Log the registration + this.log(`Adapter registered: ${adapter.type} (${adapter.displayName}) from extension ${callerId}`); + + // Validate adapter implements the full interface + if (!adapter.type || !adapter.connect || !adapter.discover) { + throw new Error('Invalid adapter: missing required methods'); + } + + // Prevent duplicate type registration + if (this.adapters.has(adapter.type)) { + throw new Error(`Adapter type "${adapter.type}" is already registered`); + } + + this.adapters.set(adapter.type, adapter); + return new vscode.Disposable(() => this.adapters.delete(adapter.type)); +} +``` + +## Logging & Audit + +### What Gets Logged + +``` +~/.occ/logs/occ-client.log +``` + +``` +[2026-03-19T10:15:00Z] Host connected: ssh-vps-prod (SSH) +[2026-03-19T10:15:01Z] Gateway health check: running (v2026.3.19) +[2026-03-19T10:20:00Z] Command executed: openclaw gateway restart (exit: 0) +[2026-03-19T10:25:00Z] Host disconnected: ssh-vps-prod (connection lost) +[2026-03-19T10:25:05Z] Reconnect attempt: ssh-vps-prod +[2026-03-19T10:25:06Z] Host connected: ssh-vps-prod (SSH) +``` + +### What Never Gets Logged + +- Passwords, passphrases, or tokens +- File contents +- Command output (only exit codes) +- SSH key paths or fingerprints diff --git a/docs/plans/multihost/12-testing.md b/docs/plans/multihost/12-testing.md new file mode 100644 index 00000000..4bd9fcef --- /dev/null +++ b/docs/plans/multihost/12-testing.md @@ -0,0 +1,342 @@ +# Testing Strategy + +## Test Pyramid + +``` + ╱ ╲ + ╱ E2E ╲ 2-3 per adapter (real hosts) + ╱───────╲ + ╱ Integr. ╲ 5-10 per adapter (mock hosts) + ╱───────────╲ + ╱ Unit Tests ╲ 20-30 per module + ╱─────────────────╲ +``` + +## Unit Tests + +### Core Extension + +```typescript +// tests/hosts/registry.test.ts +describe('HostRegistry', () => { + it('creates ~/.occ/ and seeds hosts.json on first launch', () => { ... }); + it('reads existing hosts.json', () => { ... }); + it('adds a host and saves', () => { ... }); + it('removes a host and saves', () => { ... }); + it('handles corrupted hosts.json gracefully', () => { ... }); + it('handles missing ~/.occ/ directory', () => { ... }); + it('sets file permissions to 0600', () => { ... }); + it('fires onDidChange when hosts.json changes externally', () => { ... }); + it('migrates from v1 to v2 schema', () => { ... }); +}); + +// tests/hosts/manager.test.ts +describe('HostManager', () => { + it('registers an adapter', () => { ... }); + it('prevents duplicate adapter registration', () => { ... }); + it('connects to active host on startup', () => { ... }); + it('switches active host', () => { ... }); + it('disconnects previous host when switching', () => { ... }); + it('fires onDidChangeActiveHost', () => { ... }); + it('prompts to install adapter for unknown host type', () => { ... }); + it('handles adapter connection failure', () => { ... }); + it('returns undefined when no host is active', () => { ... }); +}); + +// tests/hosts/cache.test.ts +describe('HostCache', () => { + it('reads cache from disk', () => { ... }); + it('writes cache to disk', () => { ... }); + it('returns empty state when cache missing', () => { ... }); + it('handles corrupted cache file', () => { ... }); +}); +``` + +### Local Adapter + +```typescript +// tests/local/adapter.test.ts +describe('LocalHostAdapter', () => { + it('discovers local OpenClaw installation', () => { ... }); + it('returns empty when OpenClaw not installed', () => { ... }); + it('test connection succeeds with OpenClaw installed', () => { ... }); + it('test connection reports gateway status', () => { ... }); + it('returns minimal config fields', () => { ... }); +}); + +// tests/local/connection.test.ts +describe('LocalHostConnection', () => { + it('exec runs command via child_process', () => { ... }); + it('exec handles timeout', () => { ... }); + it('exec captures stdout and stderr', () => { ... }); + it('readTextFile reads from local filesystem', () => { ... }); + it('writeTextFile creates parent directories', () => { ... }); + it('stat returns file info', () => { ... }); + it('stat returns null for missing file', () => { ... }); + it('readDirectory lists directory contents', () => { ... }); + it('readConfig parses openclaw.json', () => { ... }); + it('getGatewayUrl returns localhost with configured port', () => { ... }); + it('gatewayHealthCheck detects running gateway', () => { ... }); + it('gatewayHealthCheck detects stopped gateway', () => { ... }); + it('expandPath handles tilde', () => { ... }); +}); +``` + +### Docker Adapter + +```typescript +// tests/docker/adapter.test.ts +describe('DockerHostAdapter', () => { + it('discovers containers with openclaw label', () => { ... }); + it('discovers containers with openclaw binary', () => { ... }); + it('returns empty when no Docker containers running', () => { ... }); + it('returns empty when Docker not installed', () => { ... }); + it('test connection checks OpenClaw inside container', () => { ... }); + it('returns Docker-specific config fields', () => { ... }); +}); + +// tests/docker/connection.test.ts +describe('DockerHostConnection', () => { + it('exec routes through docker exec', () => { ... }); + it('exec with cwd sets -w flag', () => { ... }); + it('exec with env sets -e flags', () => { ... }); + it('readTextFile uses docker exec cat', () => { ... }); + it('writeTextFile uses docker exec tee', () => { ... }); + it('readFile uses docker cp for binary', () => { ... }); + it('writeFile uses docker cp', () => { ... }); + it('openTerminal creates docker exec -it terminal', () => { ... }); + it('compose mode routes through docker compose exec', () => { ... }); + it('getGatewayUrl uses port mapping', () => { ... }); +}); +``` + +### SSH Adapter + +```typescript +// tests/ssh/adapter.test.ts +describe('SSHHostAdapter', () => { + it('parses ~/.ssh/config for host entries', () => { ... }); + it('skips wildcard hosts in config', () => { ... }); + it('returns SSH-specific config fields', () => { ... }); + it('validates config — requires host or sshConfigHost', () => { ... }); + it('validates config — checks key file exists', () => { ... }); +}); + +// tests/ssh/connection.test.ts (uses mock ssh2) +describe('SSHHostConnection', () => { + it('connects with key auth', () => { ... }); + it('connects with agent auth', () => { ... }); + it('exec runs command over SSH', () => { ... }); + it('exec escapes arguments properly', () => { ... }); + it('exec handles command timeout', () => { ... }); + it('readTextFile uses SSH exec cat', () => { ... }); + it('writeTextFile uses SFTP', () => { ... }); + it('readFile uses SFTP stream', () => { ... }); + it('forwardGatewayPort creates SSH tunnel', () => { ... }); + it('reconnects on connection drop', () => { ... }); + it('dispose cleans up forwarded ports', () => { ... }); +}); + +// tests/ssh/config-parser.test.ts +describe('SSHConfigParser', () => { + it('parses basic Host block', () => { ... }); + it('parses Host with HostName override', () => { ... }); + it('handles comments', () => { ... }); + it('handles multiple Host blocks', () => { ... }); + it('handles Host with Port and User', () => { ... }); + it('handles indented and non-indented styles', () => { ... }); +}); +``` + +### Cloud Adapter + +```typescript +// tests/cloud/adapter.test.ts +describe('CloudHostAdapter', () => { + it('discovers pods when authenticated', () => { ... }); + it('returns empty when not authenticated', () => { ... }); + it('prompts sign-in when no token', () => { ... }); +}); + +// tests/cloud/connection.test.ts (uses mock API) +describe('CloudHostConnection', () => { + it('exec calls /pods/{id}/exec API', () => { ... }); + it('readTextFile calls /pods/{id}/files API', () => { ... }); + it('writeTextFile calls PUT /pods/{id}/files API', () => { ... }); + it('getGatewayUrl returns MoltPod proxy URL', () => { ... }); + it('handles 401 and triggers re-auth', () => { ... }); +}); + +// tests/cloud/filesystem.test.ts +describe('CloudFileSystemProvider', () => { + it('stat returns file info from API', () => { ... }); + it('readDirectory returns entries from API', () => { ... }); + it('readFile returns content from API', () => { ... }); + it('writeFile calls PUT API', () => { ... }); + it('fires onDidChangeFile after write', () => { ... }); +}); +``` + +## Integration Tests + +### Mock Host Server + +Create a lightweight mock server for integration tests: + +```typescript +// test-utils/mock-host.ts +export class MockHost { + private files: Map = new Map(); + private gateway: { running: boolean; version: string } = { running: true, version: '2026.3.19' }; + + constructor() { + // Seed with default OpenClaw config + this.files.set('/root/.openclaw/openclaw.json', JSON.stringify({ + agents: { list: [{ id: 'main' }] }, + channels: { whatsapp: { enabled: true } }, + gateway: { port: 18789 }, + })); + } + + async exec(command: string, args: string[]): Promise { + if (command === 'openclaw' && args[0] === 'status' && args[1] === '--json') { + return { exitCode: 0, stdout: JSON.stringify(this.gateway), stderr: '' }; + } + if (command === 'openclaw' && args[0] === 'gateway' && args[1] === 'start') { + this.gateway.running = true; + return { exitCode: 0, stdout: 'Gateway started', stderr: '' }; + } + if (command === 'which' && args[0] === 'openclaw') { + return { exitCode: 0, stdout: '/usr/local/bin/openclaw', stderr: '' }; + } + return { exitCode: 127, stdout: '', stderr: `Command not found: ${command}` }; + } + + async readFile(path: string): Promise { + const content = this.files.get(path); + if (!content) throw new Error(`ENOENT: ${path}`); + return content; + } + + async writeFile(path: string, content: string): Promise { + this.files.set(path, content); + } +} +``` + +### Docker Integration Test Setup + +```dockerfile +# test-utils/Dockerfile.test-host +FROM node:20-slim +RUN npm install -g openclaw@latest +RUN openclaw doctor --non-interactive || true +EXPOSE 18789 +CMD ["sleep", "infinity"] +``` + +```bash +# Run Docker integration tests +docker build -t occ-test-host -f test-utils/Dockerfile.test-host . +docker run -d --name occ-test-host -p 28789:18789 occ-test-host + +npx jest tests/docker/integration.test.ts + +docker rm -f occ-test-host +``` + +### SSH Integration Test Setup + +```bash +# Use Docker to create an SSH-accessible test host +docker run -d --name occ-test-ssh -p 2222:22 \ + -e SSH_ENABLE_ROOT=true \ + -e SSH_ENABLE_ROOT_PASSWORD_AUTH=true \ + -e ROOT_PASSWORD=testpass \ + linuxserver/openssh-server + +# Install OpenClaw inside +docker exec occ-test-ssh bash -c "curl -fsSL https://install.openclaw.ai | bash" + +# Run SSH integration tests +SSH_TEST_HOST=localhost SSH_TEST_PORT=2222 SSH_TEST_USER=root SSH_TEST_PASS=testpass \ + npx jest tests/ssh/integration.test.ts + +docker rm -f occ-test-ssh +``` + +## End-to-End Tests + +These test the full flow through the VS Code extension host: + +```typescript +// e2e/multihost.test.ts +describe('MultiHost E2E', () => { + it('fresh install creates ~/.occ/ with local-default', async () => { + // Launch VS Code with clean profile + // Verify ~/.occ/hosts.json exists with local-default + // Verify no host picker visible (only 1 host) + }); + + it('adding a second host shows the host picker', async () => { + // Add a Docker host via command palette + // Verify host picker appears in status bar + // Verify sidebar tree shows both hosts + }); + + it('switching hosts updates the control center', async () => { + // Select host A → verify control center shows host A config + // Switch to host B → verify control center shows host B config + }); + + it('gateway start/stop works on non-local host', async () => { + // Connect to Docker host + // Start gateway → verify health check passes + // Stop gateway → verify health check fails + }); +}); +``` + +## CI Pipeline + +```yaml +# .github/workflows/test.yml +name: MultiHost Tests +on: [push, pull_request] + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npm test -- --coverage + + integration-docker: + runs-on: ubuntu-latest + services: + test-host: + image: occ-test-host + ports: ['28789:18789'] + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: npx jest tests/docker/integration.test.ts + + integration-ssh: + runs-on: ubuntu-latest + services: + ssh-host: + image: linuxserver/openssh-server + ports: ['2222:22'] + env: + SSH_ENABLE_ROOT: true + ROOT_PASSWORD: testpass + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: | + SSH_TEST_HOST=localhost SSH_TEST_PORT=2222 \ + npx jest tests/ssh/integration.test.ts +``` From 99132483b628ed4379bb90b8a7817561f23ae76c Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:42:31 -0400 Subject: [PATCH 013/294] =?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 --- .../extensions/openclaw-docker/package.json | 23 +++++++++++++++++++ .../extensions/openclaw-docker/tsconfig.json | 14 +++++++++++ .../extensions/openclaw-local/package.json | 23 +++++++++++++++++++ .../extensions/openclaw-local/tsconfig.json | 14 +++++++++++ .../extensions/openclaw-ssh/package.json | 23 +++++++++++++++++++ .../extensions/openclaw-ssh/tsconfig.json | 14 +++++++++++ 6 files changed, 111 insertions(+) create mode 100644 apps/editor/extensions/openclaw-docker/package.json create mode 100644 apps/editor/extensions/openclaw-docker/tsconfig.json create mode 100644 apps/editor/extensions/openclaw-local/package.json create mode 100644 apps/editor/extensions/openclaw-local/tsconfig.json create mode 100644 apps/editor/extensions/openclaw-ssh/package.json create mode 100644 apps/editor/extensions/openclaw-ssh/tsconfig.json diff --git a/apps/editor/extensions/openclaw-docker/package.json b/apps/editor/extensions/openclaw-docker/package.json new file mode 100644 index 00000000..d2a8efa3 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/package.json @@ -0,0 +1,23 @@ +{ + "name": "openclaw-docker", + "displayName": "OpenClaw Docker", + "description": "Manage OpenClaw inside Docker containers", + "version": "1.0.0", + "publisher": "openclaw", + "license": "MIT", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["openclaw.home"], + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension", + "contributes": {}, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + } +} diff --git a/apps/editor/extensions/openclaw-docker/tsconfig.json b/apps/editor/extensions/openclaw-docker/tsconfig.json new file mode 100644 index 00000000..a358ee24 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/apps/editor/extensions/openclaw-local/package.json b/apps/editor/extensions/openclaw-local/package.json new file mode 100644 index 00000000..e73b1b71 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/package.json @@ -0,0 +1,23 @@ +{ + "name": "openclaw-local", + "displayName": "OpenClaw Local", + "description": "Manage OpenClaw on your local machine", + "version": "1.0.0", + "publisher": "openclaw", + "license": "MIT", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["openclaw.home"], + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension", + "contributes": {}, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + } +} diff --git a/apps/editor/extensions/openclaw-local/tsconfig.json b/apps/editor/extensions/openclaw-local/tsconfig.json new file mode 100644 index 00000000..a358ee24 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/apps/editor/extensions/openclaw-ssh/package.json b/apps/editor/extensions/openclaw-ssh/package.json new file mode 100644 index 00000000..323749bd --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/package.json @@ -0,0 +1,23 @@ +{ + "name": "openclaw-ssh", + "displayName": "OpenClaw SSH (Preview)", + "description": "Manage OpenClaw on remote servers via SSH — coming soon", + "version": "0.1.0", + "publisher": "openclaw", + "license": "MIT", + "engines": { "vscode": "^1.85.0" }, + "extensionDependencies": ["openclaw.home"], + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension", + "contributes": {}, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + } +} diff --git a/apps/editor/extensions/openclaw-ssh/tsconfig.json b/apps/editor/extensions/openclaw-ssh/tsconfig.json new file mode 100644 index 00000000..a358ee24 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", ".vscode-test"] +} From 7ddd79ba2c6af0755ea62fa0d042b4a3206923a5 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:45:46 -0400 Subject: [PATCH 014/294] feat(multihost): add shared host types + adapter extension scaffolds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/editor/extensions/openclaw/src/hosts/types.ts: complete shared interface definitions — HostAdapter, HostConnection, HostEntry, HostsFile, HostCache, OpenClawCoreAPI, all connection configs (Local/Docker/SSH/Cloud), LogFn, ExecOpts/Result, GatewayStatus, etc. - apps/editor/extensions/openclaw-local/: new standalone extension stub (package.json + tsconfig.json) - apps/editor/extensions/openclaw-docker/: new standalone extension stub - apps/editor/extensions/openclaw-ssh/: new standalone extension stub (preview, 0.1.0) Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw/src/hosts/types.ts | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 apps/editor/extensions/openclaw/src/hosts/types.ts diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts new file mode 100644 index 00000000..2d82e9f2 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -0,0 +1,302 @@ +import * as vscode from 'vscode'; + +// ───────────────────────────────────────────── +// Primitive helpers +// ───────────────────────────────────────────── + +export type LogFn = (line: string) => void; + +export interface ExecOpts { + cwd?: string; + env?: Record; + timeout?: number; + /** Bytes to pipe to stdin before closing it */ + stdinData?: string; + windowsHide?: boolean; +} + +export interface ExecResult { + stdout: string; + stderr: string; + code: number; +} + +export interface CliCheckResult { + ok: boolean; + output?: string; + error?: string; + command: string; +} + +// ───────────────────────────────────────────── +// Gateway +// ───────────────────────────────────────────── + +export type GatewayRunState = 'running' | 'stopped' | 'starting' | 'error' | 'unknown'; + +export interface GatewayStatus { + state: GatewayRunState; + port?: number; + version?: string; + uptime?: string; + error?: string; +} + +// ───────────────────────────────────────────── +// OpenClaw config (openclaw.json) +// ───────────────────────────────────────────── + +export interface OpenClawConfig { + gateway?: { port?: number; [key: string]: unknown }; + [key: string]: unknown; +} + +// ───────────────────────────────────────────── +// Setup params (what home.ts passes to runSetup) +// ───────────────────────────────────────────── + +export interface SetupParams { + provider: string; + apiKey: string; + port: string; +} + +// ───────────────────────────────────────────── +// Add-Host wizard config fields +// ───────────────────────────────────────────── + +export interface ConfigField { + id: string; + label: string; + type: 'text' | 'password' | 'number' | 'select' | 'checkbox'; + placeholder?: string; + required?: boolean; + options?: { label: string; value: string }[]; + defaultValue?: string | number | boolean; +} + +export interface ConfigValidationResult { + valid: boolean; + errors?: { fieldId: string; message: string }[]; +} + +export interface TestResult { + success: boolean; + message: string; + details?: { + openclawInstalled?: boolean; + openclawVersion?: string; + gatewayRunning?: boolean; + os?: string; + hostname?: string; + }; +} + +// ───────────────────────────────────────────── +// Discovery +// ───────────────────────────────────────────── + +export interface DiscoveredHost { + suggestedId: string; + suggestedLabel: string; + connection: HostConnectionConfig; + metadata?: Record; +} + +// ───────────────────────────────────────────── +// Connection configs (stored in hosts.json) +// ───────────────────────────────────────────── + +export interface LocalConnection { + type: 'local'; +} + +export interface DockerConnection { + type: 'docker'; + containerId?: string; + containerLabel?: string; + composeService?: string; + composeFile?: string; + dockerHost?: string; + shell?: string; + portMappings?: { gateway?: number }; +} + +export interface SSHConnection { + type: 'ssh'; + host: string; + port?: number; + user: string; + authMethod: 'key' | 'agent' | 'password'; + keyPath?: string; + passphrase?: boolean; + jumpHost?: string; + gatewayPort?: number; + sshConfigHost?: string; +} + +export interface CloudConnection { + type: 'cloud'; + provider: 'moltpod'; + podId: string; + apiEndpoint?: string; +} + +export type HostConnectionConfig = + | LocalConnection + | DockerConnection + | SSHConnection + | CloudConnection; + +// ───────────────────────────────────────────── +// Host entry (one row in hosts.json) +// ───────────────────────────────────────────── + +export type HostType = 'local' | 'docker' | 'ssh' | 'cloud'; +export type HostStatus = 'online' | 'offline' | 'error' | 'unknown'; + +export interface HostEntry { + id: string; + type: HostType; + label: string; + connection: HostConnectionConfig; + configPath?: string; + default?: boolean; + color?: string; + tags?: string[]; + createdAt: string; + lastConnectedAt?: string; + lastStatus?: HostStatus; + lastError?: string; +} + +// ───────────────────────────────────────────── +// hosts.json root schema +// ───────────────────────────────────────────── + +export interface HostsFile { + version: 1; + activeHostId: string; + hosts: HostEntry[]; +} + +// ───────────────────────────────────────────── +// Per-host cache (~/.occ/hosts/{id}/cache.json) +// ───────────────────────────────────────────── + +export interface HostCache { + lastCheckedAt: string; + gateway: { + running: boolean; + version?: string; + uptime?: string; + port?: number; + }; + agents: { + count: number; + names: string[]; + defaultAgent?: string; + }; + channels: { + count: number; + connected: string[]; + }; + system: { + os?: string; + hostname?: string; + openclawVersion?: string; + nodeVersion?: string; + }; +} + +// ───────────────────────────────────────────── +// HostConnection — what every adapter implements +// ───────────────────────────────────────────── + +export interface HostConnection extends vscode.Disposable { + readonly id: string; + readonly type: HostType; + readonly label: string; + + // ── CLI ── + isCliInstalled(): Promise; + getCliVersion(): Promise; + installCli(onLog: LogFn): Promise; + findOpenClawPath(): Promise; + testOpenClawCli(): Promise; + + // ── Process execution ── + exec(cmd: string, args: string[], opts?: ExecOpts): Promise; + execStream( + cmd: string, + args: string[], + opts: ExecOpts, + onData: LogFn, + onError: LogFn, + ): Promise; + + // ── Filesystem ── + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + exists(path: string): Promise; + mkdir(path: string): Promise; + stat(path: string): Promise<{ size: number; isDirectory: boolean } | null>; + + // ── OpenClaw config ── + readConfig(): Promise; + writeConfig(patch: Partial): Promise; + getConfigPath(): Promise; + + // ── Gateway ── + gatewayHealthCheck(): Promise; + gatewayStart(onLog: LogFn): Promise; + gatewayStop(onLog: LogFn): Promise; + gatewayRestart(onLog: LogFn): Promise; + + // ── Full install+onboard ── + runSetup(params: SetupParams, onLog: LogFn): Promise; + + // ── Environment ── + buildExecEnv(): Record; +} + +// ───────────────────────────────────────────── +// HostAdapter — implemented by each adapter ext +// ───────────────────────────────────────────── + +export interface HostAdapter { + readonly type: HostType; + readonly displayName: string; + readonly icon: vscode.ThemeIcon; + + discover(): Promise; + connect(config: HostConnectionConfig): Promise; + testConnection(config: HostConnectionConfig): Promise; + getConfigFields(): ConfigField[]; + validateConfig(config: HostConnectionConfig): ConfigValidationResult; +} + +// ───────────────────────────────────────────── +// OpenClawCoreAPI — returned from activate() +// Adapter extensions grab it via getExtension().exports +// ───────────────────────────────────────────── + +export interface OpenClawCoreAPI { + readonly version: string; + + registerHostAdapter(adapter: HostAdapter): vscode.Disposable; + + getActiveHost(): HostConnection | undefined; + getHost(id: string): HostConnection | undefined; + getAllHosts(): HostEntry[]; + setActiveHost(id: string): Promise; + + readonly onDidChangeActiveHost: vscode.Event; + readonly onDidChangeHostStatus: vscode.Event<{ hostId: string; status: HostStatus }>; + readonly onDidAddHost: vscode.Event; + readonly onDidRemoveHost: vscode.Event; + + showHostPicker(): Promise; + showAddHostWizard(type?: HostType): Promise; + refreshHost(id: string): Promise; +} From b38bca4a1710879eaeacc9f843348821e9874cc5 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:48:24 -0400 Subject: [PATCH 015/294] feat(multihost): HostRegistry, HostManager, status bar, tree provider, API export - hosts/registry.ts: reads/writes ~/.occ/hosts.json, seeds local default, file-watches for external changes, CRUD for hosts + activeHostId - hosts/manager.ts: implements OpenClawCoreAPI; owns live HostConnection map; registers adapters, connects persisted hosts, drives status events - hosts/statusbar.ts: status bar item showing active host with icon - hosts/tree.ts: Tree view provider listing all hosts (openclaw.hosts view) - extension.ts: bootstraps registry + manager, exports OpenClawCoreAPI from activate(), registers openclaw.pickHost / setActiveHost / refreshHost commands - package.json: adds views (openclaw.hosts in explorer), 3 new commands Co-Authored-By: Claude Sonnet 4.6 --- apps/editor/extensions/openclaw/package.json | 24 ++ .../extensions/openclaw/src/extension.ts | 43 +++- .../extensions/openclaw/src/hosts/manager.ts | 177 +++++++++++++++ .../extensions/openclaw/src/hosts/registry.ts | 213 ++++++++++++++++++ .../openclaw/src/hosts/statusbar.ts | 51 +++++ .../extensions/openclaw/src/hosts/tree.ts | 82 +++++++ 6 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 apps/editor/extensions/openclaw/src/hosts/manager.ts create mode 100644 apps/editor/extensions/openclaw/src/hosts/registry.ts create mode 100644 apps/editor/extensions/openclaw/src/hosts/statusbar.ts create mode 100644 apps/editor/extensions/openclaw/src/hosts/tree.ts diff --git a/apps/editor/extensions/openclaw/package.json b/apps/editor/extensions/openclaw/package.json index a1cd46e4..ff4d92c0 100644 --- a/apps/editor/extensions/openclaw/package.json +++ b/apps/editor/extensions/openclaw/package.json @@ -80,6 +80,18 @@ "when": "true" } ], + "viewsContainers": { + "activitybar": [] + }, + "views": { + "explorer": [ + { + "id": "openclaw.hosts", + "name": "OpenClaw Hosts", + "when": "true" + } + ] + }, "commands": [ { "command": "openclaw.home", @@ -104,6 +116,18 @@ { "command": "openclaw.runWithSudo", "title": "OCC: Run Command with Sudo" + }, + { + "command": "openclaw.pickHost", + "title": "OpenClaw: Switch Host" + }, + { + "command": "openclaw.setActiveHost", + "title": "OpenClaw: Set Active Host" + }, + { + "command": "openclaw.refreshHost", + "title": "OpenClaw: Refresh Host Status" } ] }, diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index 67f318d3..d39f3e48 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -7,6 +7,11 @@ import * as https from 'https'; import { HomePanel } from './panels/home'; import { StatusPanel } from './panels/status'; import { stopConfigProxy, getDashboardUrl } from './panels/config'; +import { HostRegistry } from './hosts/registry'; +import { HostManager } from './hosts/manager'; +import { HostStatusBarItem } from './hosts/statusbar'; +import { HostTreeProvider } from './hosts/tree'; +import type { OpenClawCoreAPI } from './hosts/types'; const DEFAULT_GATEWAY_PORT = 18789; @@ -510,7 +515,40 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => return () => {}; // spend is a no-op — kept so call sites don't break } -export async function activate(context: vscode.ExtensionContext): Promise { +export async function activate(context: vscode.ExtensionContext): Promise { + // ── MultiHost: HostRegistry + HostManager ─────────────────────────────────── + const hostRegistry = new HostRegistry(); + await hostRegistry.init(); + const hostManager = new HostManager(hostRegistry); + context.subscriptions.push(hostRegistry, hostManager); + + // Status bar: shows active host name + const hostStatusBar = new HostStatusBarItem(hostManager); + context.subscriptions.push(hostStatusBar); + + // Tree view: lists all registered hosts + const hostTreeProvider = new HostTreeProvider(hostManager); + const hostTreeView = vscode.window.createTreeView('openclaw.hosts', { + treeDataProvider: hostTreeProvider, + showCollapseAll: false, + }); + context.subscriptions.push(hostTreeView, hostTreeProvider); + + // Host management commands + context.subscriptions.push( + vscode.commands.registerCommand('openclaw.pickHost', async () => { + const id = await hostManager.showHostPicker(); + if (id) { await hostManager.setActiveHost(id); } + }), + vscode.commands.registerCommand('openclaw.setActiveHost', async (id: string) => { + await hostManager.setActiveHost(id); + }), + vscode.commands.registerCommand('openclaw.refreshHost', async () => { + const activeId = hostRegistry.getActiveHostId(); + await hostManager.refreshHost(activeId); + }), + ); + // Inference balance bar (shown at bottom-right, tracks $1.00 free budget). const spendBalance = initBalanceBar(context); @@ -799,6 +837,9 @@ export async function activate(context: vscode.ExtensionContext): Promise setTimeout(() => { HomePanel.createOrShow(context.extensionUri); }, 500); + + // Return OpenClawCoreAPI so adapter extensions can register their adapters. + return hostManager; } export function deactivate() { diff --git a/apps/editor/extensions/openclaw/src/hosts/manager.ts b/apps/editor/extensions/openclaw/src/hosts/manager.ts new file mode 100644 index 00000000..275a3d45 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/manager.ts @@ -0,0 +1,177 @@ +import * as vscode from 'vscode'; +import type { + HostAdapter, + HostConnection, + HostEntry, + HostStatus, + HostType, + OpenClawCoreAPI, +} from './types'; +import { HostRegistry } from './registry'; + +// ───────────────────────────────────────────── +// HostManager +// Owns adapters, live connections, and the registry. +// Implements OpenClawCoreAPI for export to adapter extensions. +// ───────────────────────────────────────────── + +export class HostManager implements OpenClawCoreAPI, vscode.Disposable { + readonly version = '1.0.0'; + + private _adapters = new Map(); + private _connections = new Map(); + private _disposables: vscode.Disposable[] = []; + + private readonly _onDidChangeActiveHost = new vscode.EventEmitter(); + readonly onDidChangeActiveHost: vscode.Event = this._onDidChangeActiveHost.event; + + private readonly _onDidChangeHostStatus = new vscode.EventEmitter<{ hostId: string; status: HostStatus }>(); + readonly onDidChangeHostStatus: vscode.Event<{ hostId: string; status: HostStatus }> = this._onDidChangeHostStatus.event; + + private readonly _onDidAddHost = new vscode.EventEmitter(); + readonly onDidAddHost: vscode.Event = this._onDidAddHost.event; + + private readonly _onDidRemoveHost = new vscode.EventEmitter(); + readonly onDidRemoveHost: vscode.Event = this._onDidRemoveHost.event; + + constructor(private readonly registry: HostRegistry) { + this._disposables.push( + registry.onDidChange(() => this._onRegistryChange()), + ); + } + + // ── Adapter registration ────────────────── + + registerHostAdapter(adapter: HostAdapter): vscode.Disposable { + this._adapters.set(adapter.type, adapter); + // Attempt to connect any persisted hosts of this type + this._connectPersistedHosts(adapter.type); + return new vscode.Disposable(() => { + this._adapters.delete(adapter.type); + }); + } + + getAdapter(type: HostType): HostAdapter | undefined { + return this._adapters.get(type); + } + + // ── Host queries ────────────────────────── + + getActiveHost(): HostConnection | undefined { + const id = this.registry.getActiveHostId(); + return this._connections.get(id); + } + + getHost(id: string): HostConnection | undefined { + return this._connections.get(id); + } + + getAllHosts(): HostEntry[] { + return this.registry.getAllHosts(); + } + + async setActiveHost(id: string): Promise { + const prev = this.registry.getActiveHostId(); + if (prev === id) { return; } + this.registry.setActiveHostId(id); + // Ensure connected + await this._ensureConnected(id); + this._onDidChangeActiveHost.fire(this._connections.get(id)); + } + + // ── Connection management ───────────────── + + private async _connectPersistedHosts(type: HostType): Promise { + const entries = this.registry.getAllHosts().filter(h => h.type === type); + for (const entry of entries) { + await this._ensureConnected(entry.id).catch(() => {/* ignore individual failures */}); + } + } + + private async _ensureConnected(id: string): Promise { + if (this._connections.has(id)) { + return this._connections.get(id); + } + const entry = this.registry.getHost(id); + if (!entry) { return undefined; } + const adapter = this._adapters.get(entry.type); + if (!adapter) { return undefined; } + try { + const conn = await adapter.connect(entry.connection); + this._connections.set(id, conn); + this.registry.touchLastConnected(id); + return conn; + } catch (err) { + this.registry.setHostStatus(id, 'error', String(err)); + this._onDidChangeHostStatus.fire({ hostId: id, status: 'error' }); + return undefined; + } + } + + // ── Wizard / picker ─────────────────────── + + async showHostPicker(): Promise { + const entries = this.registry.getAllHosts(); + const activeId = this.registry.getActiveHostId(); + const items = entries.map(e => ({ + label: e.label, + description: e.type + (e.id === activeId ? ' • active' : ''), + id: e.id, + })); + const pick = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a host', + }); + return pick?.id; + } + + async showAddHostWizard(_type?: HostType): Promise { + // Full wizard lives in the webview panel; this is a lightweight fallback. + vscode.window.showInformationMessage('Use the OpenClaw panel to add hosts.'); + return undefined; + } + + async refreshHost(id: string): Promise { + const conn = this._connections.get(id); + if (!conn) { + await this._ensureConnected(id); + return; + } + try { + const status = await conn.gatewayHealthCheck(); + const hostStatus: HostStatus = + status.state === 'running' ? 'online' : + status.state === 'error' ? 'error' : 'offline'; + this.registry.setHostStatus(id, hostStatus, status.error); + this._onDidChangeHostStatus.fire({ hostId: id, status: hostStatus }); + } catch { + this.registry.setHostStatus(id, 'error', 'Health check failed'); + this._onDidChangeHostStatus.fire({ hostId: id, status: 'error' }); + } + } + + // ── Internal ────────────────────────────── + + private _onRegistryChange(): void { + // Drop connections for removed hosts + for (const [id, conn] of this._connections) { + if (!this.registry.getHost(id)) { + conn.dispose(); + this._connections.delete(id); + } + } + } + + // ── Dispose ─────────────────────────────── + + dispose(): void { + for (const conn of this._connections.values()) { + conn.dispose(); + } + this._connections.clear(); + this._onDidChangeActiveHost.dispose(); + this._onDidChangeHostStatus.dispose(); + this._onDidAddHost.dispose(); + this._onDidRemoveHost.dispose(); + this._disposables.forEach(d => d.dispose()); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/registry.ts b/apps/editor/extensions/openclaw/src/hosts/registry.ts new file mode 100644 index 00000000..b0b2f8a0 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/registry.ts @@ -0,0 +1,213 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { HostEntry, HostsFile, HostType, HostStatus } from './types'; + +// ───────────────────────────────────────────── +// Paths +// ───────────────────────────────────────────── + +export function getOccDir(): string { + return path.join(os.homedir(), '.occ'); +} + +export function getHostsFilePath(): string { + return path.join(getOccDir(), 'hosts.json'); +} + +export function getHostCacheDir(hostId: string): string { + return path.join(getOccDir(), 'hosts', hostId); +} + +export function getHostCachePath(hostId: string): string { + return path.join(getHostCacheDir(hostId), 'cache.json'); +} + +// ───────────────────────────────────────────── +// Default local host seed +// ───────────────────────────────────────────── + +function makeLocalDefaultEntry(): HostEntry { + return { + id: 'local', + type: 'local' as HostType, + label: 'Local', + connection: { type: 'local' }, + default: true, + createdAt: new Date().toISOString(), + }; +} + +function makeEmptyHostsFile(localEntry: HostEntry): HostsFile { + return { + version: 1, + activeHostId: localEntry.id, + hosts: [localEntry], + }; +} + +// ───────────────────────────────────────────── +// HostRegistry +// ───────────────────────────────────────────── + +export class HostRegistry implements vscode.Disposable { + private _hostsFile: HostsFile | undefined; + private _watcher: fs.FSWatcher | undefined; + private _disposables: vscode.Disposable[] = []; + + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; + + // ── Init ────────────────────────────────── + + async init(): Promise { + await this._ensureOccDir(); + await this._loadOrSeed(); + this._startWatching(); + } + + private async _ensureOccDir(): Promise { + const dir = getOccDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const hostsDir = path.join(dir, 'hosts'); + if (!fs.existsSync(hostsDir)) { + fs.mkdirSync(hostsDir, { recursive: true }); + } + } + + private async _loadOrSeed(): Promise { + const filePath = getHostsFilePath(); + if (!fs.existsSync(filePath)) { + const seed = makeEmptyHostsFile(makeLocalDefaultEntry()); + this._hostsFile = seed; + this._persist(); + } else { + this._hostsFile = this._readFromDisk(); + // Ensure local default is always present + if (!this._hostsFile.hosts.find(h => h.id === 'local')) { + this._hostsFile.hosts.unshift(makeLocalDefaultEntry()); + if (!this._hostsFile.activeHostId) { + this._hostsFile.activeHostId = 'local'; + } + this._persist(); + } + } + } + + private _readFromDisk(): HostsFile { + try { + const raw = fs.readFileSync(getHostsFilePath(), 'utf-8'); + return JSON.parse(raw) as HostsFile; + } catch { + return makeEmptyHostsFile(makeLocalDefaultEntry()); + } + } + + private _persist(): void { + try { + fs.writeFileSync( + getHostsFilePath(), + JSON.stringify(this._hostsFile, null, 2), + 'utf-8', + ); + } catch (err) { + console.error('[HostRegistry] Failed to persist hosts.json:', err); + } + } + + private _startWatching(): void { + const filePath = getHostsFilePath(); + try { + this._watcher = fs.watch(filePath, (_event) => { + const fresh = this._readFromDisk(); + this._hostsFile = fresh; + this._onDidChange.fire(); + }); + } catch { + // File watcher is best-effort + } + } + + // ── Read ────────────────────────────────── + + getAllHosts(): HostEntry[] { + return this._hostsFile?.hosts ?? []; + } + + getHost(id: string): HostEntry | undefined { + return this._hostsFile?.hosts.find(h => h.id === id); + } + + getActiveHostId(): string { + return this._hostsFile?.activeHostId ?? 'local'; + } + + // ── Write ───────────────────────────────── + + addHost(entry: HostEntry): void { + if (!this._hostsFile) { return; } + // Remove any existing entry with same id + this._hostsFile.hosts = this._hostsFile.hosts.filter(h => h.id !== entry.id); + this._hostsFile.hosts.push(entry); + this._persist(); + this._onDidChange.fire(); + } + + updateHost(id: string, patch: Partial): void { + if (!this._hostsFile) { return; } + const idx = this._hostsFile.hosts.findIndex(h => h.id === id); + if (idx === -1) { return; } + this._hostsFile.hosts[idx] = { ...this._hostsFile.hosts[idx], ...patch }; + this._persist(); + this._onDidChange.fire(); + } + + removeHost(id: string): void { + if (!this._hostsFile || id === 'local') { return; } // local is permanent + this._hostsFile.hosts = this._hostsFile.hosts.filter(h => h.id !== id); + if (this._hostsFile.activeHostId === id) { + this._hostsFile.activeHostId = 'local'; + } + this._persist(); + this._onDidChange.fire(); + } + + setActiveHostId(id: string): void { + if (!this._hostsFile) { return; } + if (!this._hostsFile.hosts.find(h => h.id === id)) { return; } + this._hostsFile.activeHostId = id; + this._persist(); + this._onDidChange.fire(); + } + + setHostStatus(id: string, status: HostStatus, error?: string): void { + this.updateHost(id, { + lastStatus: status, + lastError: error, + }); + } + + touchLastConnected(id: string): void { + this.updateHost(id, { lastConnectedAt: new Date().toISOString() }); + } + + // ── Host cache dir ──────────────────────── + + ensureHostCacheDir(hostId: string): void { + const dir = getHostCacheDir(hostId); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + + // ── Dispose ─────────────────────────────── + + dispose(): void { + this._watcher?.close(); + this._onDidChange.dispose(); + this._disposables.forEach(d => d.dispose()); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/statusbar.ts b/apps/editor/extensions/openclaw/src/hosts/statusbar.ts new file mode 100644 index 00000000..5e3737b0 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/statusbar.ts @@ -0,0 +1,51 @@ +import * as vscode from 'vscode'; +import type { HostManager } from './manager'; +import type { HostEntry } from './types'; + +// ───────────────────────────────────────────── +// HostStatusBarItem +// Shows the active host name in the status bar. +// Click → host picker quick-pick. +// ───────────────────────────────────────────── + +export class HostStatusBarItem implements vscode.Disposable { + private readonly _item: vscode.StatusBarItem; + private _disposables: vscode.Disposable[] = []; + + constructor(private readonly manager: HostManager) { + this._item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + this._item.command = 'openclaw.pickHost'; + this._item.tooltip = 'OpenClaw: active host — click to switch'; + this._disposables.push(this._item); + + this._disposables.push( + manager.onDidChangeActiveHost(() => this._refresh()), + manager.onDidChangeHostStatus(() => this._refresh()), + ); + + this._refresh(); + this._item.show(); + } + + private _refresh(): void { + const activeId = (this.manager as any).registry?.getActiveHostId?.() ?? 'local'; + const hosts: HostEntry[] = this.manager.getAllHosts(); + const entry = hosts.find(h => h.id === activeId); + const label = entry?.label ?? 'Local'; + const status = entry?.lastStatus; + + const icon = status === 'online' ? '$(vm-active)' + : status === 'error' ? '$(vm-connect)' + : status === 'offline' ? '$(vm)' + : '$(vm)'; + + this._item.text = `${icon} ${label}`; + } + + dispose(): void { + this._disposables.forEach(d => d.dispose()); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/tree.ts b/apps/editor/extensions/openclaw/src/hosts/tree.ts new file mode 100644 index 00000000..9e2016af --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/tree.ts @@ -0,0 +1,82 @@ +import * as vscode from 'vscode'; +import type { HostManager } from './manager'; +import type { HostEntry } from './types'; + +// ───────────────────────────────────────────── +// HostTreeItem +// ───────────────────────────────────────────── + +class HostTreeItem extends vscode.TreeItem { + constructor(readonly entry: HostEntry, isActive: boolean) { + super(entry.label, vscode.TreeItemCollapsibleState.None); + this.id = `host-${entry.id}`; + this.contextValue = `host-${entry.type}`; + this.description = entry.type; + + const status = entry.lastStatus; + this.iconPath = new vscode.ThemeIcon( + status === 'online' ? 'vm-active' + : status === 'error' ? 'vm-connect' + : 'vm', + isActive ? new vscode.ThemeColor('statusBarItem.prominentBackground') : undefined, + ); + + if (isActive) { + this.label = `${entry.label} ●`; + } + + this.command = { + command: 'openclaw.setActiveHost', + title: 'Switch to host', + arguments: [entry.id], + }; + + this.tooltip = new vscode.MarkdownString( + `**${entry.label}** (${entry.type})\n\n` + + (entry.lastStatus ? `Status: ${entry.lastStatus}\n` : '') + + (entry.lastConnectedAt ? `Last connected: ${entry.lastConnectedAt}` : ''), + ); + } +} + +// ───────────────────────────────────────────── +// HostTreeProvider +// ───────────────────────────────────────────── + +export class HostTreeProvider + implements vscode.TreeDataProvider, vscode.Disposable +{ + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = + this._onDidChangeTreeData.event; + + private _disposables: vscode.Disposable[] = []; + + constructor(private readonly manager: HostManager) { + this._disposables.push( + manager.onDidChangeActiveHost(() => this._onDidChangeTreeData.fire()), + manager.onDidChangeHostStatus(() => this._onDidChangeTreeData.fire()), + manager.onDidAddHost(() => this._onDidChangeTreeData.fire()), + manager.onDidRemoveHost(() => this._onDidChangeTreeData.fire()), + ); + } + + getTreeItem(element: HostTreeItem): vscode.TreeItem { + return element; + } + + getChildren(_element?: HostTreeItem): HostTreeItem[] { + const entries = this.manager.getAllHosts(); + const activeId = (this.manager as any).registry?.getActiveHostId?.() ?? 'local'; + return entries.map(e => new HostTreeItem(e, e.id === activeId)); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + dispose(): void { + this._onDidChangeTreeData.dispose(); + this._disposables.forEach(d => d.dispose()); + } +} From e342e891bbf5a8f89c5eab81a4be25c8bdd147de Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:50:08 -0400 Subject: [PATCH 016/294] =?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 +- .../extensions/openclaw-local/src/adapter.ts | 70 ++++ .../openclaw-local/src/connection.ts | 370 ++++++++++++++++++ .../openclaw-local/src/extension.ts | 31 ++ .../extensions/openclaw-local/tsconfig.json | 2 +- .../extensions/openclaw-ssh/tsconfig.json | 2 +- 6 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 apps/editor/extensions/openclaw-local/src/adapter.ts create mode 100644 apps/editor/extensions/openclaw-local/src/connection.ts create mode 100644 apps/editor/extensions/openclaw-local/src/extension.ts diff --git a/apps/editor/extensions/openclaw-docker/tsconfig.json b/apps/editor/extensions/openclaw-docker/tsconfig.json index a358ee24..c8613a95 100644 --- a/apps/editor/extensions/openclaw-docker/tsconfig.json +++ b/apps/editor/extensions/openclaw-docker/tsconfig.json @@ -4,11 +4,11 @@ "target": "ES2020", "lib": ["ES2020"], "outDir": "./out", - "rootDir": "./src", "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true }, + "include": ["src/**/*"], "exclude": ["node_modules", ".vscode-test"] } diff --git a/apps/editor/extensions/openclaw-local/src/adapter.ts b/apps/editor/extensions/openclaw-local/src/adapter.ts new file mode 100644 index 00000000..601fabcf --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/adapter.ts @@ -0,0 +1,70 @@ +import * as vscode from 'vscode'; +import type { + HostAdapter, + HostType, + HostConnection, + HostConnectionConfig, + LocalConnection, + TestResult, + DiscoveredHost, + ConfigField, + ConfigValidationResult, +} from '../../openclaw/src/hosts/types'; +import { LocalHostConnection } from './connection'; + +// ───────────────────────────────────────────── +// LocalHostAdapter +// ───────────────────────────────────────────── + +export class LocalHostAdapter implements HostAdapter { + readonly type: HostType = 'local'; + readonly displayName = 'Local Machine'; + readonly icon = new vscode.ThemeIcon('device-desktop'); + + /** Always discovers exactly one local host. */ + async discover(): Promise { + return [ + { + suggestedId: 'local', + suggestedLabel: 'Local', + connection: { type: 'local' } as LocalConnection, + }, + ]; + } + + async connect(_config: HostConnectionConfig): Promise { + return new LocalHostConnection(); + } + + async testConnection(_config: HostConnectionConfig): Promise { + const conn = new LocalHostConnection(); + try { + const cliCheck = await conn.testOpenClawCli(); + const gwStatus = cliCheck.ok ? await conn.gatewayHealthCheck() : undefined; + return { + success: cliCheck.ok, + message: cliCheck.ok + ? `OpenClaw CLI found (${cliCheck.output ?? ''})` + : `OpenClaw CLI not found: ${cliCheck.error ?? 'unknown'}`, + details: { + openclawInstalled: cliCheck.ok, + openclawVersion: cliCheck.output, + gatewayRunning: gwStatus?.state === 'running', + os: process.platform, + hostname: require('os').hostname(), + }, + }; + } finally { + conn.dispose(); + } + } + + getConfigFields(): ConfigField[] { + // Local needs no config fields + return []; + } + + validateConfig(_config: HostConnectionConfig): ConfigValidationResult { + return { valid: true }; + } +} diff --git a/apps/editor/extensions/openclaw-local/src/connection.ts b/apps/editor/extensions/openclaw-local/src/connection.ts new file mode 100644 index 00000000..de5ee339 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/connection.ts @@ -0,0 +1,370 @@ +import * as vscode from 'vscode'; +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + GatewayRunState, + OpenClawConfig, + SetupParams, +} from '../../openclaw/src/hosts/types'; + +// ───────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────── + +function platformOpenClawPaths(): string[] { + switch (process.platform) { + case 'win32': + return [ + path.join(process.env['LOCALAPPDATA'] ?? 'C:\\Users\\Default\\AppData\\Local', 'Programs', 'openclaw', 'openclaw.exe'), + path.join(process.env['PROGRAMFILES'] ?? 'C:\\Program Files', 'openclaw', 'openclaw.exe'), + 'C:\\openclaw\\openclaw.exe', + ]; + case 'darwin': + return [ + '/usr/local/bin/openclaw', + '/opt/homebrew/bin/openclaw', + path.join(os.homedir(), '.local', 'bin', 'openclaw'), + '/usr/bin/openclaw', + ]; + default: // linux + return [ + '/usr/local/bin/openclaw', + path.join(os.homedir(), '.local', 'bin', 'openclaw'), + '/usr/bin/openclaw', + '/snap/bin/openclaw', + ]; + } +} + +function openClawConfigDir(): string { + return path.join(os.homedir(), '.openclaw'); +} + +function openClawConfigFile(): string { + return path.join(openClawConfigDir(), 'openclaw.json'); +} + +// ───────────────────────────────────────────── +// LocalHostConnection +// ───────────────────────────────────────────── + +export class LocalHostConnection implements HostConnection { + readonly id = 'local'; + readonly type: HostType = 'local'; + readonly label = 'Local'; + + private _disposed = false; + + // ── vscode.Disposable ───────────────────── + + dispose(): void { + this._disposed = true; + } + + // ── Process execution ───────────────────── + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + + if (opts.stdinData !== undefined) { + proc.stdin.write(opts.stdinData); + proc.stdin.end(); + } + + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + + proc.on('error', reject); + proc.on('close', code => { + resolve({ stdout, stderr, code: code ?? -1 }); + }); + }); + } + + execStream( + cmd: string, + args: string[], + opts: ExecOpts, + onData: LogFn, + onError: LogFn, + ): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + + if (opts.stdinData !== undefined) { + proc.stdin.write(opts.stdinData); + proc.stdin.end(); + } + + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + // ── Filesystem ──────────────────────────── + + readFile(filePath: string): Promise { + return Promise.resolve(fs.readFileSync(filePath, 'utf-8')); + } + + writeFile(filePath: string, content: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); + return Promise.resolve(); + } + + exists(filePath: string): Promise { + return Promise.resolve(fs.existsSync(filePath)); + } + + mkdir(dirPath: string): Promise { + fs.mkdirSync(dirPath, { recursive: true }); + return Promise.resolve(); + } + + stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + try { + const s = fs.statSync(filePath); + return Promise.resolve({ size: s.size, isDirectory: s.isDirectory() }); + } catch { + return Promise.resolve(null); + } + } + + // ── CLI ─────────────────────────────────── + + async findOpenClawPath(): Promise { + // 1. User-configured path + const configuredPath = vscode.workspace.getConfiguration('openclaw').get('cliPath'); + if (configuredPath && fs.existsSync(configuredPath)) { + return configuredPath; + } + + // 2. $PATH via `which` / `where` + try { + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + const result = await this.exec(whichCmd, ['openclaw'], { timeout: 5000 }); + const found = result.stdout.trim().split('\n')[0]; + if (found && fs.existsSync(found)) { return found; } + } catch { /* ignore */ } + + // 3. Well-known platform paths + for (const p of platformOpenClawPaths()) { + if (fs.existsSync(p)) { return p; } + } + + return undefined; + } + + async isCliInstalled(): Promise { + const p = await this.findOpenClawPath(); + return p !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const result = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (result.stdout + result.stderr).trim(); + const match = out.match(/[\d]+\.[\d]+\.[\d]+/); + return match ? match[0] : out || null; + } catch { + return null; + } + } + + async testOpenClawCli(): Promise { + const p = await this.findOpenClawPath(); + const command = p ?? 'openclaw'; + if (!p) { + return { ok: false, command, error: 'OpenClaw CLI not found' }; + } + try { + const result = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (result.stdout + result.stderr).trim(); + if (result.code === 0) { + return { ok: true, command, output: out }; + } else { + return { ok: false, command, error: `Exit code ${result.code}: ${out}` }; + } + } catch (err) { + return { ok: false, command, error: String(err) }; + } + } + + async installCli(onLog: LogFn): Promise { + // Platform-specific install script + switch (process.platform) { + case 'darwin': + case 'linux': + await this._installUnix(onLog); + break; + case 'win32': + await this._installWindows(onLog); + break; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } + } + + private async _installUnix(onLog: LogFn): Promise { + onLog('Downloading OpenClaw installer...\n'); + // Use curl to pipe the official install script + const code = await this.execStream( + 'bash', + ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], + { timeout: 120_000 }, + onLog, + onLog, + ); + if (code !== 0) { + throw new Error(`Installer exited with code ${code}`); + } + onLog('OpenClaw installed.\n'); + } + + private async _installWindows(onLog: LogFn): Promise { + onLog('Downloading OpenClaw installer for Windows...\n'); + const code = await this.execStream( + 'powershell', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + 'irm https://get.openclaw.sh/win | iex'], + { timeout: 120_000, windowsHide: true }, + onLog, + onLog, + ); + if (code !== 0) { + throw new Error(`Installer exited with code ${code}`); + } + onLog('OpenClaw installed.\n'); + } + + // ── OpenClaw config ─────────────────────── + + async getConfigPath(): Promise { + return openClawConfigFile(); + } + + async readConfig(): Promise { + const cfgPath = openClawConfigFile(); + if (!fs.existsSync(cfgPath)) { return {}; } + try { + return JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as OpenClawConfig; + } catch { + return {}; + } + } + + async writeConfig(patch: Partial): Promise { + const cfgPath = openClawConfigFile(); + fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); + let existing: OpenClawConfig = {}; + if (fs.existsSync(cfgPath)) { + try { existing = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as OpenClawConfig; } catch { /* ok */ } + } + const merged = { ...existing, ...patch }; + fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf-8'); + } + + // ── Gateway ─────────────────────────────── + + async gatewayHealthCheck(): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { + return { state: 'unknown' as GatewayRunState, error: 'CLI not installed' }; + } + + try { + const result = await this.exec(cliPath, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (result.code === 0) { + try { + const parsed = JSON.parse(result.stdout) as Partial; + return { + state: parsed.state ?? 'unknown', + port: parsed.port, + version: parsed.version, + uptime: parsed.uptime, + }; + } catch { + // If not JSON, treat non-zero stderr as error + const out = (result.stdout + result.stderr).toLowerCase(); + const running = out.includes('running') || out.includes('started'); + return { state: running ? 'running' : 'stopped' }; + } + } + return { state: 'stopped' as GatewayRunState }; + } catch { + return { state: 'error' as GatewayRunState, error: 'Health check failed' }; + } + } + + async gatewayStart(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited with code ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited with code ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } + } + + // ── Full install + onboard ──────────────── + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed — call installCli first'); } + + onLog(`Setting up OpenClaw with provider=${params.provider}, port=${params.port}\n`); + + const args = ['onboard', + '--provider', params.provider, + '--api-key', params.apiKey, + '--port', params.port, + ]; + + const code = await this.execStream(cliPath, args, {}, onLog, onLog); + if (code !== 0) { throw new Error(`onboard exited with code ${code}`); } + } + + // ── Environment ─────────────────────────── + + buildExecEnv(): Record { + return { ...process.env }; + } +} diff --git a/apps/editor/extensions/openclaw-local/src/extension.ts b/apps/editor/extensions/openclaw-local/src/extension.ts new file mode 100644 index 00000000..e0619b15 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/extension.ts @@ -0,0 +1,31 @@ +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { LocalHostAdapter } from './adapter'; + +export async function activate(context: vscode.ExtensionContext): Promise { + // Grab the core API exported by the openclaw.home extension + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (!coreExt) { + console.warn('[openclaw-local] Core extension openclaw.home not found — local adapter not registered'); + return; + } + + const coreAPI = coreExt.isActive + ? coreExt.exports + : await coreExt.activate(); + + if (!coreAPI || typeof coreAPI.registerHostAdapter !== 'function') { + console.warn('[openclaw-local] Core extension did not export OpenClawCoreAPI — local adapter not registered'); + return; + } + + const adapter = new LocalHostAdapter(); + const disposable = coreAPI.registerHostAdapter(adapter); + context.subscriptions.push(disposable); + + console.log('[openclaw-local] LocalHostAdapter registered'); +} + +export function deactivate(): void { + // Subscriptions cleaned up automatically +} diff --git a/apps/editor/extensions/openclaw-local/tsconfig.json b/apps/editor/extensions/openclaw-local/tsconfig.json index a358ee24..c8613a95 100644 --- a/apps/editor/extensions/openclaw-local/tsconfig.json +++ b/apps/editor/extensions/openclaw-local/tsconfig.json @@ -4,11 +4,11 @@ "target": "ES2020", "lib": ["ES2020"], "outDir": "./out", - "rootDir": "./src", "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true }, + "include": ["src/**/*"], "exclude": ["node_modules", ".vscode-test"] } diff --git a/apps/editor/extensions/openclaw-ssh/tsconfig.json b/apps/editor/extensions/openclaw-ssh/tsconfig.json index a358ee24..c8613a95 100644 --- a/apps/editor/extensions/openclaw-ssh/tsconfig.json +++ b/apps/editor/extensions/openclaw-ssh/tsconfig.json @@ -4,11 +4,11 @@ "target": "ES2020", "lib": ["ES2020"], "outDir": "./out", - "rootDir": "./src", "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true }, + "include": ["src/**/*"], "exclude": ["node_modules", ".vscode-test"] } From 1f0995125bf7b287a2448966a758f54c2c0ef041 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:52:40 -0400 Subject: [PATCH 017/294] feat(multihost): openclaw-docker extension + openclaw-ssh stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openclaw-docker: - preflight.ts: three-state Docker health check (cli_missing / daemon_down / permission_denied) with platform-specific remedies for darwin (OrbStack), win32 (Docker Desktop + WSL2), linux (systemd + apt/dnf) - connection.ts: DockerHostConnection — all HostConnection methods via `docker exec`; writeFile via stdin+tee; portMapping aware gatewayHealthCheck - adapter.ts: DockerHostAdapter — discovers running containers via `docker ps`; resolves containerId from label or compose service; testConnection runs preflight + CLI check inside container; full getConfigFields / validateConfig - extension.ts: registers DockerHostAdapter against openclaw.home exports openclaw-ssh: - src/extension.ts: activation stub (logs "coming soon", no-op) All four extensions compile cleanly with zero TypeScript errors. Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw-docker/src/adapter.ts | 219 ++++++++++++ .../openclaw-docker/src/connection.ts | 324 ++++++++++++++++++ .../openclaw-docker/src/extension.ts | 30 ++ .../openclaw-docker/src/preflight.ts | 190 ++++++++++ .../extensions/openclaw-ssh/src/extension.ts | 13 + 5 files changed, 776 insertions(+) create mode 100644 apps/editor/extensions/openclaw-docker/src/adapter.ts create mode 100644 apps/editor/extensions/openclaw-docker/src/connection.ts create mode 100644 apps/editor/extensions/openclaw-docker/src/extension.ts create mode 100644 apps/editor/extensions/openclaw-docker/src/preflight.ts create mode 100644 apps/editor/extensions/openclaw-ssh/src/extension.ts diff --git a/apps/editor/extensions/openclaw-docker/src/adapter.ts b/apps/editor/extensions/openclaw-docker/src/adapter.ts new file mode 100644 index 00000000..06262f4e --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/adapter.ts @@ -0,0 +1,219 @@ +import * as vscode from 'vscode'; +import * as cp from 'child_process'; +import type { + HostAdapter, + HostType, + HostConnection, + HostConnectionConfig, + DockerConnection, + TestResult, + DiscoveredHost, + ConfigField, + ConfigValidationResult, +} from '../../openclaw/src/hosts/types'; +import { DockerHostConnection } from './connection'; +import { dockerPreflight } from './preflight'; + +// ───────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────── + +function listRunningContainers(): Array<{ id: string; name: string; image: string }> { + try { + const result = cp.spawnSync( + 'docker', + ['ps', '--format', '{{.ID}}\t{{.Names}}\t{{.Image}}'], + { timeout: 6000, windowsHide: true }, + ); + if (result.status !== 0) { return []; } + return result.stdout + .toString() + .trim() + .split('\n') + .filter(Boolean) + .map(line => { + const [id, name, image] = line.split('\t'); + return { id: id ?? '', name: name ?? '', image: image ?? '' }; + }); + } catch { + return []; + } +} + +async function resolveContainerId(config: DockerConnection): Promise { + if (config.containerId) { return config.containerId; } + + if (config.containerLabel) { + const result = cp.spawnSync( + 'docker', + ['ps', '--filter', `name=${config.containerLabel}`, '--format', '{{.ID}}'], + { timeout: 6000, windowsHide: true }, + ); + if (result.status === 0) { + const id = result.stdout.toString().trim().split('\n')[0]; + if (id) { return id; } + } + } + + if (config.composeService) { + const args = config.composeFile + ? ['-f', config.composeFile, 'ps', '-q', config.composeService] + : ['ps', '-q', config.composeService]; + const result = cp.spawnSync('docker', ['compose', ...args], { + timeout: 6000, + windowsHide: true, + }); + if (result.status === 0) { + const id = result.stdout.toString().trim().split('\n')[0]; + if (id) { return id; } + } + } + + throw new Error( + 'Could not resolve Docker container ID. Specify containerId, containerLabel, or composeService.', + ); +} + +// ───────────────────────────────────────────── +// DockerHostAdapter +// ───────────────────────────────────────────── + +export class DockerHostAdapter implements HostAdapter { + readonly type: HostType = 'docker'; + readonly displayName = 'Docker Container'; + readonly icon = new vscode.ThemeIcon('package'); + + async discover(): Promise { + const preflight = await dockerPreflight(); + if (!preflight.ok) { return []; } + + return listRunningContainers().map(c => ({ + suggestedId: `docker:${c.id.slice(0, 12)}`, + suggestedLabel: c.name || c.id.slice(0, 12), + connection: { + type: 'docker', + containerId: c.id, + containerLabel: c.name, + } as DockerConnection, + metadata: { image: c.image }, + })); + } + + async connect(config: HostConnectionConfig): Promise { + const dockerConfig = config as DockerConnection; + + // Run preflight before attempting connection + const preflight = await dockerPreflight(); + if (!preflight.ok) { + throw new Error(preflight.remedy); + } + + const containerId = await resolveContainerId(dockerConfig); + return new DockerHostConnection(dockerConfig, containerId); + } + + async testConnection(config: HostConnectionConfig): Promise { + const dockerConfig = config as DockerConnection; + + // 1. Docker daemon preflight + const preflight = await dockerPreflight(); + if (!preflight.ok) { + return { + success: false, + message: preflight.remedy, + details: { os: process.platform }, + }; + } + + // 2. Resolve container + let containerId: string; + try { + containerId = await resolveContainerId(dockerConfig); + } catch (err) { + return { + success: false, + message: String(err), + details: { os: process.platform }, + }; + } + + // 3. Test CLI inside container + const conn = new DockerHostConnection(dockerConfig, containerId); + try { + const cliCheck = await conn.testOpenClawCli(); + const gwStatus = cliCheck.ok ? await conn.gatewayHealthCheck() : undefined; + return { + success: cliCheck.ok, + message: cliCheck.ok + ? `OpenClaw CLI found in container ${containerId.slice(0, 12)} (${cliCheck.output ?? ''})` + : `Container ${containerId.slice(0, 12)}: ${cliCheck.error ?? 'CLI not found'}`, + details: { + openclawInstalled: cliCheck.ok, + openclawVersion: cliCheck.output, + gatewayRunning: gwStatus?.state === 'running', + os: process.platform, + hostname: containerId.slice(0, 12), + }, + }; + } finally { + conn.dispose(); + } + } + + getConfigFields(): ConfigField[] { + return [ + { + id: 'containerId', + label: 'Container ID or Name', + type: 'text', + placeholder: 'e.g. my-openclaw or abc123def456', + required: false, + }, + { + id: 'composeService', + label: 'Docker Compose Service', + type: 'text', + placeholder: 'e.g. openclaw (from docker-compose.yml)', + required: false, + }, + { + id: 'composeFile', + label: 'Compose File Path', + type: 'text', + placeholder: 'Leave blank for default docker-compose.yml', + required: false, + }, + { + id: 'dockerHost', + label: 'Docker Host (DOCKER_HOST)', + type: 'text', + placeholder: 'Leave blank for local socket', + required: false, + }, + { + id: 'shell', + label: 'Shell inside container', + type: 'text', + placeholder: '/bin/sh (default) or /bin/bash', + defaultValue: '/bin/sh', + required: false, + }, + ]; + } + + validateConfig(config: HostConnectionConfig): ConfigValidationResult { + const dockerConfig = config as DockerConnection; + if (!dockerConfig.containerId && !dockerConfig.containerLabel && !dockerConfig.composeService) { + return { + valid: false, + errors: [ + { + fieldId: 'containerId', + message: 'Provide at least one of: Container ID/Name or Compose Service.', + }, + ], + }; + } + return { valid: true }; + } +} diff --git a/apps/editor/extensions/openclaw-docker/src/connection.ts b/apps/editor/extensions/openclaw-docker/src/connection.ts new file mode 100644 index 00000000..c85fc7c5 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/connection.ts @@ -0,0 +1,324 @@ +import * as vscode from 'vscode'; +import * as cp from 'child_process'; +import * as os from 'os'; +import * as path from 'path'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + GatewayRunState, + OpenClawConfig, + SetupParams, + DockerConnection, +} from '../../openclaw/src/hosts/types'; + +// ───────────────────────────────────────────── +// DockerHostConnection +// Runs all commands inside a Docker container via `docker exec`. +// ───────────────────────────────────────────── + +export class DockerHostConnection implements HostConnection { + readonly type: HostType = 'docker'; + + get id(): string { return `docker:${this._containerId}`; } + get label(): string { return this._config.containerLabel ?? this._containerId; } + + private _containerId: string; + + constructor( + private readonly _config: DockerConnection, + resolvedContainerId: string, + ) { + this._containerId = resolvedContainerId; + } + + // ── vscode.Disposable ───────────────────── + + dispose(): void { + // No persistent resources to release + } + + // ── docker exec helper ──────────────────── + + private _dockerArgs(cmd: string, args: string[]): string[] { + const dockerExecArgs = ['exec', this._containerId]; + if (this._config.shell) { + return [...dockerExecArgs, this._config.shell, '-c', [cmd, ...args.map(a => `'${a.replace(/'/g, "'\\''")}'`)].join(' ')]; + } + return [...dockerExecArgs, cmd, ...args]; + } + + private _dockerHost(): string[] { + if (this._config.dockerHost) { + return ['-H', this._config.dockerHost]; + } + return []; + } + + // ── Process execution ───────────────────── + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + return new Promise((resolve, reject) => { + const fullArgs = [...this._dockerHost(), ...this._dockerArgs(cmd, args)]; + const proc = cp.spawn('docker', fullArgs, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + + if (opts.stdinData !== undefined) { + proc.stdin.write(opts.stdinData); + proc.stdin.end(); + } + + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', reject); + proc.on('close', code => resolve({ stdout, stderr, code: code ?? -1 })); + }); + } + + execStream( + cmd: string, + args: string[], + opts: ExecOpts, + onData: LogFn, + onError: LogFn, + ): Promise { + return new Promise((resolve, reject) => { + const fullArgs = [...this._dockerHost(), ...this._dockerArgs(cmd, args)]; + const proc = cp.spawn('docker', fullArgs, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + + if (opts.stdinData !== undefined) { + proc.stdin.write(opts.stdinData); + proc.stdin.end(); + } + + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + // ── Filesystem (via docker exec cat / tee / stat) ────────────────────────── + + async readFile(filePath: string): Promise { + const result = await this.exec('cat', [filePath]); + if (result.code !== 0) { + throw new Error(`cat ${filePath} failed: ${result.stderr}`); + } + return result.stdout; + } + + async writeFile(filePath: string, content: string): Promise { + const dir = path.posix.dirname(filePath); + await this.exec('mkdir', ['-p', dir]); + // Pipe content via stdin to tee + await new Promise((resolve, reject) => { + const fullArgs = [...this._dockerHost(), 'exec', '-i', this._containerId, 'tee', filePath]; + const proc = cp.spawn('docker', fullArgs, { windowsHide: true }); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('error', reject); + proc.on('close', code => { + if (code === 0) { resolve(); } + else { reject(new Error(`tee ${filePath} failed with code ${code}`)); } + }); + }); + } + + async exists(filePath: string): Promise { + const result = await this.exec('test', ['-e', filePath]); + return result.code === 0; + } + + async mkdir(dirPath: string): Promise { + await this.exec('mkdir', ['-p', dirPath]); + } + + async stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + const result = await this.exec('stat', ['-c', '%s %F', filePath]); + if (result.code !== 0) { return null; } + const [sizeStr, type] = result.stdout.trim().split(' '); + return { + size: parseInt(sizeStr ?? '0', 10), + isDirectory: (type ?? '').includes('directory'), + }; + } + + // ── CLI ─────────────────────────────────── + + async findOpenClawPath(): Promise { + const result = await this.exec('which', ['openclaw']); + if (result.code === 0) { + const p = result.stdout.trim().split('\n')[0]; + if (p) { return p; } + } + // Well-known paths inside the container + for (const p of ['/usr/local/bin/openclaw', '/usr/bin/openclaw', `${os.homedir()}/.local/bin/openclaw`]) { + if (await this.exists(p)) { return p; } + } + return undefined; + } + + async isCliInstalled(): Promise { + return (await this.findOpenClawPath()) !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const result = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (result.stdout + result.stderr).trim(); + const match = out.match(/[\d]+\.[\d]+\.[\d]+/); + return match ? match[0] : out || null; + } catch { + return null; + } + } + + async testOpenClawCli(): Promise { + const p = await this.findOpenClawPath(); + const command = p ? `docker exec ${this._containerId} ${p} --version` : 'openclaw (not found)'; + if (!p) { + return { ok: false, command, error: 'OpenClaw CLI not found inside container' }; + } + try { + const result = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (result.stdout + result.stderr).trim(); + if (result.code === 0) { + return { ok: true, command, output: out }; + } + return { ok: false, command, error: `Exit code ${result.code}: ${out}` }; + } catch (err) { + return { ok: false, command, error: String(err) }; + } + } + + async installCli(onLog: LogFn): Promise { + onLog('Installing OpenClaw inside container...\n'); + const code = await this.execStream( + 'bash', + ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], + { timeout: 120_000 }, + onLog, + onLog, + ); + if (code !== 0) { + throw new Error(`Installer exited with code ${code}`); + } + onLog('OpenClaw installed in container.\n'); + } + + // ── OpenClaw config ─────────────────────── + + async getConfigPath(): Promise { + return '/root/.openclaw/openclaw.json'; // containers typically run as root + } + + async readConfig(): Promise { + const cfgPath = await this.getConfigPath(); + if (!(await this.exists(cfgPath))) { return {}; } + try { + const raw = await this.readFile(cfgPath); + return JSON.parse(raw) as OpenClawConfig; + } catch { + return {}; + } + } + + async writeConfig(patch: Partial): Promise { + const cfgPath = await this.getConfigPath(); + let existing: OpenClawConfig = {}; + if (await this.exists(cfgPath)) { + try { existing = JSON.parse(await this.readFile(cfgPath)) as OpenClawConfig; } catch { /* ok */ } + } + const merged = { ...existing, ...patch }; + await this.writeFile(cfgPath, JSON.stringify(merged, null, 2)); + } + + // ── Gateway ─────────────────────────────── + + async gatewayHealthCheck(): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { + return { state: 'unknown' as GatewayRunState, error: 'CLI not installed' }; + } + try { + const result = await this.exec(cliPath, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (result.code === 0) { + try { + const parsed = JSON.parse(result.stdout) as Partial; + return { + state: parsed.state ?? 'unknown', + port: parsed.port ?? this._config.portMappings?.gateway, + version: parsed.version, + uptime: parsed.uptime, + }; + } catch { + const out = (result.stdout + result.stderr).toLowerCase(); + const running = out.includes('running') || out.includes('started'); + return { state: running ? 'running' : 'stopped', port: this._config.portMappings?.gateway }; + } + } + return { state: 'stopped' as GatewayRunState }; + } catch { + return { state: 'error' as GatewayRunState, error: 'Health check failed' }; + } + } + + async gatewayStart(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited with code ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited with code ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(cliPath, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } + } + + // ── Full install + onboard ──────────────── + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { throw new Error('OpenClaw CLI not installed — call installCli first'); } + const args = ['onboard', + '--provider', params.provider, + '--api-key', params.apiKey, + '--port', params.port, + ]; + const code = await this.execStream(cliPath, args, {}, onLog, onLog); + if (code !== 0) { throw new Error(`onboard exited with code ${code}`); } + } + + // ── Environment ─────────────────────────── + + buildExecEnv(): Record { + return { ...process.env }; + } +} diff --git a/apps/editor/extensions/openclaw-docker/src/extension.ts b/apps/editor/extensions/openclaw-docker/src/extension.ts new file mode 100644 index 00000000..a49e0c93 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/extension.ts @@ -0,0 +1,30 @@ +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { DockerHostAdapter } from './adapter'; + +export async function activate(context: vscode.ExtensionContext): Promise { + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (!coreExt) { + console.warn('[openclaw-docker] Core extension openclaw.home not found — docker adapter not registered'); + return; + } + + const coreAPI = coreExt.isActive + ? coreExt.exports + : await coreExt.activate(); + + if (!coreAPI || typeof coreAPI.registerHostAdapter !== 'function') { + console.warn('[openclaw-docker] Core extension did not export OpenClawCoreAPI — docker adapter not registered'); + return; + } + + const adapter = new DockerHostAdapter(); + const disposable = coreAPI.registerHostAdapter(adapter); + context.subscriptions.push(disposable); + + console.log('[openclaw-docker] DockerHostAdapter registered'); +} + +export function deactivate(): void { + // Subscriptions cleaned up automatically +} diff --git a/apps/editor/extensions/openclaw-docker/src/preflight.ts b/apps/editor/extensions/openclaw-docker/src/preflight.ts new file mode 100644 index 00000000..d45a30c5 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/preflight.ts @@ -0,0 +1,190 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; + +// ───────────────────────────────────────────── +// Docker preflight results +// ───────────────────────────────────────────── + +export type DockerPreflightStatus = + | { ok: true } + | { ok: false; reason: 'cli_missing'; remedy: string } + | { ok: false; reason: 'daemon_down'; remedy: string } + | { ok: false; reason: 'permission_denied'; remedy: string } + | { ok: false; reason: 'unknown'; error: string; remedy: string }; + +// ───────────────────────────────────────────── +// Platform remedies +// ───────────────────────────────────────────── + +function installRemedy(): string { + switch (process.platform) { + case 'darwin': + return ( + 'Docker is not installed.\n\n' + + 'Install options:\n' + + ' • OrbStack (recommended): https://orbstack.dev — lightweight, fast\n' + + ' • Docker Desktop: https://www.docker.com/products/docker-desktop\n' + + ' • Homebrew: brew install --cask docker' + ); + case 'win32': + return ( + 'Docker is not installed.\n\n' + + 'Install options:\n' + + ' • Docker Desktop for Windows: https://www.docker.com/products/docker-desktop\n' + + ' Requires WSL 2 (Windows Subsystem for Linux).\n' + + ' Enable WSL 2 first: wsl --install' + ); + default: // linux + return ( + 'Docker is not installed.\n\n' + + 'Install options:\n' + + ' • Ubuntu/Debian: sudo apt-get install docker.io && sudo systemctl enable --now docker\n' + + ' • Fedora/RHEL: sudo dnf install docker-ce && sudo systemctl enable --now docker\n' + + ' • Official script: curl -fsSL https://get.docker.com | sh' + ); + } +} + +function daemonDownRemedy(): string { + switch (process.platform) { + case 'darwin': + return ( + 'Docker daemon is not running.\n\n' + + 'Start it:\n' + + ' • OrbStack: open the OrbStack app in your Applications folder\n' + + ' • Docker Desktop: open Docker Desktop from your Applications folder\n' + + ' • After starting, wait ~10 s for the daemon to be ready' + ); + case 'win32': + return ( + 'Docker daemon is not running.\n\n' + + 'Start it:\n' + + ' • Open Docker Desktop from the Start menu or system tray\n' + + ' • Wait for the whale icon to stop animating\n' + + ' • Ensure WSL 2 backend is enabled in Docker Desktop → Settings → General' + ); + default: // linux + return ( + 'Docker daemon is not running.\n\n' + + 'Start it:\n' + + ' • sudo systemctl start docker\n' + + ' • To auto-start on boot: sudo systemctl enable docker' + ); + } +} + +function permissionRemedy(): string { + switch (process.platform) { + case 'darwin': + return ( + 'Permission denied connecting to Docker socket.\n\n' + + 'If you are using OrbStack or Docker Desktop, try restarting the app.\n' + + 'If you installed Docker CLI manually: sudo usermod -aG docker $USER && newgrp docker' + ); + case 'win32': + return ( + 'Permission denied connecting to Docker socket.\n\n' + + 'Make sure your Windows user account has access to Docker Desktop.\n' + + 'Open Docker Desktop → Settings → General → "Use WSL 2 based engine" and restart.' + ); + default: // linux + return ( + 'Permission denied connecting to Docker socket.\n\n' + + 'Add your user to the docker group:\n' + + ' sudo usermod -aG docker $USER\n' + + 'Then log out and back in (or run: newgrp docker)' + ); + } +} + +// ───────────────────────────────────────────── +// Check helpers +// ───────────────────────────────────────────── + +function isDockerCliOnPath(): boolean { + try { + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + const result = cp.spawnSync(whichCmd, ['docker'], { timeout: 5000 }); + return result.status === 0 && result.stdout.toString().trim().length > 0; + } catch { + return false; + } +} + +function isDockerCliOnWellKnownPath(): boolean { + const paths = process.platform === 'win32' + ? [ + 'C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe', + 'C:\\ProgramData\\DockerDesktop\\version-bin\\docker.exe', + ] + : process.platform === 'darwin' + ? [ + '/usr/local/bin/docker', + '/opt/homebrew/bin/docker', + '/Applications/OrbStack.app/Contents/MacOS/xbin/docker', + '/Applications/Docker.app/Contents/Resources/bin/docker', + ] + : [ + '/usr/bin/docker', + '/usr/local/bin/docker', + '/snap/bin/docker', + ]; + return paths.some(p => fs.existsSync(p)); +} + +function runDockerInfo(): { ok: boolean; permissionDenied: boolean; daemonDown: boolean } { + try { + const result = cp.spawnSync('docker', ['info'], { + timeout: 8000, + windowsHide: true, + }); + if (result.status === 0) { + return { ok: true, permissionDenied: false, daemonDown: false }; + } + const combined = (result.stdout?.toString() ?? '') + (result.stderr?.toString() ?? ''); + const lower = combined.toLowerCase(); + const permissionDenied = lower.includes('permission denied') || lower.includes('access is denied'); + const daemonDown = + lower.includes('cannot connect') || + lower.includes('is the docker daemon running') || + lower.includes('error during connect') || + lower.includes('pipe') || + lower.includes('no such file'); + return { ok: false, permissionDenied, daemonDown }; + } catch { + return { ok: false, permissionDenied: false, daemonDown: true }; + } +} + +// ───────────────────────────────────────────── +// Main preflight check +// ───────────────────────────────────────────── + +export async function dockerPreflight(): Promise { + const cliPresent = isDockerCliOnPath() || isDockerCliOnWellKnownPath(); + + if (!cliPresent) { + return { ok: false, reason: 'cli_missing', remedy: installRemedy() }; + } + + const info = runDockerInfo(); + + if (info.ok) { + return { ok: true }; + } + + if (info.permissionDenied) { + return { ok: false, reason: 'permission_denied', remedy: permissionRemedy() }; + } + + if (info.daemonDown) { + return { ok: false, reason: 'daemon_down', remedy: daemonDownRemedy() }; + } + + return { + ok: false, + reason: 'unknown', + error: 'docker info failed for unknown reason', + remedy: daemonDownRemedy(), + }; +} diff --git a/apps/editor/extensions/openclaw-ssh/src/extension.ts b/apps/editor/extensions/openclaw-ssh/src/extension.ts new file mode 100644 index 00000000..55a38ee4 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/extension.ts @@ -0,0 +1,13 @@ +import * as vscode from 'vscode'; + +/** + * openclaw-ssh — Preview stub. + * + * SSH host support is coming soon. This extension activates silently + * and will register an SSHHostAdapter once the implementation lands. + */ +export function activate(_context: vscode.ExtensionContext): void { + console.log('[openclaw-ssh] SSH adapter — coming soon'); +} + +export function deactivate(): void {} From c5a28cde10a327b1f0ac9f5f9902a1bca7a44e4b Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 04:57:04 -0400 Subject: [PATCH 018/294] fix(multihost): correct adapter extension main paths after TS rootDir inference TypeScript infers the rootDir as apps/editor/extensions/ (common ancestor) when import-type references span across extension directories. This causes output files to land in out/openclaw-local/src/ and out/openclaw-docker/src/ rather than out/. Fix: update "main" in each adapter's package.json to match the actual compiled path: - openclaw-local: ./out/openclaw-local/src/extension - openclaw-docker: ./out/openclaw-docker/src/extension - openclaw-ssh: ./out/extension (no cross-extension imports, unaffected) All four extensions verified with clean builds. Co-Authored-By: Claude Sonnet 4.6 --- apps/editor/extensions/openclaw-docker/package.json | 2 +- apps/editor/extensions/openclaw-local/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/editor/extensions/openclaw-docker/package.json b/apps/editor/extensions/openclaw-docker/package.json index d2a8efa3..53298d49 100644 --- a/apps/editor/extensions/openclaw-docker/package.json +++ b/apps/editor/extensions/openclaw-docker/package.json @@ -9,7 +9,7 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/extension", + "main": "./out/openclaw-docker/src/extension", "contributes": {}, "scripts": { "compile": "tsc -p ./", diff --git a/apps/editor/extensions/openclaw-local/package.json b/apps/editor/extensions/openclaw-local/package.json index e73b1b71..d34547dd 100644 --- a/apps/editor/extensions/openclaw-local/package.json +++ b/apps/editor/extensions/openclaw-local/package.json @@ -9,7 +9,7 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/extension", + "main": "./out/openclaw-local/src/extension", "contributes": {}, "scripts": { "compile": "tsc -p ./", From 399373cbf051bbf00c1b963117a0b3ebb2422840 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Fri, 20 Mar 2026 05:14:48 -0400 Subject: [PATCH 019/294] =?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 | 299 +++++++++++ .../extensions/openclaw/src/hosts/types.ts | 2 + .../extensions/openclaw/src/panels/home.ts | 471 +++++------------- 4 files changed, 437 insertions(+), 337 deletions(-) create mode 100644 apps/editor/extensions/openclaw/src/hosts/localDefault.ts diff --git a/apps/editor/extensions/openclaw-local/src/connection.ts b/apps/editor/extensions/openclaw-local/src/connection.ts index de5ee339..d6d9bc9c 100644 --- a/apps/editor/extensions/openclaw-local/src/connection.ts +++ b/apps/editor/extensions/openclaw-local/src/connection.ts @@ -79,6 +79,7 @@ export class LocalHostConnection implements HostConnection { env: { ...process.env, ...opts.env }, timeout: opts.timeout, windowsHide: opts.windowsHide ?? true, + shell: opts.shell, }); if (opts.stdinData !== undefined) { @@ -111,6 +112,7 @@ export class LocalHostConnection implements HostConnection { env: { ...process.env, ...opts.env }, timeout: opts.timeout, windowsHide: opts.windowsHide ?? true, + shell: opts.shell, }); if (opts.stdinData !== undefined) { diff --git a/apps/editor/extensions/openclaw/src/hosts/localDefault.ts b/apps/editor/extensions/openclaw/src/hosts/localDefault.ts new file mode 100644 index 00000000..179b4c98 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/localDefault.ts @@ -0,0 +1,299 @@ +/** + * Minimal local HostConnection used by the core extension as the default + * before any adapter extension registers a LocalHostAdapter. + * + * Implements only the methods HomePanel actively calls. Full implementation + * lives in the openclaw-local adapter extension. + */ + +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + OpenClawConfig, + SetupParams, +} from './types'; +import * as vscode from 'vscode'; + +// ───────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────── + +function openClawConfigFile(): string { + return path.join(os.homedir(), '.openclaw', 'openclaw.json'); +} + +function wellKnownCliPaths(): string[] { + const home = os.homedir(); + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'); + return [ + path.join(appData, 'npm', 'openclaw.cmd'), + path.join(appData, 'npm', 'openclaw.exe'), + ]; + } + return [ + '/usr/local/bin/openclaw', + '/opt/homebrew/bin/openclaw', + path.join(home, '.local', 'bin', 'openclaw'), + path.join(home, '.npm-global', 'bin', 'openclaw'), + ]; +} + +function buildLocalEnv(): Record { + const env = { ...process.env }; + const basePath = env.PATH ?? (env as Record).Path ?? ''; + const extra: string[] = []; + if (process.platform === 'win32') { + const appData = env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'); + extra.push(path.join(appData, 'npm')); + } else { + extra.push('/usr/local/bin', '/opt/homebrew/bin'); + extra.push(path.join(os.homedir(), '.local', 'bin')); + extra.push(path.join(os.homedir(), '.npm-global', 'bin')); + extra.push(path.join(os.homedir(), '.openclaw', 'bin')); + // nvm paths + const nvmDir = process.env.NVM_DIR ?? path.join(os.homedir(), '.nvm'); + const nvmVersionsDir = path.join(nvmDir, 'versions', 'node'); + if (fs.existsSync(nvmVersionsDir)) { + try { + fs.readdirSync(nvmVersionsDir) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + .slice(0, 3) + .forEach(v => extra.push(path.join(nvmVersionsDir, v, 'bin'))); + } catch { /* non-fatal */ } + } + if (process.platform === 'darwin') { + extra.push('/opt/homebrew/opt/node/bin', '/usr/local/opt/node/bin'); + } + } + const sep = process.platform === 'win32' ? ';' : ':'; + env.PATH = [...extra, basePath].filter(Boolean).join(sep); + (env as Record).Path = env.PATH; + return env; +} + +// ───────────────────────────────────────────── +// DefaultLocalHostConnection +// ───────────────────────────────────────────── + +export class DefaultLocalHostConnection implements HostConnection { + readonly id = 'local'; + readonly type: HostType = 'local'; + readonly label = 'Local'; + + dispose(): void {} + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + shell: opts.shell, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + let stdout = ''; let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', reject); + proc.on('close', code => resolve({ stdout, stderr, code: code ?? -1 })); + }); + } + + execStream(cmd: string, args: string[], opts: ExecOpts, onData: LogFn, onError: LogFn): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + shell: opts.shell, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + readFile(filePath: string): Promise { + return Promise.resolve(fs.readFileSync(filePath, 'utf-8')); + } + + writeFile(filePath: string, content: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); + return Promise.resolve(); + } + + exists(filePath: string): Promise { + return Promise.resolve(fs.existsSync(filePath)); + } + + mkdir(dirPath: string): Promise { + fs.mkdirSync(dirPath, { recursive: true }); + return Promise.resolve(); + } + + stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + try { + const s = fs.statSync(filePath); + return Promise.resolve({ size: s.size, isDirectory: s.isDirectory() }); + } catch { + return Promise.resolve(null); + } + } + + async findOpenClawPath(): Promise { + const cfg = vscode.workspace.getConfiguration('openclaw').get('cliPath'); + if (cfg && fs.existsSync(cfg)) { return cfg; } + const env = process.env.OPENCLAW_CLI; + if (env && fs.existsSync(env)) { return env; } + // which/where + try { + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + const r = await this.exec(whichCmd, ['openclaw'], { timeout: 5000 }); + const found = r.stdout.trim().split('\n')[0]; + if (found && fs.existsSync(found)) { return found; } + } catch { /* ignore */ } + // Well-known paths + for (const p of wellKnownCliPaths()) { + if (fs.existsSync(p)) { return p; } + } + // nvm paths + const nvmDir = path.join(os.homedir(), '.nvm', 'versions', 'node'); + if (fs.existsSync(nvmDir)) { + for (const ver of fs.readdirSync(nvmDir).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))) { + const p = path.join(nvmDir, ver, 'bin', 'openclaw'); + if (fs.existsSync(p)) { return p; } + } + } + return undefined; + } + + async isCliInstalled(): Promise { + return (await this.findOpenClawPath()) !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const r = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (r.stdout + r.stderr).trim(); + const m = out.match(/[\d]+\.[\d]+\.[\d]+/); + return m ? m[0] : out || null; + } catch { return null; } + } + + async testOpenClawCli(): Promise { + // On unix: try sourcing nvm first for version-managed installs + if (process.platform !== 'win32') { + const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); + if (fs.existsSync(nvmSh)) { + try { + const r = await this.exec('bash', ['-c', `. "${nvmSh}" 2>/dev/null && openclaw --version 2>&1`], { timeout: 10000 }); + const line = r.stdout.trim().split('\n').find(l => /\d/.test(l) && !l.startsWith('nvm') && !l.startsWith('Now')) ?? ''; + if (line) { return { ok: true, output: line, command: `bash -c '. "${nvmSh}" && openclaw --version'` }; } + } catch { /* fall through */ } + } + } + const p = await this.findOpenClawPath(); + const command = p ? `${p} --version` : 'openclaw --version'; + if (!p) { return { ok: false, command, error: 'openclaw not found' }; } + try { + const r = await this.exec(p, ['--version'], { timeout: 15000 }); + const out = (r.stdout + r.stderr).trim(); + return r.code === 0 + ? { ok: true, output: out, command } + : { ok: false, error: out || `Exit ${r.code}`, command }; + } catch (err) { return { ok: false, command, error: String(err) }; } + } + + async installCli(onLog: LogFn): Promise { + if (process.platform === 'win32') { + const code = await this.execStream('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', 'irm https://get.openclaw.sh/win | iex'], { timeout: 120_000, windowsHide: true }, onLog, onLog); + if (code !== 0) { throw new Error(`Installer exited with code ${code}`); } + } else { + const code = await this.execStream('bash', ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], { timeout: 120_000 }, onLog, onLog); + if (code !== 0) { throw new Error(`Installer exited with code ${code}`); } + } + } + + getConfigPath(): Promise { return Promise.resolve(openClawConfigFile()); } + + async readConfig(): Promise { + const p = openClawConfigFile(); + if (!fs.existsSync(p)) { return {}; } + try { return JSON.parse(fs.readFileSync(p, 'utf-8')) as OpenClawConfig; } catch { return {}; } + } + + async writeConfig(patch: Partial): Promise { + const p = openClawConfigFile(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + let existing: OpenClawConfig = {}; + if (fs.existsSync(p)) { try { existing = JSON.parse(fs.readFileSync(p, 'utf-8')) as OpenClawConfig; } catch { /* ok */ } } + fs.writeFileSync(p, JSON.stringify({ ...existing, ...patch }, null, 2), 'utf-8'); + } + + async gatewayHealthCheck(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return { state: 'unknown', error: 'CLI not installed' }; } + try { + const r = await this.exec(p, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (r.code === 0) { + try { + const parsed = JSON.parse(r.stdout) as Partial; + return { state: parsed.state ?? 'unknown', port: parsed.port, version: parsed.version, uptime: parsed.uptime }; + } catch { + const out = (r.stdout + r.stderr).toLowerCase(); + return { state: out.includes('running') ? 'running' : 'stopped' }; + } + } + return { state: 'stopped' }; + } catch { return { state: 'error', error: 'Health check failed' }; } + } + + async gatewayStart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited ${code}`); } + } + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('CLI not installed'); } + const code = await this.execStream(p, ['onboard', '--provider', params.provider, '--api-key', params.apiKey, '--port', params.port], {}, onLog, onLog); + if (code !== 0) { throw new Error(`onboard exited ${code}`); } + } + + buildExecEnv(): Record { + return buildLocalEnv(); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts index 2d82e9f2..86a628b0 100644 --- a/apps/editor/extensions/openclaw/src/hosts/types.ts +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -13,6 +13,8 @@ export interface ExecOpts { /** Bytes to pipe to stdin before closing it */ stdinData?: string; windowsHide?: boolean; + /** Run via OS shell (required for .cmd/.bat shims on Windows) */ + shell?: boolean; } export interface ExecResult { diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 0184fc40..20af41df 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -5,6 +5,8 @@ import * as http from 'http'; import * as https from 'https'; import * as os from 'os'; import * as path from 'path'; +import type { HostConnection, OpenClawCoreAPI } from '../hosts/types'; +import { DefaultLocalHostConnection } from '../hosts/localDefault'; type GatewayStatus = 'checking' | 'running' | 'stopped' | 'starting' | 'stopping' | 'restarting' | 'errored' | 'ai-fixing'; @@ -102,11 +104,27 @@ export class HomePanel { private _closeSidebarOnGatewayStart = false; // close sidebar once gateway reaches running after first install private _uninstallCloseSidebarTimer: ReturnType | undefined; private _uninstallCloseWatcher: ReturnType | undefined; + /** Active host connection — defaults to local; swapped when the user picks a remote host. */ + private _host: HostConnection = new DefaultLocalHostConnection(); + /** Cached gateway port — populated on every _update() so _getConfiguredPort() stays sync. */ + private _cachedGatewayPort = 18789; private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { this._panel = panel; this._extensionUri = extensionUri; this._outputChannel = vscode.window.createOutputChannel('OpenClaw Gateway'); + // Subscribe to active host changes from the core extension if available. + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (coreExt?.isActive && coreExt.exports != null) { + const coreAPI = coreExt.exports; + const hostSub = coreAPI.onDidChangeActiveHost(conn => { + this._host = conn ?? new DefaultLocalHostConnection(); + void this._update(); + }); + this._disposables.push(hostSub); + const active = coreAPI.getActiveHost(); + if (active) { this._host = active; } + } const iconUri = this._panel.webview.asWebviewUri( vscode.Uri.joinPath(this._extensionUri, 'media', 'icon.png') ); @@ -723,11 +741,26 @@ export class HomePanel { } private async _update() { + // Get config path and check existence via the active host. + const configFile = await this._host.getConfigPath(); + const isConfigured = await this._host.exists(configFile); + + // Update cached port from config (keeps _getConfiguredPort() synchronous). + if (isConfigured) { + try { + const cfg = await this._host.readConfig(); + const gateway = cfg['gateway'] as Record | undefined; + const p = gateway?.['port'] ?? cfg['port'] ?? cfg['gateway_port'] ?? cfg['gatewayPort']; + const n = typeof p === 'string' ? parseInt(p, 10) : typeof p === 'number' ? p : NaN; + if (Number.isFinite(n) && n > 0 && n < 65536) { + this._cachedGatewayPort = n; + } + } catch { /* non-fatal */ } + } + + // Silently fix ownership if ~/.openclaw was created as root (local host only, non-interactive sudo). const openclawDir = path.join(os.homedir(), '.openclaw'); const dirExists = fs.existsSync(openclawDir); - - // Silently fix ownership if ~/.openclaw was created as root (e.g. after a sudo install). - // sudo -n is non-interactive — only succeeds if a sudo session is still cached; otherwise a no-op. if (dirExists && process.platform !== 'win32') { try { const stat = fs.statSync(openclawDir); @@ -740,8 +773,6 @@ export class HomePanel { const cliCheck = await this._testOpenClawCli(); writeLog(`[cli-check] ok=${cliCheck.ok} cmd="${cliCheck.command}" output="${(cliCheck.output ?? '').trim()}"\n`); - const configFile = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - const isConfigured = fs.existsSync(configFile); const isInstalled = isConfigured; // config file is the sole source of truth — a leftover binary without config is not "installed" this._lastInstalledState = isInstalled; this._lastInstalledVersion = cliCheck.ok ? (cliCheck.output ?? '').trim() : null; @@ -770,11 +801,10 @@ export class HomePanel { const emojiBaseUri = this._panel.webview.asWebviewUri( vscode.Uri.joinPath(this._extensionUri, 'media', 'emojis') ).toString(); - // Read AI model info from openclaw.json + // Read AI model info from openclaw.json (via active host) let aiModelName = ''; try { - const raw = fs.readFileSync(configFile, 'utf-8'); - const cfg = JSON.parse(raw) as Record; + const cfg = await this._host.readConfig() as Record; const primaryModel = (cfg as Record>>>) ?.agents?.defaults?.model?.primary ?? ''; if (primaryModel) { @@ -812,21 +842,11 @@ export class HomePanel { // ── Gateway status helpers ───────────────────────────────────────────────── /** - * Reads the gateway port from ~/.openclaw/openclaw.json. - * Falls back to 18789 if the file is missing or the field is absent. + * Returns the last-known gateway port (populated by _update() via host.readConfig()). + * Falls back to 18789 before the first update completes. */ private _getConfiguredPort(): number { - try { - const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - const raw = fs.readFileSync(configPath, 'utf-8'); - const config = JSON.parse(raw) as Record; - const gateway = config['gateway'] as Record | undefined; - const p = gateway?.['port'] ?? config['port'] ?? config['gateway_port'] ?? config['gatewayPort']; - const n = typeof p === 'string' ? parseInt(p, 10) : typeof p === 'number' ? p : NaN; - return Number.isFinite(n) && n > 0 && n < 65536 ? n : 18789; - } catch { - return 18789; - } + return this._cachedGatewayPort; } /** Raw HTTP probe against the configured port — no _commandAction guard. Used by the polling loop. */ @@ -854,13 +874,16 @@ export class HomePanel { } /** - * Fast synchronous check — ~/.openclaw/openclaw.json is the single - * definitive signal that OpenClaw is installed and initialised. + * Check — openclaw.json is the single definitive signal that OpenClaw + * is installed and initialised on the active host. */ - private _quickInstallCheck(): boolean { - // Config file is the sole source of truth — no config means not installed, - // even if the binary is still on PATH. - return fs.existsSync(path.join(os.homedir(), '.openclaw', 'openclaw.json')); + private async _quickInstallCheck(): Promise { + try { + const cfgPath = await this._host.getConfigPath(); + return this._host.exists(cfgPath); + } catch { + return false; + } } private _startPolling(): void { @@ -874,7 +897,7 @@ export class HomePanel { // No process spawn — just cheap stat calls. If the result differs from // the last known state, do a full _update() to confirm and re-render. if (this._pollTick % 2 === 0) { - const nowInstalled = this._quickInstallCheck(); + const nowInstalled = await this._quickInstallCheck(); if (nowInstalled !== this._lastInstalledState) { void this._update(); return; @@ -1200,26 +1223,21 @@ export class HomePanel { // openclaw requires Node.js >= 22. Check the active version and auto-install // via nvm if needed — the editor pins v20, so users may only have v20 active. if (process.platform !== 'win32') { - const nodeVerRaw = await new Promise(resolve => { - cp.exec('node --version', { env, timeout: 5000 }, (err, stdout) => - resolve((stdout || '').toString().trim()) - ); - }); + const nodeResult = await this._host.exec('node', ['--version'], { env: env as Record, timeout: 5000 }); + const nodeVerRaw = nodeResult.stdout.trim(); const nodeMinor = parseInt((nodeVerRaw.match(/^v?(\d+)/) || [])[1] || '0', 10); if (nodeMinor < 22) { const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); if (fs.existsSync(nvmSh)) { wizardPost(`Node.js ${nodeVerRaw || 'unknown'} detected — openclaw requires v22+. Installing Node.js 22 via nvm...\n`, false, false); - const nvmR = await new Promise(resolve => { - const child = cp.spawn('bash', ['-c', - `. "${nvmSh}" && nvm install 22 && nvm use 22 && nvm alias default 22` - ], { env, stdio: ['ignore', 'pipe', 'pipe'] }); - child.stdout?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); - child.stderr?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); - child.on('close', code => resolve(code ?? 1)); - child.on('error', () => resolve(1)); - }); - if (nvmR === 0) { + const nvmCode = await this._host.execStream( + 'bash', + ['-c', `. "${nvmSh}" && nvm install 22 && nvm use 22 && nvm alias default 22`], + { env: env as Record }, + (d) => wizardPost(d, false, false), + (d) => wizardPost(d, false, false), + ); + if (nvmCode === 0) { // Rebuild env so the new Node 22 bin is on PATH const nvmVersionsDir = path.join(os.homedir(), '.nvm', 'versions', 'node'); const v22dirs = fs.existsSync(nvmVersionsDir) @@ -1285,83 +1303,77 @@ export class HomePanel { writeLog(`\n=== _runSetup START provider=${data.provider} port=${port} ===\n`); wizardPost(isFree ? 'Installing Inference for MoltPilot...\nInstalling Inference for your new OpenClaw...\n' : 'Installing Inference for your new OpenClaw...\n', false, false); - await new Promise(resolve => { - const child = cp.spawn(cliPath, args, { - env, - stdio: ['ignore', 'pipe', 'pipe'], - ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), - }); - child.stdout?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); - child.stderr?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); - child.on('close', code => { - const ok = code === 0; - writeLog(`=== _runSetup END code=${code} ===\n`); - wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${code}.\n`, true, ok); - if (ok) { - if (isFree) { - // Write local free-tier marker (no remote enforcement). - try { - const occDir = path.join(os.homedir(), '.occ'); - if (!fs.existsSync(occDir)) fs.mkdirSync(occDir, { recursive: true }); - fs.writeFileSync( - path.join(occDir, 'moltpilot-tier.json'), - JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), - ); - } catch { /* non-fatal */ } - // Patch openclaw.json to inject correct cost/context metadata for occ-legacy. - // openclaw onboard writes the model with null/zero values; we fix them here. - try { - const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - const raw = fs.readFileSync(configPath, 'utf-8'); - const cfg = JSON.parse(raw) as Record; - // Recursively find any model object with id === OCC_LEGACY_MODEL_ID and patch it. - const patchModel = (obj: unknown): boolean => { - if (!obj || typeof obj !== 'object') return false; - if (Array.isArray(obj)) { - for (const item of obj) { - if (patchModel(item)) return true; - } - return false; - } - const o = obj as Record; - if (o['id'] === OCC_LEGACY_MODEL_ID) { - o['name'] = OCC_LEGACY_MODEL_NAME; - o['reasoning'] = false; - o['input'] = ['text']; - o['cost'] = { ...OCC_LEGACY_COST }; - o['contextWindow'] = OCC_LEGACY_CONTEXT_WINDOW; - o['maxTokens'] = OCC_LEGACY_MAX_TOKENS; - return true; - } - for (const v of Object.values(o)) { patchModel(v); } - return false; - }; - patchModel(cfg); - fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); - } catch { /* non-fatal — openclaw.json may not exist yet */ } - } - setTimeout(() => { - HomePanel.refresh(); - if (isFree) { - vscode.commands.executeCommand('openclaw.openWorkspace'); + let exitCode: number; + try { + exitCode = await this._host.execStream( + cliPath, args, + { + env: env as Record, + timeout: 120_000, + ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), + }, + (d) => wizardPost(d, false, false), + (d) => wizardPost(d, false, false), + ); + } catch (err) { + wizardPost(`Error: ${String(err)}\n`, true, false); + return; + } + + const ok = exitCode === 0; + writeLog(`=== _runSetup END code=${exitCode} ===\n`); + wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${exitCode}.\n`, true, ok); + + if (ok) { + if (isFree) { + // Write local free-tier marker (extension-host local state — always local fs). + try { + const occDir = path.join(os.homedir(), '.occ'); + if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } + fs.writeFileSync( + path.join(occDir, 'moltpilot-tier.json'), + JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), + ); + } catch { /* non-fatal */ } + // Patch openclaw.json on the active host to inject correct cost/context metadata. + try { + const cfg = await this._host.readConfig() as Record; + const patchModel = (obj: unknown): boolean => { + if (!obj || typeof obj !== 'object') return false; + if (Array.isArray(obj)) { + for (const item of obj) { if (patchModel(item)) return true; } + return false; } - // Give the dashboard time to render, then open a new chat asking AI to start the gateway. - // Set flag so the sidebar auto-closes once the gateway is confirmed running. - setTimeout(() => { - this._closeSidebarOnGatewayStart = true; - vscode.commands.executeCommand('void.openChatWithMessage', - 'Run `openclaw gateway start` to start the OpenClaw gateway.', - 'agent'); - }, 1000); - }, 1500); + const o = obj as Record; + if (o['id'] === OCC_LEGACY_MODEL_ID) { + o['name'] = OCC_LEGACY_MODEL_NAME; + o['reasoning'] = false; + o['input'] = ['text']; + o['cost'] = { ...OCC_LEGACY_COST }; + o['contextWindow'] = OCC_LEGACY_CONTEXT_WINDOW; + o['maxTokens'] = OCC_LEGACY_MAX_TOKENS; + return true; + } + for (const v of Object.values(o)) { patchModel(v); } + return false; + }; + patchModel(cfg); + await this._host.writeConfig(cfg); + } catch { /* non-fatal — openclaw.json may not exist yet */ } + } + setTimeout(() => { + HomePanel.refresh(); + if (isFree) { + vscode.commands.executeCommand('openclaw.openWorkspace'); } - resolve(); - }); - child.on('error', err => { - wizardPost(`Error: ${err.message}\n`, true, false); - resolve(); - }); - }); + setTimeout(() => { + this._closeSidebarOnGatewayStart = true; + vscode.commands.executeCommand('void.openChatWithMessage', + 'Run `openclaw gateway start` to start the OpenClaw gateway.', + 'agent'); + }, 1000); + }, 1500); + } } // ── Uninstall ────────────────────────────────────────────────────────────── @@ -4033,107 +4045,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; } private async _testOpenClawCli(): Promise<{ ok: boolean; output?: string; error?: string; command: string }> { - if (process.platform === 'win32') { - // ── 1. Find openclaw.mjs (checks npm prefix + version-manager paths) ────── - const mjs = await this._findWindowsOpenClawMjs(); - if (mjs) { - // ── 2. Find node.exe (PATH-first, then nvm/Volta/scoop, then hardcoded) ── - const nodeExe = await this._findWindowsNodeExe(); - if (nodeExe) { - return this._spawnNodeMjs(nodeExe, mjs, `"${nodeExe}" "${mjs}" --version`); - } - } - - // ── 3. .cmd / .exe shim fallback (npm prefix + scoop shims) ────────────── - const cmdPath = await this._findWindowsOpenClawCmd(); - if (cmdPath) { - return new Promise(resolve => { - cp.execFile( - 'cmd.exe', ['/c', cmdPath, '--version'], - { timeout: 30000, windowsHide: true, maxBuffer: 1024 * 1024 }, - (error, stdout, stderr) => { - if (error) { - const timedOut = (error as any).signal === 'SIGTERM' || error.code == null; - resolve({ - ok: false, - error: timedOut ? 'Timed out' : (stderr?.toString().trim() || `Exit ${error.code}`), - command: `${cmdPath} --version`, - }); - } else { - resolve({ ok: true, output: (stdout || stderr || '').toString().trim(), command: `${cmdPath} --version` }); - } - } - ); - }); - } - - return { ok: false, error: 'openclaw not found', command: 'openclaw --version' }; - } - - // ── Mac / Linux ────────────────────────────────────────────────────────────── - // Strategy: source nvm/nvm.sh first so the nvm-managed binary takes priority - // over any stale system install (e.g. /usr/local/bin/openclaw). Falls back to - // enumerating ~/.nvm/versions/node/*/bin/openclaw (newest version first), - // then the existing path-based search. - const home = os.homedir(); - const nvmSh = path.join(home, '.nvm', 'nvm.sh'); - - // 1. Try sourcing nvm so it activates the default alias / current version. - if (fs.existsSync(nvmSh)) { - const nvmCmd = `bash -c '. "${nvmSh}" 2>/dev/null && openclaw --version 2>&1'`; - const nvmResult = await new Promise<{ ok: boolean; output?: string; error?: string; command: string }>(resolve => { - cp.exec(nvmCmd, { timeout: 10000, maxBuffer: 1024 * 1024 }, (error, stdout) => { - const out = (stdout || '').toString().trim(); - // Extract just the version line (ignores nvm banner noise) - const line = out.split('\n').find(l => /\d/.test(l) && !l.startsWith('nvm') && !l.startsWith('Now')) || ''; - if (!error && line) { - resolve({ ok: true, output: line.trim(), command: nvmCmd }); - } else { - resolve({ ok: false, error: out || error?.message || 'not found', command: nvmCmd }); - } - }); - }); - if (nvmResult.ok) return nvmResult; - } - - // 2. Enumerate ~/.nvm/versions/node/*/bin/openclaw — newest node version first. - const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); - if (fs.existsSync(nvmVersionsDir)) { - const nodeVersions = fs.readdirSync(nvmVersionsDir) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); // newest first - for (const ver of nodeVersions) { - const candidate = path.join(nvmVersionsDir, ver, 'bin', 'openclaw'); - if (fs.existsSync(candidate)) { - const result = await new Promise<{ ok: boolean; output?: string; error?: string; command: string }>(resolve => { - cp.execFile(candidate, ['--version'], { timeout: 10000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { - const out = (stdout || stderr || '').toString().trim(); - if (error || !out) resolve({ ok: false, error: out || error?.message, command: `${candidate} --version` }); - else resolve({ ok: true, output: out, command: `${candidate} --version` }); - }); - }); - if (result.ok) return result; - } - } - } - - // 3. Fall back to path-based search. - const cliPath = await this._findOpenClawPath(); - if (!cliPath) { - return { ok: false, error: 'openclaw not found', command: 'openclaw --version' }; - } - return new Promise(resolve => { - cp.execFile( - cliPath, ['--version'], - { timeout: 15000, maxBuffer: 1024 * 1024, env: this._buildExecEnv() }, - (error, stdout, stderr) => { - if (error) { - resolve({ ok: false, error: stderr?.toString().trim() || error.message || `Exit ${(error as any).code}`, command: `${cliPath} --version` }); - } else { - resolve({ ok: true, output: (stdout || stderr || '').toString().trim(), command: `${cliPath} --version` }); - } - } - ); - }); + return this._host.testOpenClawCli(); } /** @@ -4290,70 +4202,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; } private async _findOpenClawPath(): Promise { - const cfgPath = vscode.workspace.getConfiguration('openclaw').get('cliPath'); - if (cfgPath && fs.existsSync(cfgPath)) return cfgPath; - - const envPath = process.env.OPENCLAW_CLI; - if (envPath && fs.existsSync(envPath)) return envPath; - - if (process.platform === 'win32') { - const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - const candidates = [ - path.join(appData, 'npm', 'openclaw.cmd'), - path.join(appData, 'npm', 'openclaw.exe'), - path.join(appData, 'npm', 'openclaw.bat'), - path.join(appData, 'npm', 'openclaw.ps1'), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - } - - if (process.platform === 'win32') { - for (const probe of ['openclaw.cmd', 'openclaw.exe', 'openclaw.bat', 'openclaw.ps1', 'openclaw']) { - const result = await this._runCommand(`where ${probe}`, 2000); - if (!result.error && !result.notFound) { - const out = (result.stdout || '').trim(); - if (out) { - const candidates = out - .split(/\r?\n/) - .map(l => l.trim().replace(/^"+|"+$/g, '')) - .filter(Boolean); - for (const candidate of candidates) { - const resolved = this._resolveWindowsCliPath(candidate); - if (fs.existsSync(resolved)) return resolved; - } - } - } - } - } else { - const result = await this._runCommand('which openclaw', 2000); - if (!result.error && !result.notFound) { - const out = (result.stdout || '').trim(); - if (out) { - const candidates = out - .split(/\r?\n/) - .map(l => l.trim().replace(/^"+|"+$/g, '')) - .filter(Boolean); - for (const candidate of candidates) { - const resolved = this._resolveWindowsCliPath(candidate); - if (fs.existsSync(resolved)) return resolved; - } - } - } - } - - const npmCandidates = await this._getNpmGlobalCliCandidates(); - for (const candidate of npmCandidates) { - if (fs.existsSync(candidate)) return candidate; - } - - const fallback = this._getCandidateCliPaths(); - for (const candidate of fallback) { - if (fs.existsSync(candidate)) return candidate; - } - - return undefined; + return this._host.findOpenClawPath(); } private _getCandidateCliPaths(): string[] { @@ -4449,59 +4298,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; }); } - private _buildExecEnv() { - const env = { ...process.env }; - const basePath = env.PATH || (env as any).Path || ''; - const extra: string[] = []; - if (process.platform === 'win32') { - const appData = env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - if (appData) extra.push(path.join(appData, 'npm')); - if (env.ProgramFiles) extra.push(path.join(env.ProgramFiles, 'nodejs')); - if (env.LOCALAPPDATA) extra.push(path.join(env.LOCALAPPDATA, 'Programs', 'nodejs')); - const systemRoot = env.SystemRoot || (env as any).WINDIR; - if (systemRoot) extra.push(path.join(systemRoot, 'System32')); - } else { - extra.push('/usr/local/bin', '/opt/homebrew/bin'); - extra.push(path.join(os.homedir(), '.local', 'bin')); - extra.push(path.join(os.homedir(), '.npm-global', 'bin')); - extra.push(path.join(os.homedir(), '.openclaw', 'bin')); - - // Add nvm-managed Node.js paths (newest version first) - const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm'); - const nvmVersionsDir = path.join(nvmDir, 'versions', 'node'); - if (fs.existsSync(nvmVersionsDir)) { - try { - const versions = fs.readdirSync(nvmVersionsDir) - .filter(e => /^v?\d+/.test(e)) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); - for (const v of versions.slice(0, 3)) { - extra.push(path.join(nvmVersionsDir, v, 'bin')); - } - } catch { /* non-fatal */ } - } - - // Add fnm paths (popular Node version manager on macOS) - const fnmDir = path.join(os.homedir(), '.local', 'share', 'fnm', 'node-versions'); - if (fs.existsSync(fnmDir)) { - try { - const versions = fs.readdirSync(fnmDir) - .filter(e => /^v?\d+/.test(e)) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); - for (const v of versions.slice(0, 3)) { - extra.push(path.join(fnmDir, v, 'installation', 'bin')); - } - } catch { /* non-fatal */ } - } - - // Homebrew Node.js (macOS) - if (process.platform === 'darwin') { - extra.push('/opt/homebrew/opt/node/bin'); - extra.push('/usr/local/opt/node/bin'); - } - } - const sep = process.platform === 'win32' ? ';' : ':'; - env.PATH = [...extra, basePath].filter(Boolean).join(sep); - (env as any).Path = env.PATH; - return env; + private _buildExecEnv(): Record { + return this._host.buildExecEnv(); } } From fa70f41ef09f1bdf0d0ce3deeb67cccc156648a5 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Sat, 21 Mar 2026 23:03:32 -0400 Subject: [PATCH 020/294] feat: extract StatusPanelController + show full status panel in adapter tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract _getHtml() from HomePanel into shared renderStatusHtml() in statusHtml.ts - Create StatusPanelController (statusController.ts) with all behavioral logic: gateway polling, version checks, sign-in/out, CASS/Better Memory setup, workspace file shortcuts — usable by any adapter panel - LocalSetupPanel: if OpenClaw already installed, show full status panel immediately instead of wizard; after setup success, show status panel in-tab instead of delegating to HomePanel - DockerSetupPanel: after configure success, show status panel in-tab instead of disposing the panel - Adapters now delegate all webview messages to StatusPanelController once the status panel is active Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw-docker/package.json | 4 +- .../openclaw-docker/src/extension.ts | 6 + .../openclaw-docker/src/setup-panel.ts | 717 ++++ .../extensions/openclaw-local/package.json | 4 +- .../openclaw-local/src/extension.ts | 6 + .../openclaw-local/src/setup-panel.ts | 1412 ++++++++ .../extensions/openclaw-ssh/package.json | 6 +- .../extensions/openclaw-ssh/src/adapter.ts | 71 + .../extensions/openclaw-ssh/src/connection.ts | 268 ++ .../extensions/openclaw-ssh/src/extension.ts | 31 +- .../openclaw-ssh/src/setup-panel.ts | 341 ++ .../extensions/openclaw/src/extension.ts | 8 +- .../openclaw/src/hosts/hostsFile.ts | 37 + .../extensions/openclaw/src/hosts/manager.ts | 9 + .../extensions/openclaw/src/hosts/types.ts | 1 + .../extensions/openclaw/src/panels/home.ts | 3035 ++--------------- .../openclaw/src/panels/statusController.ts | 678 ++++ .../openclaw/src/panels/statusHtml.ts | 1436 ++++++++ 18 files changed, 5225 insertions(+), 2845 deletions(-) create mode 100644 apps/editor/extensions/openclaw-docker/src/setup-panel.ts create mode 100644 apps/editor/extensions/openclaw-local/src/setup-panel.ts create mode 100644 apps/editor/extensions/openclaw-ssh/src/adapter.ts create mode 100644 apps/editor/extensions/openclaw-ssh/src/connection.ts create mode 100644 apps/editor/extensions/openclaw-ssh/src/setup-panel.ts create mode 100644 apps/editor/extensions/openclaw/src/hosts/hostsFile.ts create mode 100644 apps/editor/extensions/openclaw/src/panels/statusController.ts create mode 100644 apps/editor/extensions/openclaw/src/panels/statusHtml.ts diff --git a/apps/editor/extensions/openclaw-docker/package.json b/apps/editor/extensions/openclaw-docker/package.json index 53298d49..a2cf4302 100644 --- a/apps/editor/extensions/openclaw-docker/package.json +++ b/apps/editor/extensions/openclaw-docker/package.json @@ -10,7 +10,9 @@ "categories": ["Other"], "activationEvents": ["onStartupFinished"], "main": "./out/openclaw-docker/src/extension", - "contributes": {}, + "contributes": { + "commands": [{ "command": "openclaw.host.setup.docker", "title": "OpenClaw: Set up Docker" }] + }, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-docker/src/extension.ts b/apps/editor/extensions/openclaw-docker/src/extension.ts index a49e0c93..63d70fef 100644 --- a/apps/editor/extensions/openclaw-docker/src/extension.ts +++ b/apps/editor/extensions/openclaw-docker/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; import { DockerHostAdapter } from './adapter'; +import { DockerSetupPanel } from './setup-panel'; export async function activate(context: vscode.ExtensionContext): Promise { const coreExt = vscode.extensions.getExtension('openclaw.home'); @@ -22,6 +23,11 @@ export async function activate(context: vscode.ExtensionContext): Promise const disposable = coreAPI.registerHostAdapter(adapter); context.subscriptions.push(disposable); + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.docker', () => { + DockerSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + console.log('[openclaw-docker] DockerHostAdapter registered'); } diff --git a/apps/editor/extensions/openclaw-docker/src/setup-panel.ts b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts new file mode 100644 index 00000000..1379c05f --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts @@ -0,0 +1,717 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { StatusPanelController } from '../../openclaw/src/panels/statusController'; + +export class DockerSetupPanel { + public static currentPanel: DockerSetupPanel | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _dockerWorkspaceHostPath: string | undefined; + private _disposables: vscode.Disposable[] = []; + private _statusController: StatusPanelController | undefined; + + private get _homeUri(): vscode.Uri { + const homeExt = vscode.extensions.getExtension('openclaw.home'); + return homeExt?.extensionUri ?? this._extensionUri; + } + + public static createOrShow(extensionUri: vscode.Uri, coreAPI: OpenClawCoreAPI): void { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (DockerSetupPanel.currentPanel) { + DockerSetupPanel.currentPanel._panel.reveal(column); + return; + } + + const homeExt = vscode.extensions.getExtension('openclaw.home'); + const homeUri = homeExt?.extensionUri ?? extensionUri; + const iconUri = vscode.Uri.joinPath(homeUri, 'media', 'icon.png'); + + const panel = vscode.window.createWebviewPanel( + 'openclawDockerSetup', + 'OCC Home [Docker]', + column ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(homeUri, 'media'), + ], + retainContextWhenHidden: true, + }, + ); + + DockerSetupPanel.currentPanel = new DockerSetupPanel(panel, extensionUri, coreAPI, iconUri); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + private readonly _coreAPI: OpenClawCoreAPI, + iconUri: vscode.Uri, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + + const webviewIconUri = panel.webview.asWebviewUri(iconUri).toString(); + this._panel.webview.html = this._getHtml(webviewIconUri); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + async (msg: { command: string; [key: string]: unknown }) => { + if (this._statusController) { + this._statusController.handleMessage(msg); + return; + } + switch (msg.command) { + case 'dockerPreflightCheck': + await this._handleDockerPreflight(); + break; + case 'dockerFindOrCreate': + await this._handleDockerFindOrCreate(); + break; + case 'dockerInstallCli': + await this._handleDockerInstallCli(); + break; + case 'dockerRunOnboard': + await this._handleDockerConfigure( + msg.provider as string, + msg.apiKey as string, + msg.port as string, + ); + break; + case 'closePanel': + this.dispose(); + break; + default: + if (msg.command) { + void vscode.commands.executeCommand(msg.command); + } + break; + } + }, + null, + this._disposables, + ); + } + + public dispose(): void { + DockerSetupPanel.currentPanel = undefined; + this._statusController?.dispose(); + this._statusController = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) { d.dispose(); } + } + } + + private async _showStatusPanel(): Promise { + if (!this._statusController) { + const { DockerHostConnection } = await import('./connection'); + const host = new DockerHostConnection( + { type: 'docker', containerLabel: 'occ-openclaw', portMappings: { gateway: 18789 } }, + 'occ-openclaw', + ); + this._statusController = new StatusPanelController(this._panel, this._homeUri, host); + } + await this._statusController.show(); + } + + // ── Handlers ─────────────────────────────────────────────────────────────── + + private async _handleDockerPreflight(): Promise { + try { + await new Promise((resolve) => { + const proc = cp.spawn('docker', ['info'], { windowsHide: true, timeout: 10000 }); + let stderr = ''; + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('close', (code) => { + if (code === 0) { + try { this._panel.webview.postMessage({ type: 'dockerPreflightResult', ok: true }); } catch { /* ignore */ } + } else { + const lower = stderr.toLowerCase(); + const error = lower.includes('permission denied') + ? 'Permission denied connecting to Docker. Try restarting Docker Desktop or OrbStack.' + : (lower.includes('cannot connect') || lower.includes('daemon') || lower.includes('no such file')) + ? 'Docker daemon is not running. Please start Docker Desktop or OrbStack, then try again.' + : 'Docker is not available. Please install Docker Desktop or OrbStack.'; + try { this._panel.webview.postMessage({ type: 'dockerPreflightResult', ok: false, error }); } catch { /* ignore */ } + } + resolve(); + }); + proc.on('error', () => { + try { this._panel.webview.postMessage({ type: 'dockerPreflightResult', ok: false, error: 'Docker CLI not found. Please install Docker Desktop or OrbStack.' }); } catch { /* ignore */ } + resolve(); + }); + }); + } catch (err) { + try { this._panel.webview.postMessage({ type: 'dockerPreflightResult', ok: false, error: String(err) }); } catch { /* ignore */ } + } + } + + private async _handleDockerFindOrCreate(): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'dockerLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + try { + // Check if container exists (running or stopped) + const check = cp.spawnSync('docker', ['ps', '-a', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], { timeout: 8000, windowsHide: true }); + const status = check.stdout?.toString().trim() ?? ''; + + if (status) { + if (status.toLowerCase().startsWith('up')) { + log('✓ Found occ-openclaw — already running\n'); + } else { + log('Found occ-openclaw (stopped) — starting...\n'); + const start = cp.spawnSync('docker', ['start', 'occ-openclaw'], { timeout: 15000, windowsHide: true }); + if (start.status !== 0) { + fail(`Failed to start container: ${start.stderr?.toString().trim() ?? 'unknown error'}`); + return; + } + log('✓ Container started\n'); + } + } else { + // Pull image + log('Container not found — pulling node:22-slim...\n'); + const pullCode = await new Promise((resolve) => { + const proc = cp.spawn('docker', ['pull', 'node:22-slim'], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString())); + proc.on('close', (code) => resolve(code ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (pullCode !== 0) { fail('Failed to pull node:22-slim. Check your internet connection.'); return; } + + // Prepare state dir on host Desktop (mounted as entire .openclaw dir in container) + const wsDir = path.join(os.homedir(), 'Desktop', 'occ-state-dir'); + try { fs.mkdirSync(wsDir, { recursive: true }); } catch { /* ok */ } + this._dockerWorkspaceHostPath = wsDir; + + // Create container with full openclaw state dir mounted + log('\nCreating occ-openclaw container...\n'); + const createCode = await new Promise((resolve) => { + const proc = cp.spawn('docker', [ + 'run', '-d', + '--name', 'occ-openclaw', + '--restart', 'unless-stopped', + '-p', '18789:18789', + '-v', `${wsDir}:/root/.openclaw`, + 'node:22-slim', + 'tail', '-f', '/dev/null', + ], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (code) => resolve(code ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (createCode !== 0) { fail('Failed to create occ-openclaw container.'); return; } + log('✓ Container created\n'); + } + + try { this._panel.webview.postMessage({ type: 'dockerContainerReady' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + private async _handleDockerInstallCli(): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'dockerLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + try { + // Check if already installed + log('Checking for OpenClaw CLI in container...\n'); + const check = cp.spawnSync('docker', ['exec', 'occ-openclaw', 'which', 'openclaw'], { timeout: 8000, windowsHide: true }); + const alreadyInstalled = check.status === 0 && check.stdout.toString().trim().length > 0; + + if (!alreadyInstalled) { + // Ensure curl is available then run installer + log('Installing curl and OpenClaw...\n'); + const installCode = await new Promise((resolve) => { + const proc = cp.spawn('docker', [ + 'exec', 'occ-openclaw', + 'bash', '-c', + 'apt-get update -qq 2>&1 && apt-get install -y -qq curl 2>&1 && curl -fsSL https://get.openclaw.sh | bash', + ], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (code) => resolve(code ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (installCode !== 0) { fail('OpenClaw installation failed. See the log above for details.'); return; } + log('\n✓ OpenClaw installed\n'); + } else { + log('✓ OpenClaw already installed — skipping\n'); + } + + // Register docker host and set as active + log('\nRegistering Docker host...\n'); + const entry = await this._coreAPI.addHost({ + type: 'docker', + label: 'Docker (occ-openclaw)', + connection: { type: 'docker', containerLabel: 'occ-openclaw', portMappings: { gateway: 18789 } }, + lastStatus: 'online', + }); + await this._coreAPI.setActiveHost(entry.id); + + // Ensure workspace host path is set (existing container scenario) + if (!this._dockerWorkspaceHostPath) { + this._dockerWorkspaceHostPath = path.join(os.homedir(), 'Desktop', 'occ-state-dir'); + try { fs.mkdirSync(this._dockerWorkspaceHostPath, { recursive: true }); } catch { /* ok */ } + } + + // Open the state dir as a workspace folder + const wsUri = vscode.Uri.file(this._dockerWorkspaceHostPath); + vscode.workspace.updateWorkspaceFolders( + vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders.length : 0, + null, + { uri: wsUri, name: 'occ-state-dir' }, + ); + + log('✓ Done — loading setup...\n'); + try { this._panel.webview.postMessage({ type: 'dockerInstallDone' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + private async _handleDockerConfigure(provider: string, apiKey: string, port: string): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'configureLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + const providerFlags: Record = { + anthropic: ['--auth-choice', 'apiKey', '--anthropic-api-key', apiKey], + openai: ['--auth-choice', 'openai-api-key', '--openai-api-key', apiKey], + openrouter: ['--auth-choice', 'openrouter-api-key', '--openrouter-api-key', apiKey], + gemini: ['--auth-choice', 'gemini-api-key', '--gemini-api-key', apiKey], + }; + const flags = providerFlags[provider]; + if (!flags) { fail(`Unknown provider: ${provider}`); return; } + + const gwPort = /^\d+$/.test(port) ? port : '18789'; + const args = [ + 'onboard', + '--non-interactive', '--accept-risk', + '--flow', 'quickstart', + '--gateway-auth', 'token', + '--gateway-port', gwPort, + '--skip-channels', '--skip-skills', '--skip-health', + ...flags, + ]; + + try { + log('Configuring OpenClaw...\n'); + const code = await new Promise((resolve) => { + const proc = cp.spawn('docker', ['exec', 'occ-openclaw', 'openclaw', ...args], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (c) => resolve(c ?? -1)); + proc.on('error', () => resolve(-1)); + }); + + if (code !== 0) { + fail('Setup failed. See the log above for details.'); + return; + } + + log('\n\u2713 Setup complete!\n'); + try { this._panel.webview.postMessage({ type: 'configureDone' }); } catch { /* ignore */ } + setTimeout(() => void this._showStatusPanel(), 1800); + } catch (err) { + fail(String(err)); + } + } + + // ── HTML ─────────────────────────────────────────────────────────────────── + + private _getHtml(iconUri: string): string { + return ` + + + + + + + + +
Docker Installation
+
Setting up OpenClaw in a Docker container
+ + +
+
+
1
+
Docker
Check
+
+
+
2
+
Container
Setup
+
+
+
3
+
Install
CLI
+
+
+
4
+
Configure
AI
+
+
+ + +
+ +
+
+
+ Checking Docker... +
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw-local/package.json b/apps/editor/extensions/openclaw-local/package.json index d34547dd..1fc26106 100644 --- a/apps/editor/extensions/openclaw-local/package.json +++ b/apps/editor/extensions/openclaw-local/package.json @@ -10,7 +10,9 @@ "categories": ["Other"], "activationEvents": ["onStartupFinished"], "main": "./out/openclaw-local/src/extension", - "contributes": {}, + "contributes": { + "commands": [{ "command": "openclaw.host.setup.local", "title": "OpenClaw: Set up Local" }] + }, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-local/src/extension.ts b/apps/editor/extensions/openclaw-local/src/extension.ts index e0619b15..c1097f18 100644 --- a/apps/editor/extensions/openclaw-local/src/extension.ts +++ b/apps/editor/extensions/openclaw-local/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; import { LocalHostAdapter } from './adapter'; +import { LocalSetupPanel } from './setup-panel'; export async function activate(context: vscode.ExtensionContext): Promise { // Grab the core API exported by the openclaw.home extension @@ -23,6 +24,11 @@ export async function activate(context: vscode.ExtensionContext): Promise const disposable = coreAPI.registerHostAdapter(adapter); context.subscriptions.push(disposable); + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.local', () => { + LocalSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + console.log('[openclaw-local] LocalHostAdapter registered'); } diff --git a/apps/editor/extensions/openclaw-local/src/setup-panel.ts b/apps/editor/extensions/openclaw-local/src/setup-panel.ts new file mode 100644 index 00000000..07765ac7 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/setup-panel.ts @@ -0,0 +1,1412 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { DefaultLocalHostConnection } from '../../openclaw/src/hosts/localDefault'; +import { StatusPanelController } from '../../openclaw/src/panels/statusController'; + +// ── Persistent diagnostics log ──────────────────────────────────────────────── +const LOG_PATH = path.join(os.homedir(), '.openclaw', 'occ-home.log'); +const LOG_MAX_BYTES = 512 * 1024; // 500 KB + +const _ansiRe = /\x1b(\[[0-9;]*[A-Za-z]|[^[])/g; + +function writeLog(text: string): void { + try { + const dir = path.dirname(LOG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + if (fs.existsSync(LOG_PATH) && fs.statSync(LOG_PATH).size > LOG_MAX_BYTES) { + const lines = fs.readFileSync(LOG_PATH, 'utf-8').split('\n'); + fs.writeFileSync(LOG_PATH, lines.slice(Math.floor(lines.length / 2)).join('\n'), 'utf-8'); + } + const ts = new Date().toISOString(); + const clean = text.replace(_ansiRe, ''); + const stamped = clean + .split('\n') + .map(l => (l.trim() ? `[${ts}] ${l}` : l)) + .join('\n'); + fs.appendFileSync(LOG_PATH, stamped, 'utf-8'); + } catch { /* non-fatal */ } +} + +// ── OCC Legacy model constants ──────────────────────────────────────────────── +const OCC_LEGACY_MODEL_ID = 'occ-legacy'; +const OCC_LEGACY_MODEL_NAME = 'occ-legacy'; +const OCC_LEGACY_BASE_URL = 'https://occ.mba.sh/v1'; +const OCC_LEGACY_API = 'openai-completions'; +const OCC_LEGACY_COST = { + input: 0.0000006, + output: 0.000003, + cacheRead: 0.0000001, + cacheWrite: 0, +}; +const OCC_LEGACY_CONTEXT_WINDOW = 262144; +const OCC_LEGACY_MAX_TOKENS = 262144; + +export class LocalSetupPanel { + public static currentPanel: LocalSetupPanel | undefined; + private static _installedCliPath: string | undefined; + /** Resolves with the password (or undefined on cancel) when the webview modal submits. */ + private _pendingPasswordResolve: ((pwd: string | undefined) => void) | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private readonly _coreAPI: OpenClawCoreAPI; + private _disposables: vscode.Disposable[] = []; + private _host = new DefaultLocalHostConnection(); + private _statusController: StatusPanelController | undefined; + + /** Returns the home extension URI (has media/icon.png and media/emojis/). */ + private get _homeUri(): vscode.Uri { + const homeExt = vscode.extensions.getExtension('openclaw.home'); + return homeExt?.extensionUri ?? this._extensionUri; + } + + public static createOrShow(extensionUri: vscode.Uri, coreAPI: OpenClawCoreAPI): void { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (LocalSetupPanel.currentPanel) { + LocalSetupPanel.currentPanel._panel.reveal(column); + return; + } + + const homeExt = vscode.extensions.getExtension('openclaw.home'); + const homeUri = homeExt?.extensionUri ?? extensionUri; + const iconUri = vscode.Uri.joinPath(homeUri, 'media', 'icon.png'); + + const panel = vscode.window.createWebviewPanel( + 'openclawLocalSetup', + 'OCC Home [Local]', + column ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(homeUri, 'media'), + ], + retainContextWhenHidden: true, + }, + ); + + LocalSetupPanel.currentPanel = new LocalSetupPanel(panel, extensionUri, coreAPI, iconUri); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + coreAPI: OpenClawCoreAPI, + iconUri: vscode.Uri, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + this._coreAPI = coreAPI; + + void this._initHtml(iconUri); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + async (msg: { command: string; [key: string]: unknown }) => { + // When the status panel is active, delegate all messages to it. + if (this._statusController) { + this._statusController.handleMessage(msg); + return; + } + if (msg.command === 'openclaw.install') { + void this._runInstall(); + } else if (msg.command === 'runSetup') { + void this._runSetup(msg as { command: string; provider: string; apiKey: string; port: string }); + } else if (msg.command === 'autoSetupSkipped') { + setTimeout(() => { + void this._showStatusPanel(); + void vscode.commands.executeCommand('openclaw.openWorkspace'); + setTimeout(() => { + void vscode.commands.executeCommand('void.openChatWithMessage', + 'Run `openclaw gateway start` to start the OpenClaw gateway.', + 'agent'); + }, 1000); + }, 500); + } else if (msg.command === 'verifyCliBeforeSetup') { + const storedPath = LocalSetupPanel._installedCliPath; + if (storedPath && fs.existsSync(storedPath)) { + try { this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); } catch {} + return; + } + void this._host.testOpenClawCli().then(result => { + if (result.ok) { + try { + this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); + } catch {} + } else { + try { + this._panel.webview.postMessage({ + type: 'installLog', + text: '\n⚠️ OpenClaw was installed but could not be found in PATH.\n' + + ' Please restart OCCode to pick up the new PATH.\n' + }); + } catch {} + } + }); + } else if (msg.command === 'sudoPassword') { + this._pendingPasswordResolve?.(msg.password as string | undefined); + this._pendingPasswordResolve = undefined; + } else if (msg.command === 'signIn') { + void vscode.env.openExternal(vscode.Uri.parse('https://occ.mba.sh/login?ref=occ-editor')); + } else if (msg.command === 'openDashboard') { + void vscode.env.openExternal(vscode.Uri.parse('https://occ.mba.sh/dashboard')); + } else if (msg.command === 'signOut') { + void vscode.commands.executeCommand('occ.auth.setLegacyJwt', ''); + void vscode.commands.executeCommand('occ.auth.setMoltpilotKey', ''); + void vscode.commands.executeCommand('openclaw.jwt.set', ''); + } else if (msg.command === 'closePanel') { + this.dispose(); + } else if (msg.command) { + void vscode.commands.executeCommand(msg.command); + } + }, + null, + this._disposables, + ); + } + + public dispose(): void { + LocalSetupPanel.currentPanel = undefined; + this._statusController?.dispose(); + this._statusController = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) { d.dispose(); } + } + } + + private async _initHtml(iconUri: vscode.Uri): Promise { + const configPath = await this._host.getConfigPath(); + const isInstalled = await this._host.exists(configPath); + + if (isInstalled) { + await this._showStatusPanel(); + return; + } + + const webviewIconUri = this._panel.webview.asWebviewUri(iconUri); + + const occJwt = await vscode.commands.executeCommand('occ.auth.getLegacyJwt').then(r => r ?? '', () => ''); + + let occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null; + if (occJwt) { + try { + const r = await fetch('https://occ.mba.sh/api/v1/me', { + headers: { Authorization: `Bearer ${occJwt}` }, + }); + if (r.ok) occUser = await r.json() as { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null }; + } catch { /* network error */ } + } + + this._panel.webview.html = this._getHtml(isInstalled, webviewIconUri.toString(), occUser); + } + + /** Switch this panel to show the full status panel (with gateway controls, AI model, etc.). */ + private async _showStatusPanel(): Promise { + if (this._statusController) { + await this._statusController._update(); + return; + } + this._statusController = new StatusPanelController(this._panel, this._homeUri, this._host); + await this._statusController.show(); + } + + private async _runInstall(): Promise { + const platform = process.platform; + const arch = process.arch; + const shell = process.env.SHELL || ''; + + const post = (msg: object) => { try { this._panel.webview.postMessage(msg); } catch {} }; + let fullLog = ''; + const tee = (text: string) => { fullLog += text; post({ type: 'installLog', text }); writeLog(text); }; + + writeLog(`\n=== _runInstall START platform=${platform} arch=${arch} ===\n`); + post({ type: 'installState', state: 'running' }); + + const env = this._host.buildExecEnv(); + const isPermError = (s: string) => /EACCES|permission denied|EPERM|not permitted|Need sudo access|needs to be an Administrator/i.test(s); + + const cmdExists = (cmd: string): Promise => + new Promise(resolve => + cp.exec(cmd, { env, timeout: 5000, windowsHide: true }, err => resolve(!err)) + ); + + const runCaptured = (cmd: string, args: string[], opts: cp.SpawnOptions = {}): Promise<{ code: number }> => + new Promise(resolve => { + const child = cp.spawn(cmd, args, { env, stdio: ['ignore', 'pipe', 'pipe'], ...opts }); + child.stdout?.on('data', (d: Buffer) => tee(d.toString())); + child.stderr?.on('data', (d: Buffer) => tee(d.toString())); + child.on('close', code => resolve({ code: code ?? 1 })); + child.on('error', err => { tee(`\nError: ${err.message}\n`); resolve({ code: 1 }); }); + }); + + const cacheSudo = async (_prompt: string): Promise => { + const password = await new Promise(resolve => { + this._pendingPasswordResolve = resolve; + post({ type: 'requestPassword' }); + }); + if (!password) return false; + tee('Verifying credentials...\n'); + return new Promise(resolve => { + const child = cp.spawn('sudo', ['-S', '-v'], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + child.stdin?.write(password + '\n'); + child.stdin?.end(); + child.on('close', code => resolve(code === 0)); + child.on('error', () => resolve(false)); + }); + }; + + const fixOpenclawPermissions = async () => { + if (platform === 'win32') return; + const openclawDir = path.join(os.homedir(), '.openclaw'); + if (!fs.existsSync(openclawDir)) return; + try { + const username = os.userInfo().username; + tee('Fixing .openclaw folder ownership...\n'); + await runCaptured('sudo', ['-n', 'chown', '-R', username, openclawDir]); + await runCaptured('chmod', ['700', openclawDir]); + tee('✅ Permissions set (700, owned by you)\n'); + } catch { /* non-fatal */ } + }; + + const failCancelled = () => { + post({ type: 'installState', state: 'cancelled' }); + }; + + const fail = async () => { + writeLog('=== _runInstall END failed ===\n'); + post({ type: 'installState', state: 'failed' }); + const platformDesc = platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : `Linux (${arch})`; + await vscode.commands.executeCommand('void.openChatWithMessage', [ + `OpenClaw installation failed on **${platformDesc}**.`, + ``, `**System info:**`, + `- Node.js: \`${process.version}\``, + `- Shell: \`${shell || 'unknown'}\``, + ``, `**Full output:**`, `\`\`\``, fullLog.trim(), `\`\`\``, ``, + `Please diagnose what went wrong and provide exact steps to fix it on this platform.`, + `If Node.js or npm is missing, explain how to install them first.`, + ].join('\n')); + void vscode.commands.executeCommand('openclaw.balance.spend'); + }; + + const captureInstalledPath = async (): Promise => { + if (platform === 'win32') return; + const candidates: string[] = []; + const prefixResult = await new Promise(resolve => { + cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => + resolve(err ? '' : (stdout || '').trim()) + ); + }); + if (prefixResult) candidates.push(path.join(prefixResult, 'bin', 'openclaw')); + const home = os.homedir(); + candidates.push( + '/usr/local/bin/openclaw', + '/opt/homebrew/bin/openclaw', + path.join(home, '.local', 'bin', 'openclaw'), + path.join(home, '.npm-global', 'bin', 'openclaw'), + path.join(home, '.openclaw', 'bin', 'openclaw'), + ); + const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); + if (fs.existsSync(nvmVersionsDir)) { + fs.readdirSync(nvmVersionsDir) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + .forEach(ver => candidates.push(path.join(nvmVersionsDir, ver, 'bin', 'openclaw'))); + } + const found = candidates.find(c => c && fs.existsSync(c)); + if (found) { + LocalSetupPanel._installedCliPath = found; + tee(` ✓ Binary found at ${found}\n`); + } + }; + + const succeed = async () => { + await captureInstalledPath(); + tee('\n✅ Installed successfully!\n'); + writeLog('=== _runInstall END ok ===\n'); + post({ type: 'installState', state: 'done' }); + }; + + // ── PREREQUISITE CHECKS ────────────────────────────────────────────────── + tee('Checking prerequisites...\n'); + + if (platform === 'darwin') { + const xcodeOk = await new Promise(resolve => + cp.exec('xcode-select -p', { env, timeout: 5000 }, (err, stdout) => { + resolve(!err && !!stdout?.toString().trim()); + }) + ); + if (!xcodeOk) { + post({ type: 'xcodeRequired' }); + return; + } + tee(' ✓ Xcode Command Line Tools\n'); + } + + if (platform === 'win32') { + let nodeOk = await cmdExists('node --version'); + if (!nodeOk) { + const nodeVersion = '20.18.2'; + const nodeArch = arch === 'arm64' ? 'arm64' : 'x64'; + const zipName = `node-v${nodeVersion}-win-${nodeArch}.zip`; + const zipUrl = `https://nodejs.org/dist/v${nodeVersion}/${zipName}`; + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + const installDir = path.join(localAppData, 'Programs', 'nodejs'); + const tmpZip = path.join(os.tmpdir(), zipName); + const tmpExtract = path.join(os.tmpdir(), `occ-node-${Date.now()}`); + + tee(` ⚠ Node.js not found — downloading v${nodeVersion} (${nodeArch})...\n`); + const dlR = await runCaptured('powershell', [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing '${zipUrl}' -OutFile '${tmpZip}'`, + ], { windowsHide: true, shell: true } as cp.SpawnOptions); + + if (dlR.code !== 0) { + tee(' ❌ Failed to download Node.js. Check your internet connection.\n'); + await fail(); return; + } + + tee(` Extracting...\n`); + const innerDir = path.join(tmpExtract, `node-v${nodeVersion}-win-${nodeArch}`); + const exR = await runCaptured('powershell', [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + `$ProgressPreference='SilentlyContinue'; ` + + `Expand-Archive -Path '${tmpZip}' -DestinationPath '${tmpExtract}' -Force; ` + + `if (Test-Path '${installDir}') { Remove-Item '${installDir}' -Recurse -Force }; ` + + `Move-Item '${innerDir}' '${installDir}'`, + ], { windowsHide: true, shell: true } as cp.SpawnOptions); + + try { fs.unlinkSync(tmpZip); } catch {} + + if (exR.code !== 0) { + tee(' ❌ Failed to extract Node.js.\n'); + await fail(); return; + } + + env.PATH = [installDir, env.PATH || ''].filter(Boolean).join(';'); + (env as Record).Path = env.PATH; + + nodeOk = await cmdExists('node --version'); + if (!nodeOk) { + tee(' ❌ Node.js install did not complete properly.\n'); + await fail(); return; + } + tee(` ✓ Node.js v${nodeVersion} installed to ${installDir}\n`); + } else { + tee(' ✓ Node.js found\n'); + } + } + + const npmOk = await cmdExists('npm --version'); + if (npmOk) { + tee(' ✓ npm found\n'); + } else if (platform === 'win32') { + tee(' ❌ npm not found after Node.js install — unexpected.\n'); + await fail(); return; + } else { + tee(' ⚠ npm not found — will attempt to install Node.js\n'); + } + + let sudoCached = false; + if (platform !== 'win32') { + let needsSudo = false; + if (npmOk) { + const prefixResult = await new Promise(resolve => + cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => + resolve(err ? '' : stdout?.toString().trim() || '')) + ); + if (prefixResult) { + try { + const gBin = path.join(prefixResult, 'bin'); + const gLib = path.join(prefixResult, 'lib'); + if (fs.existsSync(gBin)) fs.accessSync(gBin, fs.constants.W_OK); + if (fs.existsSync(gLib)) fs.accessSync(gLib, fs.constants.W_OK); + } catch { + needsSudo = true; + } + } + } else { + for (const dir of ['/usr/local/bin', '/usr/local/lib']) { + try { if (fs.existsSync(dir)) fs.accessSync(dir, fs.constants.W_OK); } catch { needsSudo = true; break; } + } + } + + if (needsSudo) { + tee('\n Administrator password required for installation.\n'); + const sudoOk = await cacheSudo('Enter your system password to install OpenClaw'); + if (!sudoOk) { tee(' Incorrect password or cancelled.\n'); failCancelled(); return; } + tee(' ✓ Credentials verified\n'); + sudoCached = true; + } else { + tee(' ✓ Write access OK\n'); + } + } + + tee('\n'); + + if (npmOk) { + tee('Installing openclaw via npm...\n'); + const spawnOpts: cp.SpawnOptions = platform === 'win32' ? { shell: true, windowsHide: true } : {}; + const npmArgs = ['install', '-g', 'openclaw']; + const r1 = sudoCached + ? await runCaptured('sudo', ['-E', 'npm', ...npmArgs]) + : await runCaptured('npm', npmArgs, spawnOpts); + if (r1.code === 0) { + if (sudoCached) await fixOpenclawPermissions(); + await succeed(); return; + } + if (!sudoCached && platform !== 'win32' && isPermError(fullLog)) { + tee('\nPermission error — elevated access required.\n'); + const ok = await cacheSudo('Enter your system password to install OpenClaw'); + if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + sudoCached = true; + tee('Retrying with elevated permissions...\n'); + const r2 = await runCaptured('sudo', ['-E', 'npm', 'install', '-g', 'openclaw']); + if (r2.code === 0) { + await fixOpenclawPermissions(); + await succeed(); return; + } + } + tee('\nnpm install did not succeed — trying full installer script...\n'); + } else if (platform !== 'win32') { + + const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); + if (fs.existsSync(nvmSh)) { + tee('nvm detected — installing Node.js LTS...\n'); + const nvmR = await runCaptured('bash', ['-c', + `. "${nvmSh}" && nvm install --lts && nvm use --lts && npm install -g openclaw` + ]); + if (nvmR.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } + tee('nvm install failed — falling back to system install...\n'); + } + + if (!sudoCached) { + tee('\nNode.js is required. Your password is needed once to install it.\n'); + const sudoOk = await cacheSudo('Enter your password to install Node.js'); + if (!sudoOk) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + sudoCached = true; + } + + if (platform === 'darwin') { + const nodeVersion = '20.18.2'; + const pkgUrl = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}.pkg`; + const pkgPath = `/tmp/.occ-node-${nodeVersion}.pkg`; + tee(`Downloading Node.js v${nodeVersion}...\n`); + const dlR = await runCaptured('curl', ['-fsSL', pkgUrl, '-o', pkgPath]); + if (dlR.code !== 0) { try { fs.unlinkSync(pkgPath); } catch {} await fail(); return; } + tee('Installing Node.js (this may take a moment)...\n'); + const instR = await runCaptured('sudo', ['-n', 'installer', '-pkg', pkgPath, '-target', '/']); + try { fs.unlinkSync(pkgPath); } catch { /* non-fatal */ } + if (instR.code !== 0) { await fail(); return; } + } else { + const hasCmdSync = (cmd: string): boolean => { + try { cp.execSync(`which ${cmd}`, { env, stdio: 'ignore' }); return true; } catch { return false; } + }; + tee('Installing Node.js via package manager...\n'); + let pkgResult: { code: number } | undefined; + if (hasCmdSync('apt-get')) { + pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', + 'curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get install -y nodejs' + ]); + } else if (hasCmdSync('dnf')) { + pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', + 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && dnf install -y nodejs' + ]); + } else if (hasCmdSync('yum')) { + pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', + 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && yum install -y nodejs' + ]); + } + if (!pkgResult) { tee('No supported package manager found (tried apt-get, dnf, yum).\n'); await fail(); return; } + if (pkgResult.code !== 0) { await fail(); return; } + } + + tee('Installing OpenClaw...\n'); + const npmCandidates = ['/usr/local/bin/npm', '/usr/bin/npm']; + const npmBin = npmCandidates.find(p => fs.existsSync(p)) ?? 'npm'; + const npmR1 = await runCaptured(npmBin, ['install', '-g', 'openclaw']); + if (npmR1.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } + if (isPermError(fullLog)) { + const npmR2 = await runCaptured('sudo', ['-n', npmBin, 'install', '-g', 'openclaw']); + if (npmR2.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } + } + await fail(); return; + + } else { + tee('npm not found — running full installer script...\n'); + } + + if (platform === 'win32') { + tee('Running PowerShell installer...\n'); + const psArgs = [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; ` + + `Invoke-WebRequest -UseBasicParsing https://openclaw.ai/install.ps1 | Invoke-Expression`, + ]; + const r = await runCaptured('powershell', psArgs, { windowsHide: true } as cp.SpawnOptions); + if (r.code === 0) { await succeed(); return; } + } else { + tee('Running install script...\n'); + const r1 = await runCaptured('bash', ['-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); + if (r1.code === 0) { + await fixOpenclawPermissions(); + await succeed(); return; + } + if (isPermError(fullLog)) { + tee('\nPermission error in installer — elevated access required.\n'); + const ok = await cacheSudo('Enter your system password to complete installation'); + if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + tee('Retrying with elevated permissions...\n'); + const r2 = await runCaptured('sudo', ['-E', 'bash', '-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); + if (r2.code === 0) { + await fixOpenclawPermissions(); + await succeed(); return; + } + } + } + + await fail(); + } + + private async _runSetup(data: { provider: string; apiKey: string; port: string }): Promise { + const post = (msg: object) => { try { this._panel.webview.postMessage(msg); } catch {} }; + const wizardPost = (text: string, done: boolean, ok: boolean) => { + writeLog(text); + post({ type: 'wizardLog', text, done, ok }); + }; + const env = this._host.buildExecEnv(); + + if (process.platform !== 'win32') { + const nodeResult = await this._host.exec('node', ['--version'], { env: env as Record, timeout: 5000 }); + const nodeVerRaw = nodeResult.stdout.trim(); + const nodeMinor = parseInt((nodeVerRaw.match(/^v?(\d+)/) || [])[1] || '0', 10); + if (nodeMinor < 22) { + const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); + if (fs.existsSync(nvmSh)) { + wizardPost(`Node.js ${nodeVerRaw || 'unknown'} detected — openclaw requires v22+. Installing Node.js 22 via nvm...\n`, false, false); + const nvmCode = await this._host.execStream( + 'bash', + ['-c', `. "${nvmSh}" && nvm install 22 && nvm use 22 && nvm alias default 22`], + { env: env as Record }, + (d) => wizardPost(d, false, false), + (d) => wizardPost(d, false, false), + ); + if (nvmCode === 0) { + const nvmVersionsDir = path.join(os.homedir(), '.nvm', 'versions', 'node'); + const v22dirs = fs.existsSync(nvmVersionsDir) + ? fs.readdirSync(nvmVersionsDir).filter(v => /^v?22/.test(v)) + : []; + if (v22dirs.length > 0) { + const v22bin = path.join(nvmVersionsDir, v22dirs[0], 'bin'); + env.PATH = [v22bin, env.PATH || ''].filter(Boolean).join(':'); + (env as Record).Path = env.PATH; + } + wizardPost(' ✓ Node.js 22 ready.\n', false, false); + } else { + wizardPost(' ⚠️ Node.js 22 install via nvm failed. Setup may fail.\n', false, false); + } + } else { + wizardPost(`⚠️ Node.js ${nodeVerRaw || 'v20'} is active but openclaw requires v22+.\n Please install Node.js 22 (e.g. via https://nodejs.org) and restart OCCode.\n`, true, false); + return; + } + } + } + + const cliPath = await this._host.findOpenClawPath() ?? 'openclaw'; + const port = data.port && /^\d+$/.test(data.port) ? data.port : '18789'; + const isFree = data.provider === 'free'; + + const providerFlags: Record = { + free: [ + '--auth-choice', 'custom-api-key', + '--custom-base-url', 'https://occ.mba.sh/v1', + '--custom-api-key', data.apiKey, + '--custom-model-id', 'occ-legacy', + '--custom-compatibility', 'openai', + ], + anthropic: ['--auth-choice', 'apiKey', '--anthropic-api-key', data.apiKey], + openai: ['--auth-choice', 'openai-api-key', '--openai-api-key', data.apiKey], + openrouter: ['--auth-choice', 'openrouter-api-key', '--openrouter-api-key', data.apiKey], + gemini: ['--auth-choice', 'gemini-api-key', '--gemini-api-key', data.apiKey], + ollama: [ + '--auth-choice', 'custom-api-key', + '--custom-base-url', data.apiKey || 'http://localhost:11434', + '--custom-api-key', 'ollama', + '--custom-model-id', 'llama3', + '--custom-compatibility', 'openai', + ], + }; + const flags = providerFlags[data.provider]; + if (!flags) { + wizardPost('Unknown provider selected.\n', true, false); + return; + } + + const args = [ + 'onboard', + '--non-interactive', '--accept-risk', + '--flow', 'quickstart', + '--gateway-auth', 'token', + '--gateway-port', port, + '--skip-channels', '--skip-skills', '--skip-health', + ...flags, + ]; + + writeLog(`\n=== _runSetup START provider=${data.provider} port=${port} ===\n`); + wizardPost(isFree ? 'Installing Inference for MoltPilot...\nInstalling Inference for your new OpenClaw...\n' : 'Installing Inference for your new OpenClaw...\n', false, false); + + let exitCode: number; + try { + exitCode = await this._host.execStream( + cliPath, args, + { + env: env as Record, + timeout: 120_000, + ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), + }, + (d) => wizardPost(d, false, false), + (d) => wizardPost(d, false, false), + ); + } catch (err) { + wizardPost(`Error: ${String(err)}\n`, true, false); + return; + } + + const ok = exitCode === 0; + writeLog(`=== _runSetup END code=${exitCode} ===\n`); + wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${exitCode}.\n`, true, ok); + + if (ok) { + if (isFree) { + try { + const occDir = path.join(os.homedir(), '.occ'); + if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } + fs.writeFileSync( + path.join(occDir, 'moltpilot-tier.json'), + JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), + ); + } catch { /* non-fatal */ } + try { + const cfg = await this._host.readConfig() as Record; + const patchModel = (obj: unknown): boolean => { + if (!obj || typeof obj !== 'object') return false; + if (Array.isArray(obj)) { + for (const item of obj) { if (patchModel(item)) return true; } + return false; + } + const o = obj as Record; + if (o['id'] === OCC_LEGACY_MODEL_ID) { + o['name'] = OCC_LEGACY_MODEL_NAME; + o['reasoning'] = false; + o['input'] = ['text']; + o['cost'] = { ...OCC_LEGACY_COST }; + o['contextWindow'] = OCC_LEGACY_CONTEXT_WINDOW; + o['maxTokens'] = OCC_LEGACY_MAX_TOKENS; + return true; + } + for (const v of Object.values(o)) { patchModel(v); } + return false; + }; + patchModel(cfg); + await this._host.writeConfig(cfg); + } catch { /* non-fatal */ } + } + setTimeout(() => { + void this._showStatusPanel(); + if (isFree) { + void vscode.commands.executeCommand('openclaw.openWorkspace'); + } + setTimeout(() => { + void vscode.commands.executeCommand('void.openChatWithMessage', + 'Run `openclaw gateway start` to start the OpenClaw gateway.', + 'agent'); + }, 1000); + }, 1500); + } + } + + private _getHtml( + isInstalled: boolean, + iconUri: string, + occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null + ): string { + let userAreaHtml: string; + if (!occUser) { + userAreaHtml = ``; + } else { + const initial = (occUser.email || '?')[0].toUpperCase(); + const safeEmail = occUser.email.replace(/"/g, '"').replace(/` + : initial; + userAreaHtml = ` +
+ +
+
+
${avatarImg}
+
${safeEmail}
+
+ +
+ +
+
`; + } + + const providers = [ + { id: 'anthropic', label: 'Anthropic Claude', hint: 'console.anthropic.com/settings/keys', placeholder: 'sk-ant-...' }, + { id: 'openai', label: 'OpenAI', hint: 'platform.openai.com/api-keys', placeholder: 'sk-...' }, + { id: 'openrouter', label: 'OpenRouter', hint: 'openrouter.ai/settings/keys', placeholder: 'sk-or-...' }, + { id: 'gemini', label: 'Google Gemini', hint: 'aistudio.google.com/apikey', placeholder: 'AIza...' }, + ]; + + const providerCards = providers.map(p => + `` + ).join('\n '); + + return ` + + + + + + + + +
${userAreaHtml}
+ + + +
Set up OpenClaw
+
Follow the steps below to get started
+ + +
+
+
${isInstalled ? '✓' : '1'}
+
Install
OpenClaw
+
+
+
2
+
Configure
AI Model
+
+
+
3
+
Ready
+
+
+ + +
+ + +
+ + + + + + + + + + + + + + +
+
+
Working
+ + + +
+ + + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/package.json b/apps/editor/extensions/openclaw-ssh/package.json index 323749bd..47df9f67 100644 --- a/apps/editor/extensions/openclaw-ssh/package.json +++ b/apps/editor/extensions/openclaw-ssh/package.json @@ -9,8 +9,10 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/extension", - "contributes": {}, + "main": "./out/openclaw-ssh/src/extension", + "contributes": { + "commands": [{ "command": "openclaw.host.setup.ssh", "title": "OpenClaw: Set up SSH" }] + }, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-ssh/src/adapter.ts b/apps/editor/extensions/openclaw-ssh/src/adapter.ts new file mode 100644 index 00000000..2d862a87 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/adapter.ts @@ -0,0 +1,71 @@ +import * as vscode from 'vscode'; +import type { + HostAdapter, + HostType, + HostConnection, + HostConnectionConfig, + SSHConnection, + TestResult, + DiscoveredHost, + ConfigField, + ConfigValidationResult, +} from '../../openclaw/src/hosts/types'; +import { SSHHostConnection } from './connection'; + +export class SSHHostAdapter implements HostAdapter { + readonly type: HostType = 'ssh'; + readonly displayName = 'SSH Server'; + readonly icon = new vscode.ThemeIcon('remote'); + + async discover(): Promise { + // Future: parse ~/.ssh/config for known hosts + return []; + } + + async connect(config: HostConnectionConfig): Promise { + return new SSHHostConnection(config as SSHConnection); + } + + async testConnection(config: HostConnectionConfig): Promise { + const sshCfg = config as SSHConnection; + const conn = new SSHHostConnection(sshCfg); + try { + const r = await conn.exec('echo', ['ok'], { timeout: 15000 }); + if (r.code !== 0 || !r.stdout.includes('ok')) { + return { success: false, message: `SSH connection failed: ${r.stderr.trim()}` }; + } + const cliCheck = await conn.testOpenClawCli(); + return { + success: true, + message: cliCheck.ok + ? `Connected to ${sshCfg.user}@${sshCfg.host}. OpenClaw ${cliCheck.output ?? ''}` + : `Connected to ${sshCfg.user}@${sshCfg.host}. OpenClaw not installed.`, + details: { + openclawInstalled: cliCheck.ok, + openclawVersion: cliCheck.output, + os: 'remote', + hostname: sshCfg.host, + }, + }; + } catch (err) { + return { success: false, message: `SSH error: ${String(err)}` }; + } + } + + getConfigFields(): ConfigField[] { + return [ + { id: 'host', label: 'Hostname or IP', type: 'text', placeholder: 'example.com or 192.168.1.10', required: true }, + { id: 'user', label: 'SSH User', type: 'text', placeholder: 'ubuntu, ec2-user, root...', required: true }, + { id: 'port', label: 'Port', type: 'number', placeholder: '22', defaultValue: 22 }, + { id: 'keyPath', label: 'SSH Key Path', type: 'text', placeholder: '~/.ssh/id_rsa (blank = SSH agent)', required: false }, + ]; + } + + validateConfig(config: HostConnectionConfig): ConfigValidationResult { + const sshCfg = config as SSHConnection; + const errors: { fieldId: string; message: string }[] = []; + if (!sshCfg.host) { errors.push({ fieldId: 'host', message: 'Hostname is required' }); } + if (!sshCfg.user) { errors.push({ fieldId: 'user', message: 'SSH user is required' }); } + return errors.length ? { valid: false, errors } : { valid: true }; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/src/connection.ts b/apps/editor/extensions/openclaw-ssh/src/connection.ts new file mode 100644 index 00000000..79dcfd99 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/connection.ts @@ -0,0 +1,268 @@ +import * as cp from 'child_process'; +import * as path from 'path'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + GatewayRunState, + OpenClawConfig, + SetupParams, + SSHConnection, +} from '../../openclaw/src/hosts/types'; + +export class SSHHostConnection implements HostConnection { + readonly type: HostType = 'ssh'; + + get id(): string { return `ssh:${this._cfg.user}@${this._cfg.host}`; } + get label(): string { return `${this._cfg.user}@${this._cfg.host}`; } + + constructor(private readonly _cfg: SSHConnection) {} + + dispose(): void {} + + // ── SSH helpers ─────────────────────────── + + private _sshBaseArgs(): string[] { + const args = [ + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15', + '-o', 'StrictHostKeyChecking=accept-new', + ]; + if (this._cfg.port && this._cfg.port !== 22) { args.push('-p', String(this._cfg.port)); } + if (this._cfg.keyPath) { args.push('-i', this._cfg.keyPath); } + if (this._cfg.jumpHost) { args.push('-J', this._cfg.jumpHost); } + return args; + } + + private _remote(): string { + return `${this._cfg.user}@${this._cfg.host}`; + } + + private _remoteHome(): string { + return this._cfg.user === 'root' ? '/root' : `/home/${this._cfg.user}`; + } + + // ── Process execution ───────────────────── + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + const remoteCmd = [cmd, ...args] + .map(a => `'${a.replace(/'/g, "'\\''")}'`) + .join(' '); + return new Promise((resolve, reject) => { + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), remoteCmd], { + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', reject); + proc.on('close', code => resolve({ stdout, stderr, code: code ?? -1 })); + }); + } + + execStream(cmd: string, args: string[], opts: ExecOpts, onData: LogFn, onError: LogFn): Promise { + const remoteCmd = [cmd, ...args] + .map(a => `'${a.replace(/'/g, "'\\''")}'`) + .join(' '); + return new Promise((resolve, reject) => { + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), remoteCmd], { + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + // ── Filesystem ──────────────────────────── + + async readFile(filePath: string): Promise { + const r = await this.exec('cat', [filePath]); + if (r.code !== 0) { throw new Error(`cat ${filePath} failed: ${r.stderr}`); } + return r.stdout; + } + + async writeFile(filePath: string, content: string): Promise { + const dir = path.posix.dirname(filePath); + await this.exec('mkdir', ['-p', dir]); + await new Promise((resolve, reject) => { + const quotedPath = filePath.replace(/'/g, "'\\''"); + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), `cat > '${quotedPath}'`], { windowsHide: true }); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('error', reject); + proc.on('close', code => code === 0 ? resolve() : reject(new Error(`writeFile failed: code ${code}`))); + }); + } + + async exists(filePath: string): Promise { + const r = await this.exec('test', ['-e', filePath]); + return r.code === 0; + } + + async mkdir(dirPath: string): Promise { + await this.exec('mkdir', ['-p', dirPath]); + } + + async stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + const r = await this.exec('stat', ['-c', '%s %F', filePath]); + if (r.code !== 0) { return null; } + const parts = r.stdout.trim().split(' '); + return { + size: parseInt(parts[0] ?? '0', 10), + isDirectory: (parts[1] ?? '').includes('directory'), + }; + } + + // ── CLI ─────────────────────────────────── + + async findOpenClawPath(): Promise { + const r = await this.exec('which', ['openclaw']); + if (r.code === 0) { + const p = r.stdout.trim().split('\n')[0]; + if (p) { return p; } + } + for (const p of [ + '/usr/local/bin/openclaw', + '/usr/bin/openclaw', + `${this._remoteHome()}/.local/bin/openclaw`, + ]) { + if (await this.exists(p)) { return p; } + } + return undefined; + } + + async isCliInstalled(): Promise { + return (await this.findOpenClawPath()) !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const r = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (r.stdout + r.stderr).trim(); + const m = out.match(/[\d]+\.[\d]+\.[\d]+/); + return m ? m[0] : out || null; + } catch { return null; } + } + + async testOpenClawCli(): Promise { + const p = await this.findOpenClawPath(); + const command = p ? `ssh ${this._remote()} ${p} --version` : `ssh ${this._remote()} openclaw --version`; + if (!p) { return { ok: false, command, error: 'OpenClaw CLI not found on remote host' }; } + try { + const r = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (r.stdout + r.stderr).trim(); + return r.code === 0 + ? { ok: true, command, output: out } + : { ok: false, command, error: `Exit ${r.code}: ${out}` }; + } catch (err) { return { ok: false, command, error: String(err) }; } + } + + async installCli(onLog: LogFn): Promise { + onLog('Installing OpenClaw on remote host...\n'); + const code = await this.execStream( + 'bash', ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], + { timeout: 120_000 }, onLog, onLog, + ); + if (code !== 0) { throw new Error(`Installer exited with code ${code}`); } + onLog('OpenClaw installed.\n'); + } + + // ── OpenClaw config ─────────────────────── + + async getConfigPath(): Promise { + return `${this._remoteHome()}/.openclaw/openclaw.json`; + } + + async readConfig(): Promise { + const p = await this.getConfigPath(); + if (!(await this.exists(p))) { return {}; } + try { return JSON.parse(await this.readFile(p)) as OpenClawConfig; } catch { return {}; } + } + + async writeConfig(patch: Partial): Promise { + const p = await this.getConfigPath(); + let existing: OpenClawConfig = {}; + if (await this.exists(p)) { + try { existing = JSON.parse(await this.readFile(p)) as OpenClawConfig; } catch { /* ok */ } + } + await this.writeFile(p, JSON.stringify({ ...existing, ...patch }, null, 2)); + } + + // ── Gateway ─────────────────────────────── + + async gatewayHealthCheck(): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { return { state: 'unknown' as GatewayRunState, error: 'CLI not installed' }; } + try { + const r = await this.exec(cliPath, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (r.code === 0) { + try { + const parsed = JSON.parse(r.stdout) as Partial; + return { + state: parsed.state ?? 'unknown', + port: parsed.port ?? this._cfg.gatewayPort, + version: parsed.version, + uptime: parsed.uptime, + }; + } catch { + const out = (r.stdout + r.stderr).toLowerCase(); + return { state: out.includes('running') ? 'running' : 'stopped', port: this._cfg.gatewayPort }; + } + } + return { state: 'stopped' as GatewayRunState }; + } catch { return { state: 'error' as GatewayRunState, error: 'Health check failed' }; } + } + + async gatewayStart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited with code ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited with code ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } + } + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed — call installCli first'); } + const code = await this.execStream( + p, + ['onboard', '--provider', params.provider, '--api-key', params.apiKey, '--port', params.port], + {}, onLog, onLog, + ); + if (code !== 0) { throw new Error(`onboard exited with code ${code}`); } + } + + // ── Environment ─────────────────────────── + + buildExecEnv(): Record { + return { ...process.env }; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/src/extension.ts b/apps/editor/extensions/openclaw-ssh/src/extension.ts index 55a38ee4..47ac0855 100644 --- a/apps/editor/extensions/openclaw-ssh/src/extension.ts +++ b/apps/editor/extensions/openclaw-ssh/src/extension.ts @@ -1,13 +1,28 @@ import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { SSHHostAdapter } from './adapter'; +import { SSHSetupPanel } from './setup-panel'; -/** - * openclaw-ssh — Preview stub. - * - * SSH host support is coming soon. This extension activates silently - * and will register an SSHHostAdapter once the implementation lands. - */ -export function activate(_context: vscode.ExtensionContext): void { - console.log('[openclaw-ssh] SSH adapter — coming soon'); +export async function activate(context: vscode.ExtensionContext): Promise { + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (!coreExt) { + console.warn('[openclaw-ssh] Core extension not found'); + return; + } + const coreAPI = coreExt.isActive ? coreExt.exports : await coreExt.activate(); + if (!coreAPI) { + console.warn('[openclaw-ssh] Core extension did not export API'); + return; + } + const disposable = coreAPI.registerHostAdapter(new SSHHostAdapter()); + context.subscriptions.push(disposable); + + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.ssh', () => { + SSHSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + + console.log('[openclaw-ssh] SSHHostAdapter registered'); } export function deactivate(): void {} diff --git a/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts b/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts new file mode 100644 index 00000000..dc5e7212 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts @@ -0,0 +1,341 @@ +import * as cp from 'child_process'; +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; + +export class SSHSetupPanel { + public static currentPanel: SSHSetupPanel | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _disposables: vscode.Disposable[] = []; + + public static createOrShow(extensionUri: vscode.Uri, coreAPI: OpenClawCoreAPI): void { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (SSHSetupPanel.currentPanel) { + SSHSetupPanel.currentPanel._panel.reveal(column); + return; + } + + const homeExt = vscode.extensions.getExtension('openclaw.home'); + const homeUri = homeExt?.extensionUri ?? extensionUri; + const iconUri = vscode.Uri.joinPath(homeUri, 'media', 'icon.png'); + + const panel = vscode.window.createWebviewPanel( + 'openclawSSHSetup', + 'OCC Home [SSH]', + column ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(homeUri, 'media'), + ], + retainContextWhenHidden: true, + }, + ); + + SSHSetupPanel.currentPanel = new SSHSetupPanel(panel, extensionUri, coreAPI, iconUri); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + private readonly _coreAPI: OpenClawCoreAPI, + iconUri: vscode.Uri, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + + const webviewIconUri = panel.webview.asWebviewUri(iconUri).toString(); + this._panel.webview.html = this._getHtml(webviewIconUri); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + async (msg: { command: string; host?: string; user?: string; port?: string; keyPath?: string }) => { + switch (msg.command) { + case 'sshInstallBegin': { + const host = msg.host ?? ''; + const user = msg.user ?? ''; + const port = parseInt(msg.port ?? '22', 10) || 22; + const keyPath = msg.keyPath || undefined; + await this._handleSSHInstall(host, user, port, keyPath); + break; + } + case 'closePanel': + this.dispose(); + break; + default: + if (msg.command) { + void vscode.commands.executeCommand(msg.command); + } + break; + } + }, + null, + this._disposables, + ); + } + + public dispose(): void { + SSHSetupPanel.currentPanel = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) { d.dispose(); } + } + } + + // ── Handler ──────────────────────────────────────────────────────────────── + + private async _handleSSHInstall(host: string, user: string, port: number, keyPath?: string): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'sshLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'sshError', text }); } catch { /* ignore */ } + }; + + try { + const sshBase = [ + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15', + '-o', 'StrictHostKeyChecking=accept-new', + ]; + if (port !== 22) { sshBase.push('-p', String(port)); } + if (keyPath) { sshBase.push('-i', keyPath); } + const remote = `${user}@${host}`; + + // Test connection + log(`Testing SSH connection to ${remote}...\n`); + const test = cp.spawnSync('ssh', [...sshBase, remote, 'echo ok'], { timeout: 15000, windowsHide: true }); + if (test.status !== 0 || !test.stdout.toString().includes('ok')) { + fail(`Cannot connect to ${remote}.\n${test.stderr?.toString().trim() || 'Check hostname, user, and SSH key.'}`); + return; + } + log(`\u2713 Connected to ${remote}\n`); + + // Check if openclaw already installed + log('Checking for OpenClaw CLI...\n'); + const check = cp.spawnSync('ssh', [...sshBase, remote, 'which openclaw'], { timeout: 8000, windowsHide: true }); + const alreadyInstalled = check.status === 0 && check.stdout.toString().trim().length > 0; + + if (!alreadyInstalled) { + log('Installing OpenClaw...\n'); + const code = await new Promise((resolve) => { + const proc = cp.spawn('ssh', [...sshBase, remote, 'curl -fsSL https://get.openclaw.sh | bash'], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (c) => resolve(c ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (code !== 0) { fail('OpenClaw installation failed. See the log above for details.'); return; } + log('\n\u2713 OpenClaw installed\n'); + } else { + log('\u2713 OpenClaw already installed \u2014 skipping\n'); + } + + // Register SSH host + log('\nRegistering SSH host...\n'); + const entry = await this._coreAPI.addHost({ + type: 'ssh', + label: `SSH (${user}@${host})`, + connection: { + type: 'ssh', + host, + port: port !== 22 ? port : undefined, + user, + authMethod: keyPath ? 'key' : 'agent', + keyPath: keyPath || undefined, + }, + lastStatus: 'online', + }); + await this._coreAPI.setActiveHost(entry.id); + + log('\u2713 Done \u2014 loading setup...\n'); + try { this._panel.webview.postMessage({ type: 'sshInstallDone' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + // ── HTML ─────────────────────────────────────────────────────────────────── + + private _getHtml(iconUri: string): string { + return ` + + + + + + + + +
SSH Installation
+
Install OpenClaw on a remote server via SSH
+ + +
+
+
+
+
Hostname or IP
+ +
+
+
Port
+ +
+
+
+
SSH User
+ +
+
+
SSH Key Path (leave blank to use SSH agent)
+ +
+
+ +
+
+ + +
+
+
+ Connecting... +
+
+ + +
+ + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index d39f3e48..7ae0d746 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -628,12 +628,8 @@ export async function activate(context: vscode.ExtensionContext): Promise { - void HomePanel.runInstall( - context.extensionUri, - process.platform, - process.arch, - process.env.SHELL ?? '', - ); + // Delegate to LocalSetupPanel via the host setup command + void vscode.commands.executeCommand('openclaw.host.setup.local'); }), vscode.commands.registerCommand('openclaw.openWorkspace', () => { void openOpenClawFolder(); diff --git a/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts b/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts new file mode 100644 index 00000000..77f82d4b --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface HostFileEntry { + id: string; + type: 'local' | 'docker' | 'ssh'; + label: string; + configuredAt: string; + containerName?: string; + lastStatus?: string; +} + +export interface HostsFileData { + hosts: HostFileEntry[]; +} + +const HOSTS_PATH = path.join(os.homedir(), '.occ', 'hosts.json'); + +export function readHostsFile(): HostsFileData { + try { + const raw = fs.readFileSync(HOSTS_PATH, 'utf-8'); + return JSON.parse(raw) as HostsFileData; + } catch { + return { hosts: [] }; + } +} + +export function writeHostEntry(entry: HostFileEntry): void { + const occDir = path.join(os.homedir(), '.occ'); + if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } + const file = readHostsFile(); + // Remove existing entry with same id + file.hosts = file.hosts.filter(h => h.id !== entry.id); + file.hosts.push(entry); + fs.writeFileSync(HOSTS_PATH, JSON.stringify(file, null, 2), 'utf-8'); +} diff --git a/apps/editor/extensions/openclaw/src/hosts/manager.ts b/apps/editor/extensions/openclaw/src/hosts/manager.ts index 275a3d45..868755d9 100644 --- a/apps/editor/extensions/openclaw/src/hosts/manager.ts +++ b/apps/editor/extensions/openclaw/src/hosts/manager.ts @@ -130,6 +130,15 @@ export class HostManager implements OpenClawCoreAPI, vscode.Disposable { return undefined; } + async addHost(entry: Omit): Promise { + const id = `${entry.type}-${Date.now()}`; + const full: HostEntry = { ...entry, id, createdAt: new Date().toISOString() }; + this.registry.addHost(full); + this._onDidAddHost.fire(full); + await this._ensureConnected(id).catch(() => { /* ignore — caller handles */ }); + return full; + } + async refreshHost(id: string): Promise { const conn = this._connections.get(id); if (!conn) { diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts index 86a628b0..932d4634 100644 --- a/apps/editor/extensions/openclaw/src/hosts/types.ts +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -300,5 +300,6 @@ export interface OpenClawCoreAPI { showHostPicker(): Promise; showAddHostWizard(type?: HostType): Promise; + addHost(entry: Omit): Promise; refreshHost(id: string): Promise; } diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 20af41df..f794e2e6 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -7,6 +7,7 @@ import * as os from 'os'; import * as path from 'path'; import type { HostConnection, OpenClawCoreAPI } from '../hosts/types'; import { DefaultLocalHostConnection } from '../hosts/localDefault'; +import { renderStatusHtml } from './statusHtml'; type GatewayStatus = 'checking' | 'running' | 'stopped' | 'starting' | 'stopping' | 'restarting' | 'errored' | 'ai-fixing'; @@ -85,10 +86,6 @@ function getOpenClawWorkspaceDir(): string { export class HomePanel { public static currentPanel: HomePanel | undefined; private static _installTerminal: vscode.Terminal | undefined; - /** Resolves with the password (or undefined on cancel) when the webview modal submits. */ - private static _pendingPasswordResolve: ((pwd: string | undefined) => void) | undefined; - /** Absolute path to the openclaw binary found immediately after a successful install. */ - private static _installedCliPath: string | undefined; private readonly _panel: vscode.WebviewPanel; private readonly _extensionUri: vscode.Uri; private _disposables: vscode.Disposable[] = []; @@ -108,6 +105,8 @@ export class HomePanel { private _host: HostConnection = new DefaultLocalHostConnection(); /** Cached gateway port — populated on every _update() so _getConfiguredPort() stays sync. */ private _cachedGatewayPort = 18789; + /** Core extension API — used to register docker hosts after wizard completes. */ + private _coreAPI: OpenClawCoreAPI | undefined; private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { this._panel = panel; @@ -117,6 +116,7 @@ export class HomePanel { const coreExt = vscode.extensions.getExtension('openclaw.home'); if (coreExt?.isActive && coreExt.exports != null) { const coreAPI = coreExt.exports; + this._coreAPI = coreAPI; const hostSub = coreAPI.onDidChangeActiveHost(conn => { this._host = conn ?? new DefaultLocalHostConnection(); void this._update(); @@ -162,47 +162,6 @@ export class HomePanel { 'Please run `openclaw update` to upgrade OpenClaw to the latest version.', 'agent', ); - } else if (msg.command === 'runSetup') { - void this._runSetup(msg as { command: string; provider: string; apiKey: string; port: string }); - } else if (msg.command === 'autoSetupSkipped') { - setTimeout(() => { - HomePanel.refresh(); - void vscode.commands.executeCommand('openclaw.openWorkspace'); - setTimeout(() => { - vscode.commands.executeCommand('void.openChatWithMessage', - 'Run `openclaw gateway start` to start the OpenClaw gateway.', - 'agent'); - }, 1000); - }, 500); - } else if (msg.command === 'verifyCliBeforeSetup') { - // After install, verify the CLI is actually findable before auto-configuring. - // First: use the path we captured immediately after npm install (most reliable). - const storedPath = HomePanel._installedCliPath; - if (storedPath && fs.existsSync(storedPath)) { - try { this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); } catch {} - return; - } - // Fallback: full PATH-based search. - void this._testOpenClawCli().then(result => { - if (result.ok) { - try { - this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); - } catch {} - } else { - try { - this._panel.webview.postMessage({ - type: 'installLog', - text: '\n⚠️ OpenClaw was installed but could not be found in PATH.\n' + - ' Please restart OCCode to pick up the new PATH.\n' - }); - } catch {} - void this._update(); - } - }); - } else if (msg.command === 'sudoPassword') { - // Password modal submitted or cancelled from the webview. - HomePanel._pendingPasswordResolve?.(msg.password as string | undefined); - HomePanel._pendingPasswordResolve = undefined; } else if (msg.command === 'toggleChat') { const cmd = this._sidebarOpen ? 'void.sidebar.close' : 'void.sidebar.open'; void vscode.commands.executeCommand(cmd).then(async () => { @@ -293,6 +252,15 @@ export class HomePanel { if (args && args.length > 0) { void vscode.commands.executeCommand('void.openChatWithMessage', args[0], 'agent'); } + } else if (msg.command === 'chooseHostType') { + const t = msg.hostType as string; + if (t === 'local') { + void vscode.commands.executeCommand('openclaw.host.setup.local'); + } else if (t === 'docker') { + void vscode.commands.executeCommand('openclaw.host.setup.docker'); + } else if (t === 'ssh') { + void vscode.commands.executeCommand('openclaw.host.setup.ssh'); + } } else if (msg.command) { vscode.commands.executeCommand(msg.command); } @@ -325,405 +293,6 @@ export class HomePanel { } } - /** - * Fully silent install — no terminal is ever opened. - * Output is streamed line-by-line to the home panel webview. - * If sudo is needed, a VS Code password dialog is shown. - * On any failure the AI is invoked immediately with full context. - */ - public static async runInstall( - extensionUri: vscode.Uri, - platform: string, - arch: string, - shell: string, - ): Promise { - HomePanel.createOrShow(extensionUri); - const panel = HomePanel.currentPanel; - if (!panel) return; - - const post = (msg: object) => { try { panel._panel.webview.postMessage(msg); } catch {} }; - let fullLog = ''; - const tee = (text: string) => { fullLog += text; post({ type: 'installLog', text }); writeLog(text); }; - - writeLog(`\n=== runInstall START platform=${platform} arch=${arch} ===\n`); - post({ type: 'installState', state: 'running' }); - - const env = panel._buildExecEnv(); - const isPermError = (s: string) => /EACCES|permission denied|EPERM|not permitted|Need sudo access|needs to be an Administrator/i.test(s); - - // Quick command check helper (no output, just exit code) - const cmdExists = (cmd: string): Promise => - new Promise(resolve => - cp.exec(cmd, { env, timeout: 5000, windowsHide: true }, err => resolve(!err)) - ); - - // Spawn a command silently and stream output to the panel. - const runCaptured = (cmd: string, args: string[], opts: cp.SpawnOptions = {}): Promise<{ code: number }> => - new Promise(resolve => { - const child = cp.spawn(cmd, args, { env, stdio: ['ignore', 'pipe', 'pipe'], ...opts }); - child.stdout?.on('data', (d: Buffer) => tee(d.toString())); - child.stderr?.on('data', (d: Buffer) => tee(d.toString())); - child.on('close', code => resolve({ code: code ?? 1 })); - child.on('error', err => { tee(`\nError: ${err.message}\n`); resolve({ code: 1 }); }); - }); - - // Ask for sudo password via in-webview modal, cache with `sudo -S -v`, return success. - const cacheSudo = async (_prompt: string): Promise => { - const password = await new Promise(resolve => { - HomePanel._pendingPasswordResolve = resolve; - post({ type: 'requestPassword' }); - }); - if (!password) return false; - tee('Verifying credentials...\n'); - return new Promise(resolve => { - const child = cp.spawn('sudo', ['-S', '-v'], { env, stdio: ['pipe', 'pipe', 'pipe'] }); - child.stdin?.write(password + '\n'); - child.stdin?.end(); - child.on('close', code => resolve(code === 0)); - child.on('error', () => resolve(false)); - }); - }; - - // Fix ~/.openclaw ownership after any sudo-based install. - // Uses `sudo -n` (non-interactive) which succeeds as long as the cached sudo session - // from cacheSudo() is still valid (~15 min default). Safe to call even when sudo - // wasn't used — chown is a no-op when the directory is already user-owned. - const fixOpenclawPermissions = async () => { - if (platform === 'win32') return; - const openclawDir = path.join(os.homedir(), '.openclaw'); - if (!fs.existsSync(openclawDir)) return; - try { - const username = os.userInfo().username; - tee('Fixing .openclaw folder ownership...\n'); - await runCaptured('sudo', ['-n', 'chown', '-R', username, openclawDir]); - await runCaptured('chmod', ['700', openclawDir]); - tee('✅ Permissions set (700, owned by you)\n'); - } catch { /* non-fatal */ } - }; - - const failCancelled = () => { - post({ type: 'installState', state: 'cancelled' }); - }; - - const fail = async () => { - writeLog('=== runInstall END failed ===\n'); - post({ type: 'installState', state: 'failed' }); - const platformDesc = platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : `Linux (${arch})`; - await vscode.commands.executeCommand('void.openChatWithMessage', [ - `OpenClaw installation failed on **${platformDesc}**.`, - ``, `**System info:**`, - `- Node.js: \`${process.version}\``, - `- Shell: \`${shell || 'unknown'}\``, - ``, `**Full output:**`, `\`\`\``, fullLog.trim(), `\`\`\``, ``, - `Please diagnose what went wrong and provide exact steps to fix it on this platform.`, - `If Node.js or npm is missing, explain how to install them first.`, - ].join('\n')); - void vscode.commands.executeCommand('openclaw.balance.spend'); - }; - - // After a successful npm install, find the binary's absolute path immediately - // (before PATH settles) so verifyCliBeforeSetup can use it directly. - const captureInstalledPath = async (): Promise => { - if (platform === 'win32') return; // Windows uses cmd shims; handled by _testOpenClawCli - const candidates: string[] = []; - // 1. Ask npm where its global prefix is (same env as the install used) - const prefixResult = await new Promise(resolve => { - cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => - resolve(err ? '' : (stdout || '').trim()) - ); - }); - if (prefixResult) candidates.push(path.join(prefixResult, 'bin', 'openclaw')); - // 2. Hard-coded common macOS/Linux paths - const home = os.homedir(); - candidates.push( - '/usr/local/bin/openclaw', - '/opt/homebrew/bin/openclaw', - path.join(home, '.local', 'bin', 'openclaw'), - path.join(home, '.npm-global', 'bin', 'openclaw'), - path.join(home, '.openclaw', 'bin', 'openclaw'), - ); - // 3. nvm versions (newest first) - const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); - if (fs.existsSync(nvmVersionsDir)) { - fs.readdirSync(nvmVersionsDir) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) - .forEach(ver => candidates.push(path.join(nvmVersionsDir, ver, 'bin', 'openclaw'))); - } - const found = candidates.find(c => c && fs.existsSync(c)); - if (found) { - HomePanel._installedCliPath = found; - tee(` ✓ Binary found at ${found}\n`); - } - }; - - const succeed = async () => { - await captureInstalledPath(); - tee('\n✅ Installed successfully!\n'); - writeLog('=== runInstall END ok ===\n'); - post({ type: 'installState', state: 'done' }); - // Webview drives the post-install navigation via autoSetupSkipped or wizardLog done - }; - - // ══════════════════════════════════════════════════════════════════════════ - // PREREQUISITE CHECKS + PROACTIVE SUDO — run BEFORE any install attempt - // ══════════════════════════════════════════════════════════════════════════ - tee('Checking prerequisites...\n'); - - if (platform === 'darwin') { - // macOS: check Xcode Command Line Tools - const xcodeOk = await new Promise(resolve => - cp.exec('xcode-select -p', { env, timeout: 5000 }, (err, stdout) => { - resolve(!err && !!stdout?.toString().trim()); - }) - ); - if (!xcodeOk) { - post({ type: 'xcodeRequired' }); - return; - } - tee(' ✓ Xcode Command Line Tools\n'); - } - - if (platform === 'win32') { - // Windows: check Node.js exists; auto-install if missing (no UAC required) - let nodeOk = await cmdExists('node --version'); - if (!nodeOk) { - const nodeVersion = '20.18.2'; - const nodeArch = arch === 'arm64' ? 'arm64' : 'x64'; - const zipName = `node-v${nodeVersion}-win-${nodeArch}.zip`; - const zipUrl = `https://nodejs.org/dist/v${nodeVersion}/${zipName}`; - const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); - const installDir = path.join(localAppData, 'Programs', 'nodejs'); - const tmpZip = path.join(os.tmpdir(), zipName); - const tmpExtract = path.join(os.tmpdir(), `occ-node-${Date.now()}`); - - tee(` ⚠ Node.js not found — downloading v${nodeVersion} (${nodeArch})...\n`); - const dlR = await runCaptured('powershell', [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing '${zipUrl}' -OutFile '${tmpZip}'`, - ], { windowsHide: true, shell: true } as cp.SpawnOptions); - - if (dlR.code !== 0) { - tee(' ❌ Failed to download Node.js. Check your internet connection.\n'); - await fail(); return; - } - - tee(` Extracting...\n`); - // Pre-compute the inner folder name (avoids | pipe which cmd.exe would intercept) - const innerDir = path.join(tmpExtract, `node-v${nodeVersion}-win-${nodeArch}`); - const exR = await runCaptured('powershell', [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ProgressPreference='SilentlyContinue'; ` + - `Expand-Archive -Path '${tmpZip}' -DestinationPath '${tmpExtract}' -Force; ` + - `if (Test-Path '${installDir}') { Remove-Item '${installDir}' -Recurse -Force }; ` + - `Move-Item '${innerDir}' '${installDir}'`, - ], { windowsHide: true, shell: true } as cp.SpawnOptions); - - try { fs.unlinkSync(tmpZip); } catch {} - - if (exR.code !== 0) { - tee(' ❌ Failed to extract Node.js.\n'); - await fail(); return; - } - - // Make new Node.js dir visible to all subsequent commands in this install run - env.PATH = [installDir, env.PATH || ''].filter(Boolean).join(';'); - (env as any).Path = env.PATH; - - nodeOk = await cmdExists('node --version'); - if (!nodeOk) { - tee(' ❌ Node.js install did not complete properly.\n'); - await fail(); return; - } - tee(` ✓ Node.js v${nodeVersion} installed to ${installDir}\n`); - } else { - tee(' ✓ Node.js found\n'); - } - } - - const npmOk = await cmdExists('npm --version'); - if (npmOk) { - tee(' ✓ npm found\n'); - } else if (platform === 'win32') { - tee(' ❌ npm not found after Node.js install — unexpected.\n'); - await fail(); return; - } else { - tee(' ⚠ npm not found — will attempt to install Node.js\n'); - } - - // Proactive sudo detection (macOS / Linux only) - let sudoCached = false; - if (platform !== 'win32') { - let needsSudo = false; - if (npmOk) { - const prefixResult = await new Promise(resolve => - cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => - resolve(err ? '' : stdout?.toString().trim() || '')) - ); - if (prefixResult) { - try { - const gBin = path.join(prefixResult, 'bin'); - const gLib = path.join(prefixResult, 'lib'); - if (fs.existsSync(gBin)) fs.accessSync(gBin, fs.constants.W_OK); - if (fs.existsSync(gLib)) fs.accessSync(gLib, fs.constants.W_OK); - } catch { - needsSudo = true; - } - } - } else { - // No npm — will need to install Node.js, likely needs sudo - for (const dir of ['/usr/local/bin', '/usr/local/lib']) { - try { if (fs.existsSync(dir)) fs.accessSync(dir, fs.constants.W_OK); } catch { needsSudo = true; break; } - } - } - - if (needsSudo) { - tee('\n Administrator password required for installation.\n'); - const sudoOk = await cacheSudo('Enter your system password to install OpenClaw'); - if (!sudoOk) { tee(' Incorrect password or cancelled.\n'); failCancelled(); return; } - tee(' ✓ Credentials verified\n'); - sudoCached = true; - } else { - tee(' ✓ Write access OK\n'); - } - } - - tee('\n'); - - // ── Step 1: try npm install -g openclaw ─────────────────────────────────── - if (npmOk) { - tee('Installing openclaw via npm...\n'); - const spawnOpts: cp.SpawnOptions = platform === 'win32' ? { shell: true, windowsHide: true } : {}; - const npmArgs = ['install', '-g', 'openclaw']; - const r1 = sudoCached - ? await runCaptured('sudo', ['-E', 'npm', ...npmArgs]) - : await runCaptured('npm', npmArgs, spawnOpts); - if (r1.code === 0) { - if (sudoCached) await fixOpenclawPermissions(); - await succeed(); return; - } - // If sudo wasn't cached and we hit permission error, ask now (fallback) - if (!sudoCached && platform !== 'win32' && isPermError(fullLog)) { - tee('\nPermission error — elevated access required.\n'); - const ok = await cacheSudo('Enter your system password to install OpenClaw'); - if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - sudoCached = true; - tee('Retrying with elevated permissions...\n'); - const r2 = await runCaptured('sudo', ['-E', 'npm', 'install', '-g', 'openclaw']); - if (r2.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - } - tee('\nnpm install did not succeed — trying full installer script...\n'); - } else if (platform !== 'win32') { - - // ── Unix: npm not found — silent Node.js install (no terminal) ────────── - // Step A: try nvm (no sudo, no password needed) - const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); - if (fs.existsSync(nvmSh)) { - tee('nvm detected — installing Node.js LTS...\n'); - const nvmR = await runCaptured('bash', ['-c', - `. "${nvmSh}" && nvm install --lts && nvm use --lts && npm install -g openclaw` - ]); - if (nvmR.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - tee('nvm install failed — falling back to system install...\n'); - } - - // Step B: ensure sudo is cached (may already be from proactive check above) - if (!sudoCached) { - tee('\nNode.js is required. Your password is needed once to install it.\n'); - const sudoOk = await cacheSudo('Enter your password to install Node.js'); - if (!sudoOk) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - sudoCached = true; - } - - if (platform === 'darwin') { - // macOS: download official Node.js universal pkg, install silently with cached sudo - const nodeVersion = '20.18.2'; - const pkgUrl = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}.pkg`; - const pkgPath = `/tmp/.occ-node-${nodeVersion}.pkg`; - tee(`Downloading Node.js v${nodeVersion}...\n`); - const dlR = await runCaptured('curl', ['-fsSL', pkgUrl, '-o', pkgPath]); - if (dlR.code !== 0) { try { fs.unlinkSync(pkgPath); } catch {} await fail(); return; } - tee('Installing Node.js (this may take a moment)...\n'); - const instR = await runCaptured('sudo', ['-n', 'installer', '-pkg', pkgPath, '-target', '/']); - try { fs.unlinkSync(pkgPath); } catch { /* non-fatal */ } - if (instR.code !== 0) { await fail(); return; } - } else { - // Linux: detect package manager and install Node.js LTS via nodesource - const hasCmdSync = (cmd: string): boolean => { - try { cp.execSync(`which ${cmd}`, { env, stdio: 'ignore' }); return true; } catch { return false; } - }; - tee('Installing Node.js via package manager...\n'); - let pkgResult: { code: number } | undefined; - if (hasCmdSync('apt-get')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get install -y nodejs' - ]); - } else if (hasCmdSync('dnf')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && dnf install -y nodejs' - ]); - } else if (hasCmdSync('yum')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && yum install -y nodejs' - ]); - } - if (!pkgResult) { tee('No supported package manager found (tried apt-get, dnf, yum).\n'); await fail(); return; } - if (pkgResult.code !== 0) { await fail(); return; } - } - - // Step C: npm is now installed — find it and install openclaw - tee('Installing OpenClaw...\n'); - const npmCandidates = ['/usr/local/bin/npm', '/usr/bin/npm']; - const npmBin = npmCandidates.find(p => fs.existsSync(p)) ?? 'npm'; - const npmR1 = await runCaptured(npmBin, ['install', '-g', 'openclaw']); - if (npmR1.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - // Global prefix dir may be root-owned — retry with cached sudo - if (isPermError(fullLog)) { - const npmR2 = await runCaptured('sudo', ['-n', npmBin, 'install', '-g', 'openclaw']); - if (npmR2.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - } - await fail(); return; - - } else { - tee('npm not found — running full installer script...\n'); - } - - // ── Step 2: full install script ── (npm found but failed, or Windows no npm) - if (platform === 'win32') { - tee('Running PowerShell installer...\n'); - const psArgs = [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; ` + - `Invoke-WebRequest -UseBasicParsing https://openclaw.ai/install.ps1 | Invoke-Expression`, - ]; - const r = await runCaptured('powershell', psArgs, { windowsHide: true } as cp.SpawnOptions); - if (r.code === 0) { await succeed(); return; } - } else { - // npm was found but install failed — try the openclaw installer script - tee('Running install script...\n'); - const r1 = await runCaptured('bash', ['-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); - if (r1.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - if (isPermError(fullLog)) { - tee('\nPermission error in installer — elevated access required.\n'); - const ok = await cacheSudo('Enter your system password to complete installation'); - if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - tee('Retrying with elevated permissions...\n'); - const r2 = await runCaptured('sudo', ['-E', 'bash', '-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); - if (r2.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - } - } - - await fail(); - } - public dispose() { HomePanel.currentPanel = undefined; this._stopPolling(); @@ -773,7 +342,9 @@ export class HomePanel { const cliCheck = await this._testOpenClawCli(); writeLog(`[cli-check] ok=${cliCheck.ok} cmd="${cliCheck.command}" output="${(cliCheck.output ?? '').trim()}"\n`); - const isInstalled = isConfigured; // config file is the sole source of truth — a leftover binary without config is not "installed" + // For remote hosts (docker/ssh): CLI installed but not yet onboarded — go straight to configure step. + // For local: config file is the sole source of truth (leftover binary without config = not installed). + const isInstalled = isConfigured || (this._host.type !== 'local' && cliCheck.ok); this._lastInstalledState = isInstalled; this._lastInstalledVersion = cliCheck.ok ? (cliCheck.output ?? '').trim() : null; const iconUri = this._panel.webview.asWebviewUri( @@ -795,7 +366,7 @@ export class HomePanel { // Show unified setup view when OpenClaw is not fully configured yet. if (!isConfigured) { - this._panel.webview.html = this._getSetupHtml(isInstalled, iconUri.toString(), occUser); + this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString()); this._autoUpdateTriggered = false; // reset so check fires when they reach the dashboard } else { const emojiBaseUri = this._panel.webview.asWebviewUri( @@ -1113,6 +684,83 @@ export class HomePanel { } catch { /* best-effort */ } } + private _getHostTypeSelectionHtml(iconUri: string): string { + return ` + + + + + + + + +

Welcome to OpenClaw

+

Where do you want to run OpenClaw?

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