diff --git a/apps/editor/extensions/openclaw-docker/package-lock.json b/apps/editor/extensions/openclaw-docker/package-lock.json new file mode 100644 index 00000000..ec3be7d1 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "openclaw-docker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-docker", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/editor/extensions/openclaw-docker/package.json b/apps/editor/extensions/openclaw-docker/package.json new file mode 100644 index 00000000..a2cf4302 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/package.json @@ -0,0 +1,25 @@ +{ + "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/openclaw-docker/src/extension", + "contributes": { + "commands": [{ "command": "openclaw.host.setup.docker", "title": "OpenClaw: Set up Docker" }] + }, + "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/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..d0764beb --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/connection.ts @@ -0,0 +1,332 @@ +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 '/home/node/.openclaw/openclaw.json'; // official image runs as user 'node' (uid 1000) + } + + gatewayHostPort(): number | undefined { + return this._config.portMappings?.gateway; + } + + localStateDir(): string { + return this._config.localMountPath ?? path.join(os.homedir(), 'Desktop', 'occ-state-dir'); + } + + 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..63d70fef --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/extension.ts @@ -0,0 +1,36 @@ +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'); + 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); + + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.docker', () => { + DockerSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + + 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-docker/src/setup-panel.ts b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts new file mode 100644 index 00000000..fe0b2d16 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts @@ -0,0 +1,745 @@ +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, setActiveOpenClawWorkspaceFolder } from '../../openclaw/src/panels/statusController'; + +const IMAGE = 'ghcr.io/openclaw/openclaw:latest'; +const CONTAINER = 'occ-openclaw'; +const HOST_PORT = 18790; +const CONTAINER_PORT = 18790; +const STATE_DIR = path.join(os.homedir(), 'Desktop', 'occ-state-dir'); +const VOLUME_MOUNT = `${STATE_DIR}:/home/node/.openclaw`; + +export class DockerSetupPanel { + public static currentPanel: DockerSetupPanel | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _disposables: vscode.Disposable[] = []; + private _statusController: StatusPanelController | undefined; + private _disposed = false; + + 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; + + void this._initHtml(iconUri); + + 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 'dockerPullImage': + await this._handlePullImage(); + break; + case 'dockerOnboard': + await this._handleOnboard(); + break; + case 'dockerLaunchGateway': + await this._handleLaunchGateway(); + break; + case 'closePanel': + this.dispose(); + break; + default: + if (msg.command) { + void vscode.commands.executeCommand(msg.command); + } + break; + } + }, + null, + this._disposables, + ); + } + + public dispose(): void { + if (this._disposed) return; + this._disposed = true; + 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(); } + } + } + + /** On open: jump straight to status panel if the container is already set up, else show wizard. */ + private async _initHtml(iconUri: vscode.Uri): Promise { + const webviewIconUri = this._panel.webview.asWebviewUri(iconUri).toString(); + + // Quick synchronous check — is the container running at all? + const containerRunning = (() => { + try { + const result = cp.spawnSync( + 'docker', + ['ps', '--filter', `name=^/${CONTAINER}$`, '--format', '{{.Status}}'], + { timeout: 5000, windowsHide: true }, + ); + const st = (result.stdout?.toString() ?? '').trim(); + return st.length > 0 && st.toLowerCase().startsWith('up'); + } catch { return false; } + })(); + + if (containerRunning) { + // Health check: config file at the known path is the source of truth. + const configCheck = cp.spawnSync( + 'docker', + ['exec', CONTAINER, 'test', '-f', '/home/node/.openclaw/openclaw.json'], + { timeout: 5000, windowsHide: true }, + ); + const isConfigured = configCheck.status === 0; + + if (isConfigured) { + // Show a loading placeholder immediately so the panel isn't blank + // while the async status checks (docker exec calls) run in the background. + this._panel.webview.html = this._getLoadingHtml(webviewIconUri); + void this._showStatusPanel().catch(() => { + // If status panel fails to load, fall back to the wizard so the user + // isn't stuck looking at a blank / loading screen. + if (!this._disposed) { + this._panel.webview.html = this._getHtml(webviewIconUri); + } + }); + return; + } + + // Container running but config missing — it's broken. Delete it and restart. + cp.spawnSync('docker', ['rm', '-f', CONTAINER], { timeout: 10000, windowsHide: true }); + } + + // Container not running (or was deleted above) — show the setup wizard. + this._panel.webview.html = this._getHtml(webviewIconUri); + } + + private async _showStatusPanel(): Promise { + if (!this._statusController) { + const { DockerHostConnection } = await import('./connection'); + const host = new DockerHostConnection( + { type: 'docker', containerLabel: CONTAINER, portMappings: { gateway: HOST_PORT }, localMountPath: STATE_DIR }, + CONTAINER, + ); + this._statusController = new StatusPanelController( + this._panel, + this._homeUri, + host, + () => { + // Disconnect: clear binding, dispose this panel, reopen the host picker (never auto-route). + this.dispose(); + void vscode.commands.executeCommand('openclaw.home.picker'); + }, + ); + } + await this._statusController.show(); + this._panel.title = `OCC Home {Docker:${HOST_PORT}}`; + void vscode.commands.executeCommand('occ.window.setHost', { + type: 'docker', hostId: `docker:${CONTAINER}`, port: HOST_PORT, label: `Docker (${CONTAINER})`, + }); + } + + // ── Step 1: Docker preflight ────────────────────────────────────────────── + + 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 */ } + } + } + + // ── Step 2: Pull image + create state dir ───────────────────────────────── + + private async _handlePullImage(): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'dockerLog', text, isErr }); } catch { /* ignore */ } + }; + const logCmd = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerLog', text, isCmd: true }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + try { + logCmd(`$ docker pull ${IMAGE}\n`); + log(`Pulling ${IMAGE}...\n`); + const pullCode = await new Promise((resolve) => { + const proc = cp.spawn('docker', ['pull', IMAGE], { 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 ${IMAGE}. Check your internet connection.`); return; } + log(`\n✓ Image ready\n`); + + // Ensure state directory exists on the host + try { fs.mkdirSync(STATE_DIR, { recursive: true }); } catch { /* ok */ } + log(`✓ State dir: ${STATE_DIR}\n`); + + try { this._panel.webview.postMessage({ type: 'dockerPullDone' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + // ── Step 3: Onboard (one-shot container) ────────────────────────────────── + + private async _handleOnboard(): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'onboardLog', text, isErr }); } catch { /* ignore */ } + }; + const logCmd = (text: string) => { + try { this._panel.webview.postMessage({ type: 'onboardLog', text, isCmd: true }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + try { + logCmd(`$ docker run --rm \\\n -v ${VOLUME_MOUNT} \\\n ${IMAGE} \\\n openclaw onboard --non-interactive --accept-risk \\\n --flow quickstart --auth-choice custom-api-key \\\n --custom-base-url https://occ.mba.sh/v1 \\\n --gateway-auth token --gateway-port ${CONTAINER_PORT}\n`); + log('Running OpenClaw onboard in container...\n'); + const code = await new Promise((resolve) => { + const proc = cp.spawn('docker', [ + 'run', '--rm', + '-v', VOLUME_MOUNT, + IMAGE, + 'openclaw', 'onboard', + '--non-interactive', '--accept-risk', + '--flow', 'quickstart', + '--auth-choice', 'custom-api-key', + '--custom-base-url', 'https://occ.mba.sh/v1', + '--custom-api-key', '', + '--custom-model-id', 'occ-legacy', + '--custom-compatibility', 'openai', + '--gateway-auth', 'token', + '--gateway-port', String(CONTAINER_PORT), + '--skip-channels', '--skip-skills', '--skip-health', + ], { 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('Onboard failed. See the log above for details.'); + return; + } + + // Patch occ-legacy model metadata in the config written to STATE_DIR + log('\nPatching model config...\n'); + try { + const cfgPath = path.join(STATE_DIR, 'openclaw.json'); + if (fs.existsSync(cfgPath)) { + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as Record; + const OCC_LEGACY_COST = { input: 0.0000006, output: 0.000003, cacheRead: 0.0000001, cacheWrite: 0 }; + const patchModel = (obj: unknown): void => { + if (!obj || typeof obj !== 'object') return; + if (Array.isArray(obj)) { obj.forEach(patchModel); return; } + const o = obj as Record; + if (o['id'] === 'occ-legacy') { + o['name'] = 'occ-legacy'; + o['reasoning'] = false; + o['input'] = ['text']; + o['cost'] = { ...OCC_LEGACY_COST }; + o['contextWindow'] = 262144; + o['maxTokens'] = 262144; + return; + } + Object.values(o).forEach(patchModel); + }; + patchModel(cfg); + fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8'); + } + } catch { /* non-fatal */ } + + // Write moltpilot-tier.json into the state dir + try { + fs.writeFileSync( + path.join(STATE_DIR, 'moltpilot-tier.json'), + JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), + ); + } catch { /* non-fatal */ } + + log('\n✓ Onboard complete!\n'); + try { this._panel.webview.postMessage({ type: 'onboardDone' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + // ── Step 4: Launch persistent gateway container ─────────────────────────── + + private async _handleLaunchGateway(): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'launchLog', text, isErr }); } catch { /* ignore */ } + }; + const logCmd = (text: string) => { + try { this._panel.webview.postMessage({ type: 'launchLog', text, isCmd: true }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'dockerError', text }); } catch { /* ignore */ } + }; + + try { + // Remove any existing stopped container with this name + const existing = cp.spawnSync('docker', ['ps', '-a', '--filter', `name=^/${CONTAINER}$`, '--format', '{{.Status}}'], { timeout: 8000, windowsHide: true }); + const existingStatus = existing.stdout?.toString().trim() ?? ''; + + if (existingStatus) { + if (existingStatus.toLowerCase().startsWith('up')) { + // Health check: config file must exist before we consider the container healthy. + const configCheck = cp.spawnSync( + 'docker', + ['exec', CONTAINER, 'test', '-f', '/home/node/.openclaw/openclaw.json'], + { timeout: 5000, windowsHide: true }, + ); + if (configCheck.status === 0) { + log(`✓ Container ${CONTAINER} is already running and configured\n`); + try { this._panel.webview.postMessage({ type: 'launchDone' }); } catch { /* ignore */ } + setTimeout(() => void this._showStatusPanel(), 1200); + return; + } + // Running but not configured — delete and restart from Step 2. + log(`Container is running but not configured — removing and restarting setup...\n`); + cp.spawnSync('docker', ['rm', '-f', CONTAINER], { timeout: 10000, windowsHide: true }); + try { this._panel.webview.postMessage({ type: 'dockerContainerBroken' }); } catch { /* ignore */ } + return; + } + log(`Removing existing stopped container...\n`); + cp.spawnSync('docker', ['rm', '-f', CONTAINER], { timeout: 10000, windowsHide: true }); + } + + logCmd(`$ docker run -d \\\n --name ${CONTAINER} \\\n --restart unless-stopped \\\n -p ${HOST_PORT}:${CONTAINER_PORT} \\\n -v ${VOLUME_MOUNT} \\\n ${IMAGE} \\\n tail -f /dev/null\n`); + log(`Starting ${CONTAINER} container (port ${HOST_PORT}:${CONTAINER_PORT})...\n`); + const launchCode = await new Promise((resolve) => { + const proc = cp.spawn('docker', [ + 'run', '-d', + '--name', CONTAINER, + '--restart', 'unless-stopped', + '-p', `${HOST_PORT}:${CONTAINER_PORT}`, + '-v', VOLUME_MOUNT, + IMAGE, + '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', (c) => resolve(c ?? -1)); + proc.on('error', () => resolve(-1)); + }); + + if (launchCode !== 0) { fail('Failed to launch gateway container.'); return; } + log(`✓ Container started\n`); + + // Swap workspace folder to show STATE_DIR only (removes ~/.openclaw if present) + try { setActiveOpenClawWorkspaceFolder(STATE_DIR); } catch { /* non-fatal */ } + + log('\n✓ Setup complete!\n'); + try { this._panel.webview.postMessage({ type: 'launchDone' }); } catch { /* ignore */ } + setTimeout(() => void this._showStatusPanel(), 1800); + } catch (err) { + fail(String(err)); + } + } + + // ── HTML ─────────────────────────────────────────────────────────────────── + + private _getLoadingHtml(iconUri: string): string { + return ` + + + + + + + + +
+

Connecting to Docker container…

+ +`; + } + + private _getHtml(iconUri: string): string { + return ` + + + + + + + + +
Docker Setup
+
Setting up OpenClaw in a Docker container
+ + +
+
+
1
+
Docker
Check
+
+
+
2
+
Pull
Image
+
+
+
3
+
Onboard
Config
+
+
+
4
+
Launch
Gateway
+
+
+ + +
+ +
+
+
+ Checking Docker... +
+
+ + + + + + + + + + + + + + + +
+ + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw-docker/tsconfig.json b/apps/editor/extensions/openclaw-docker/tsconfig.json new file mode 100644 index 00000000..c8613a95 --- /dev/null +++ b/apps/editor/extensions/openclaw-docker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/apps/editor/extensions/openclaw-local/package-lock.json b/apps/editor/extensions/openclaw-local/package-lock.json new file mode 100644 index 00000000..ee7213c7 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "openclaw-local", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-local", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/editor/extensions/openclaw-local/package.json b/apps/editor/extensions/openclaw-local/package.json new file mode 100644 index 00000000..1fc26106 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/package.json @@ -0,0 +1,25 @@ +{ + "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/openclaw-local/src/extension", + "contributes": { + "commands": [{ "command": "openclaw.host.setup.local", "title": "OpenClaw: Set up Local" }] + }, + "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/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..d6d9bc9c --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/connection.ts @@ -0,0 +1,372 @@ +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, + 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)); + }); + } + + // ── 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..c1097f18 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/extension.ts @@ -0,0 +1,37 @@ +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 + 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); + + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.local', () => { + LocalSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + + console.log('[openclaw-local] LocalHostAdapter registered'); +} + +export function deactivate(): void { + // Subscriptions cleaned up automatically +} 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..2b1674e5 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/src/setup-panel.ts @@ -0,0 +1,1538 @@ +import * as cp from 'child_process'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as https from 'https'; +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, + () => { + // Disconnect: clear binding, dispose this panel, reopen the host picker (never auto-route). + this.dispose(); + void vscode.commands.executeCommand('openclaw.home.picker'); + }, + ); + await this._statusController.show(); + // Update tab title and set window-level host binding. + try { + const cfg = await this._host.readConfig(); + const gw = cfg['gateway'] as Record | undefined; + const p = gw?.['port'] ?? cfg['gateway_port'] ?? 18789; + const port = typeof p === 'number' ? p : parseInt(String(p), 10) || 18789; + this._panel.title = `OCC Home {Local:${port}}`; + void vscode.commands.executeCommand('occ.window.setHost', { + type: 'local', hostId: 'local', port, label: 'Local', + }); + } catch { + this._panel.title = 'OCC Home {Local}'; + void vscode.commands.executeCommand('occ.window.setHost', { + type: 'local', hostId: 'local', port: 18789, label: 'Local', + }); + } + } + + /** + * Fetches openclaw package metadata from npm, downloads the tarball, + * verifies its SHA-512 integrity, and returns the path to the verified file. + * Throws if the download fails or the hash doesn't match. + */ + private async _downloadAndVerifyOpenClaw(tee: (s: string) => void): Promise { + tee('Fetching package metadata from npm registry...\n'); + + // 1. Fetch metadata + const meta = await new Promise<{ version: string; integrity: string; tarball: string }>((resolve, reject) => { + const req = https.get( + { hostname: 'registry.npmjs.org', path: '/openclaw/latest', headers: { Accept: 'application/json' } }, + res => { + let raw = ''; + res.on('data', (c: Buffer) => { raw += c.toString(); }); + res.on('end', () => { + try { + const d = JSON.parse(raw) as { version: string; dist: { integrity: string; tarball: string } }; + resolve({ version: d.version, integrity: d.dist.integrity, tarball: d.dist.tarball }); + } catch (e) { reject(new Error(`Failed to parse npm metadata: ${e}`)); } + }); + } + ); + req.on('error', reject); + req.setTimeout(15000, () => { req.destroy(); reject(new Error('npm registry request timed out')); }); + }); + + tee(` ✓ Latest version: ${meta.version}\n`); + tee(`Downloading openclaw@${meta.version}...\n`); + + // 2. Download tarball + const tmpFile = path.join(os.tmpdir(), `openclaw-${meta.version}-${Date.now()}.tgz`); + await new Promise((resolve, reject) => { + const follow = (url: string) => { + https.get(url, res => { + if (res.statusCode === 301 || res.statusCode === 302) { + follow(res.headers.location!); return; + } + if (res.statusCode !== 200) { reject(new Error(`Download failed: HTTP ${res.statusCode}`)); return; } + const out = fs.createWriteStream(tmpFile); + res.pipe(out); + out.on('finish', () => { out.close(); resolve(); }); + out.on('error', reject); + res.on('error', reject); + }).on('error', reject); + }; + follow(meta.tarball); + }); + + tee(' ✓ Download complete\n'); + tee('Verifying package integrity...\n'); + + // 3. Verify SHA-512 (SRI format: "sha512-") + const [algo, expectedB64] = meta.integrity.split('-'); + if (algo !== 'sha512' || !expectedB64) { + throw new Error(`Unsupported integrity algorithm: ${algo}. Expected sha512.`); + } + const fileBuffer = fs.readFileSync(tmpFile); + const actualHash = crypto.createHash('sha512').update(fileBuffer).digest('base64'); + if (actualHash !== expectedB64) { + try { fs.unlinkSync(tmpFile); } catch {} + throw new Error( + `⚠ Security check failed — package integrity mismatch for openclaw@${meta.version}.\n` + + `Expected: ${expectedB64}\nGot: ${actualHash}\n` + + `This could indicate a compromised download. Installation aborted.` + ); + } + + tee(' ✓ Integrity verified (SHA-512 match)\n'); + return tmpFile; + } + + 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) { + // Download and verify before installing + let verifiedTgz: string | null = null; + try { + verifiedTgz = await this._downloadAndVerifyOpenClaw(tee); + } catch (e) { + tee(`\n${(e as Error).message}\n`); + await fail(); return; + } + + tee('Installing openclaw...\n'); + const spawnOpts: cp.SpawnOptions = platform === 'win32' ? { shell: true, windowsHide: true } : {}; + const npmArgs = ['install', '-g', verifiedTgz]; + const r1 = sudoCached + ? await runCaptured('sudo', ['-E', 'npm', ...npmArgs]) + : await runCaptured('npm', npmArgs, spawnOpts); + try { fs.unlinkSync(verifiedTgz); } catch {} + 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'); + // Re-download and re-verify for the sudo retry + let retryTgz: string | null = null; + try { + retryTgz = await this._downloadAndVerifyOpenClaw(tee); + } catch (e) { + tee(`\n${(e as Error).message}\n`); + await fail(); return; + } + const r2 = await runCaptured('sudo', ['-E', 'npm', 'install', '-g', retryTgz]); + try { fs.unlinkSync(retryTgz); } catch {} + 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'); + let nvmTgz: string | null = null; + try { + nvmTgz = await this._downloadAndVerifyOpenClaw(tee); + } catch (e) { + tee(`\n${(e as Error).message}\n`); + await fail(); return; + } + const nvmR = await runCaptured('bash', ['-c', + `. "${nvmSh}" && nvm install --lts && nvm use --lts && npm install -g '${nvmTgz}'` + ]); + try { fs.unlinkSync(nvmTgz); } catch {} + 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-local/tsconfig.json b/apps/editor/extensions/openclaw-local/tsconfig.json new file mode 100644 index 00000000..c8613a95 --- /dev/null +++ b/apps/editor/extensions/openclaw-local/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/apps/editor/extensions/openclaw-ssh/package-lock.json b/apps/editor/extensions/openclaw-ssh/package-lock.json new file mode 100644 index 00000000..aae5847f --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "openclaw-ssh", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-ssh", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/editor/extensions/openclaw-ssh/package.json b/apps/editor/extensions/openclaw-ssh/package.json new file mode 100644 index 00000000..47df9f67 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/package.json @@ -0,0 +1,25 @@ +{ + "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/openclaw-ssh/src/extension", + "contributes": { + "commands": [{ "command": "openclaw.host.setup.ssh", "title": "OpenClaw: Set up SSH" }] + }, + "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/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..adf1e078 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/connection.ts @@ -0,0 +1,270 @@ +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 { + if (/\0/.test(filePath)) { throw new Error('Invalid file path: null byte'); } + if (!/^[/~]/.test(filePath)) { throw new Error('Invalid file path: must be absolute or home-relative'); } + 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 new file mode 100644 index 00000000..47ac0855 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/extension.ts @@ -0,0 +1,28 @@ +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { SSHHostAdapter } from './adapter'; +import { SSHSetupPanel } from './setup-panel'; + +export async function activate(context: vscode.ExtensionContext): Promise { + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (!coreExt) { + console.warn('[openclaw-ssh] Core extension not found'); + return; + } + const coreAPI = coreExt.isActive ? coreExt.exports : await coreExt.activate(); + if (!coreAPI) { + console.warn('[openclaw-ssh] Core extension did not export API'); + return; + } + const disposable = coreAPI.registerHostAdapter(new SSHHostAdapter()); + context.subscriptions.push(disposable); + + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.ssh', () => { + SSHSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + + console.log('[openclaw-ssh] SSHHostAdapter registered'); +} + +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-ssh/tsconfig.json b/apps/editor/extensions/openclaw-ssh/tsconfig.json new file mode 100644 index 00000000..c8613a95 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/apps/editor/extensions/openclaw/package-lock.json b/apps/editor/extensions/openclaw/package-lock.json index d4a553e8..687d28e1 100644 --- a/apps/editor/extensions/openclaw/package-lock.json +++ b/apps/editor/extensions/openclaw/package-lock.json @@ -1,11 +1,11 @@ { - "name": "openclaw", + "name": "home", "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "openclaw", + "name": "home", "version": "0.2.2", "license": "MIT", "devDependencies": { diff --git a/apps/editor/extensions/openclaw/package.json b/apps/editor/extensions/openclaw/package.json index a1cd46e4..f6d1b102 100644 --- a/apps/editor/extensions/openclaw/package.json +++ b/apps/editor/extensions/openclaw/package.json @@ -80,6 +80,10 @@ "when": "true" } ], + "viewsContainers": { + "activitybar": [] + }, + "views": {}, "commands": [ { "command": "openclaw.home", @@ -104,6 +108,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..2c6c915d 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import * as cp from 'child_process'; import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; @@ -6,7 +7,13 @@ import * as http from 'http'; import * as https from 'https'; import { HomePanel } from './panels/home'; import { StatusPanel } from './panels/status'; +import { setActiveOpenClawWorkspaceFolder } from './panels/statusController'; import { stopConfigProxy, getDashboardUrl } from './panels/config'; +import { HostRegistry } from './hosts/registry'; +import { HostManager } from './hosts/manager'; +import { HostStatusBarItem } from './hosts/statusbar'; +import { HostTreeProvider } from './hosts/tree'; +import type { OpenClawCoreAPI } from './hosts/types'; const DEFAULT_GATEWAY_PORT = 18789; @@ -24,9 +31,85 @@ function getConfiguredGatewayPort(): number { } } +// ── Window host binding ───────────────────────────────────────────────────── + +/** Describes which host this VS Code window is currently bound to. */ +export interface WindowHostBinding { + type: 'local' | 'docker' | 'ssh'; + hostId: string; + port: number; + label: string; +} + +const WINDOW_HOST_KEY = 'occ.windowHost'; + +function registerWindowHostCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand('occ.window.setHost', (binding: WindowHostBinding) => { + void context.workspaceState.update(WINDOW_HOST_KEY, binding); + }), + vscode.commands.registerCommand('occ.window.clearHost', () => { + void context.workspaceState.update(WINDOW_HOST_KEY, undefined); + }), + /** Returns the current WindowHostBinding for this window, or null. */ + vscode.commands.registerCommand('occ.window.getHost', () => { + return context.workspaceState.get(WINDOW_HOST_KEY) ?? null; + }), + ); +} + +/** + * Smart routing for the "OCC Home" command. + * Priority: stored window binding → detected state → install wizard. + */ +function routeHome(extensionUri: vscode.Uri, context: vscode.ExtensionContext, forcePicker = false): void { + // When forcePicker is set (e.g. after disconnect) always show the host picker — never auto-route. + if (forcePicker) { + HomePanel.createOrShow(extensionUri, true); + return; + } + + // 1. If this window already has a binding, honour it. + const binding = context.workspaceState.get(WINDOW_HOST_KEY); + if (binding?.type === 'local') { + void vscode.commands.executeCommand('openclaw.host.setup.local'); + return; + } + if (binding?.type === 'docker') { + void vscode.commands.executeCommand('openclaw.host.setup.docker'); + return; + } + + // 2. No binding — detect installed hosts and route. + const isLocalInstalled = fs.existsSync( + path.join(os.homedir(), '.openclaw', 'openclaw.json') + ); + + let isDockerRunning = false; + try { + const result = cp.spawnSync( + 'docker', + ['ps', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], + { timeout: 3000, windowsHide: true }, + ); + const st = (result.stdout?.toString() ?? '').trim(); + isDockerRunning = st.length > 0 && st.toLowerCase().startsWith('up'); + } catch { /* docker not available */ } + + if (isLocalInstalled && isDockerRunning) { + HomePanel.createOrShow(extensionUri); // hosts overview — user picks + } else if (isLocalInstalled) { + void vscode.commands.executeCommand('openclaw.host.setup.local'); + } else if (isDockerRunning) { + void vscode.commands.executeCommand('openclaw.host.setup.docker'); + } else { + HomePanel.createOrShow(extensionUri); // install wizard + } +} + /** Returns true if the OpenClaw web server is reachable. */ -function isWebServerReachable(): Promise { - const port = getConfiguredGatewayPort(); +function isWebServerReachable(portOverride?: number): Promise { + const port = portOverride ?? getConfiguredGatewayPort(); const url = `http://localhost:${port}/`; return new Promise(resolve => { const req = http.get(url, { timeout: 3000 }, res => { @@ -130,7 +213,7 @@ async function hideActivityBarItems( */ const WORKSPACE_FILENAME = 'My OpenClaw Workspace.code-workspace'; -async function openOpenClawFolder(): Promise { +async function openOpenClawFolder(context?: vscode.ExtensionContext): Promise { // Ensure ~/.occ exists — OCcode's internal state directory. const occPath = path.join(os.homedir(), '.occ'); if (!fs.existsSync(occPath)) { @@ -177,9 +260,15 @@ async function openOpenClawFolder(): Promise { ); } - // If we're already inside this workspace, nothing more to do. + // If we're already inside this workspace, nothing more to do — except ensure + // the workspace folder reflects the current host (not a stale Docker folder). const workspaceFileUri = vscode.Uri.file(workspaceFilePath); if (vscode.workspace.workspaceFile?.fsPath === workspaceFileUri.fsPath) { + const binding = context?.workspaceState.get<{ type: string }>(WINDOW_HOST_KEY); + if (binding?.type !== 'docker') { + // Not bound to Docker — ensure ~/.openclaw is shown, not occ-state-dir. + setActiveOpenClawWorkspaceFolder(openclawPath); + } return; } @@ -190,7 +279,7 @@ async function openOpenClawFolder(): Promise { // ── Inference balance status bar ────────────────────────────────────────────── const BACKEND_BALANCE_KEY = 'occBackendBalanceV1'; // cached backend balance — persists across restarts -const OCC_JWT_KEY = 'occJwtV1'; // JWT stored directly in extension storage — no renderer IPC needed +const OCC_JWT_KEY = 'occJwtV1'; // JWT stored in SecretStorage (OS keychain) — encrypted at rest function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => void { // Restore cached backend balance so status bar shows the correct value immediately on startup @@ -284,13 +373,13 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => // FALLBACK: if extension globalState has no JWT (e.g. user signed in before this session // synced, or the extension was reloaded), read from occLegacyJwt in VS Code settings and // backfill extension globalState so future reads work without IPC. - let jwt = context.globalState.get(OCC_JWT_KEY, ''); + let jwt = (await context.secrets.get(OCC_JWT_KEY)) ?? ''; if (!jwt) { try { const legacyJwt = await vscode.commands.executeCommand('occ.auth.getLegacyJwt'); if (legacyJwt) { jwt = legacyJwt; - await context.globalState.update(OCC_JWT_KEY, jwt); + await context.secrets.store(OCC_JWT_KEY, jwt); } } catch { /* renderer not ready yet — will retry on next poll */ } } @@ -316,7 +405,7 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => const moltpilotKey = data.api_keys?.moltpilotKey ?? ''; // Guard: only sync back if the JWT wasn't cleared while the fetch was in-flight. // Without this check, an in-flight poll completing after sign-out would re-log the user in. - const currentJwt = context.globalState.get(OCC_JWT_KEY, ''); + const currentJwt = (await context.secrets.get(OCC_JWT_KEY)) ?? ''; if (currentJwt !== jwt) { return; } // Sync JWT + keys to renderer settings so ocFreeModel works vscode.commands.executeCommand('occ.auth.setLegacyJwt', jwt); @@ -347,7 +436,7 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => } } else if (r.status === 401) { // JWT expired or invalid — clear it, clear moltpilot key, and hide bar - void context.globalState.update(OCC_JWT_KEY, ''); + void context.secrets.delete(OCC_JWT_KEY); vscode.commands.executeCommand('occ.auth.setMoltpilotKey', ''); if (backendPollTimer) { clearInterval(backendPollTimer); backendPollTimer = undefined; } stopCountdown(); @@ -400,7 +489,7 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => // Called from the renderer (sidebarActions.ts occ.auth.setLegacyJwt) to sync the JWT // into extension-host storage so fetchAndUpdateBackendBalance can read it without IPC. vscode.commands.registerCommand('openclaw.jwt.set', async (token: string) => { - await context.globalState.update(OCC_JWT_KEY, token ?? ''); + if (token) { await context.secrets.store(OCC_JWT_KEY, token); } else { await context.secrets.delete(OCC_JWT_KEY); } // Stop polling immediately on sign-out so no more in-flight fetches can re-set the JWT if (!token && backendPollTimer) { clearInterval(backendPollTimer); @@ -420,13 +509,13 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => log(''); // 1. JWT - let jwt = context.globalState.get(OCC_JWT_KEY, ''); - log(`[1] JWT in extension globalState (occJwtV1): ${jwt ? 'OK present (' + jwt.substring(0, 20) + '...)' : 'MISSING'}`); + let jwt = (await context.secrets.get(OCC_JWT_KEY)) ?? ''; + log(`[1] JWT in SecretStorage (occJwtV1): ${jwt ? 'OK present [redacted]' : 'MISSING'}`); // Check what the renderer has for occLegacyJwt — this is what voidSettingsService reads try { const legacyJwt = await vscode.commands.executeCommand('occ.auth.getLegacyJwt'); - log(` occLegacyJwt in renderer settings: ${legacyJwt ? 'OK present (' + legacyJwt.substring(0, 20) + '...)' : 'MISSING <-- this causes MoltPilot to use shared key!'}`); - if (!jwt && legacyJwt) { jwt = legacyJwt; await context.globalState.update(OCC_JWT_KEY, jwt); } + log(` occLegacyJwt in renderer settings: ${legacyJwt ? 'OK present [redacted]' : 'MISSING <-- this causes MoltPilot to use shared key!'}`); + if (!jwt && legacyJwt) { jwt = legacyJwt; await context.secrets.store(OCC_JWT_KEY, jwt); } } catch { log(' occLegacyJwt check: renderer not ready'); } if (!jwt) { lines.push('\nNot signed in — cannot proceed.'); } @@ -446,8 +535,8 @@ function initBalanceBar(context: vscode.ExtensionContext): (amount?: number) => log(` Status: 200 OK`); log(` Email: ${d.email ?? '(not returned)'}`); log(` Balance: $${balanceBefore.toFixed(6)}`); - log(` MoltpilotKey: ${moltpilotKey ? 'OK ' + moltpilotKey.substring(0, 12) + '...' : 'MISSING'}`); - log(` OccKey: ${occKey ? 'OK ' + occKey.substring(0, 12) + '...' : 'MISSING'}`); + log(` MoltpilotKey: ${moltpilotKey ? 'OK [redacted]' : 'MISSING'}`); + log(` OccKey: ${occKey ? 'OK [redacted]' : 'MISSING'}`); } else { log(` HTTP ${r.status} -- JWT may be expired`); } @@ -510,7 +599,37 @@ 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 { + // ── One-time migration: move JWT from globalState → SecretStorage ──────────── + const legacyJwt = context.globalState.get(OCC_JWT_KEY, ''); + if (legacyJwt) { + await context.secrets.store(OCC_JWT_KEY, legacyJwt); + await context.globalState.update(OCC_JWT_KEY, undefined); + } + + // ── MultiHost: HostRegistry + HostManager ─────────────────────────────────── + const hostRegistry = new HostRegistry(); + await hostRegistry.init(); + const hostManager = new HostManager(hostRegistry); + context.subscriptions.push(hostRegistry, hostManager); + + // (OPENCLAW HOSTS tree view and status bar removed — window-level binding used instead) + + // 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); @@ -518,7 +637,7 @@ export async function activate(context: vscode.ExtensionContext): Promise await hideActivityBarItems(context); // Open ~/.openclaw as "My OpenClaw Workspace" (may reload the window once). - await openOpenClawFolder(); + await openOpenClawFolder(context); // Close the Explorer sidebar on startup — the user opens it explicitly when needed. // Use a short delay so the workbench has finished restoring its layout first. @@ -536,8 +655,8 @@ export async function activate(context: vscode.ExtensionContext): Promise const params = new URLSearchParams(uri.query); const token = params.get('token'); if (token) { - // Store JWT in extension-host storage immediately (no renderer IPC needed). - void context.globalState.update(OCC_JWT_KEY, token).then(() => { + // Store JWT in SecretStorage (OS keychain) — encrypted at rest. + void context.secrets.store(OCC_JWT_KEY, token).then(() => { // Also sync to renderer settings service (for chat / other renderer consumers). vscode.commands.executeCommand('occ.auth.setLegacyJwt', token); }); @@ -547,20 +666,62 @@ export async function activate(context: vscode.ExtensionContext): Promise }), ); + registerWindowHostCommands(context); + context.subscriptions.push( vscode.commands.registerCommand('openclaw.home', () => { - HomePanel.createOrShow(context.extensionUri); + routeHome(context.extensionUri, context); + }), + // Always shows the host picker regardless of what is installed — used after disconnect. + vscode.commands.registerCommand('openclaw.home.picker', () => { + routeHome(context.extensionUri, context, true); }), vscode.commands.registerCommand('openclaw.configure', async () => { - const reachable = await isWebServerReachable(); + const windowHostBinding = context.workspaceState.get(WINDOW_HOST_KEY); + + // ── Docker path ─────────────────────────────────────────────────────── + if (windowHostBinding?.type === 'docker') { + const container = windowHostBinding.hostId.replace(/^docker:/, '') || 'occ-openclaw'; + const hostPort = windowHostBinding.port; // e.g. 18790 + const containerPort = 18789; + + // 1. Start the gateway inside the container (detached — safe if already running) + cp.spawn('docker', ['exec', '-d', container, 'openclaw', 'gateway', 'run'], { + windowsHide: true, + detached: true, + }).unref(); + + // 2. Give it a moment to start, then get the tokenized dashboard URL + await new Promise(r => setTimeout(r, 2000)); + + const dashResult = cp.spawnSync( + 'docker', + ['exec', container, 'openclaw', 'dashboard', '--no-open'], + { timeout: 10000, windowsHide: true, encoding: 'utf-8' }, + ); + let rawUrl = (dashResult.stdout as string ?? '').trim(); + + if (rawUrl) { + // Rewrite the internal container port to the host-mapped port + const url = rawUrl + .replace(new RegExp(`localhost:${containerPort}`, 'g'), `localhost:${hostPort}`) + .replace(new RegExp(`127\\.0\\.0\\.1:${containerPort}`, 'g'), `127.0.0.1:${hostPort}`); + await vscode.env.openExternal(vscode.Uri.parse(url)); + } else { + // dashboard command failed — open plain URL as fallback + await vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${hostPort}/`)); + } + return; + } + + // ── Local / SSH path ────────────────────────────────────────────────── + const effectivePort = windowHostBinding ? windowHostBinding.port : getConfiguredGatewayPort(); + const reachable = await isWebServerReachable(effectivePort); if (reachable) { - const dashInfo = getDashboardUrl(); - const url = dashInfo?.url ?? `http://localhost:${getConfiguredGatewayPort()}/`; + const url = getDashboardUrl()?.url ?? `http://localhost:${effectivePort}/`; await vscode.env.openExternal(vscode.Uri.parse(url)); } else { - // Web server not running — ask the AI to start it - const port = getConfiguredGatewayPort(); - const configUrl = `http://localhost:${port}/`; + const configUrl = `http://localhost:${effectivePort}/`; const message = `The OpenClaw web configuration server is not running at ${configUrl}.\n\n` + `Please start it now by running the OpenClaw gateway in the terminal:\n` + @@ -590,12 +751,8 @@ export async function activate(context: vscode.ExtensionContext): Promise spendBalance(); }), vscode.commands.registerCommand('openclaw.install', () => { - 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(); @@ -635,7 +792,7 @@ export async function activate(context: vscode.ExtensionContext): Promise if (!password) return { result: 'User cancelled the password prompt.', exitCode: 1 }; return new Promise(resolve => { - const child = require('child_process').spawn('sudo', ['-S', 'bash', '-c', command], { + const child = cp.spawn('sudo', ['-S', 'bash', '-c', command], { stdio: ['pipe', 'pipe', 'pipe'], }); child.stdin?.write(password + '\n'); @@ -685,7 +842,12 @@ export async function activate(context: vscode.ExtensionContext): Promise hasAiModel: boolean; hasChannels: boolean; channelNames: string[]; + hostType: 'local' | 'docker' | 'ssh' | null; + containerName: string | null; + gatewayPort: number; }> => { + // Resolve window-level host binding first + const windowHost = context.workspaceState.get(WINDOW_HOST_KEY) ?? null; const homedir = os.homedir(); const configPath = path.join(homedir, '.openclaw', 'openclaw.json'); const installed = fs.existsSync(configPath); @@ -695,8 +857,11 @@ export async function activate(context: vscode.ExtensionContext): Promise try { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch {} } - // Determine gateway port + // Determine gateway port — for Docker/SSH hosts use the window binding's port directly const port = (() => { + if (windowHost?.type === 'docker' || windowHost?.type === 'ssh') { + return windowHost.port; + } const p = config['port'] ?? config['gateway_port'] ?? config['gatewayPort']; if (p === undefined) return 18789; const n = Number(p); @@ -758,7 +923,12 @@ export async function activate(context: vscode.ExtensionContext): Promise } catch {} const hasAgents = agentNames.length > 0; - return { installed, gatewayRunning, hasAgents, agentNames, hasAiModel, hasChannels, channelNames }; + const hostType = windowHost?.type ?? 'local'; + const containerName = (windowHost?.type === 'docker' || windowHost?.type === 'ssh') + ? (windowHost.hostId ?? null) + : null; + + return { installed, gatewayRunning, hasAgents, agentNames, hasAiModel, hasChannels, channelNames, hostType, containerName, gatewayPort: port }; }), ); @@ -797,8 +967,11 @@ export async function activate(context: vscode.ExtensionContext): Promise // Auto-show OCC Home on startup (after activation settles). setTimeout(() => { - HomePanel.createOrShow(context.extensionUri); + routeHome(context.extensionUri, context); }, 500); + + // Return OpenClawCoreAPI so adapter extensions can register their adapters. + return hostManager; } export function deactivate() { 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/localDefault.ts b/apps/editor/extensions/openclaw/src/hosts/localDefault.ts new file mode 100644 index 00000000..9d4d5a54 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/localDefault.ts @@ -0,0 +1,307 @@ +/** + * 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}`); } + } + + gatewayHostPort(): number | undefined { + return undefined; // local host: no port remapping + } + + localStateDir(): string { + return path.join(os.homedir(), '.openclaw'); + } + + buildExecEnv(): Record { + return buildLocalEnv(); + } +} diff --git a/apps/editor/extensions/openclaw/src/hosts/manager.ts b/apps/editor/extensions/openclaw/src/hosts/manager.ts new file mode 100644 index 00000000..868755d9 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/manager.ts @@ -0,0 +1,186 @@ +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 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) { + 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()); + } +} 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..bf951184 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -0,0 +1,318 @@ +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; + /** Run via OS shell (required for .cmd/.bat shims on Windows) */ + shell?: 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 }; + /** Host-side directory mounted as /home/node/.openclaw inside the container (e.g. ~/Desktop/occ-state-dir). */ + localMountPath?: string; +} + +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; + + // ── Port override (for tunnelled connections, e.g. Docker) ── + /** Host-side port to poll for gateway health. Overrides the port read from openclaw.json. */ + gatewayHostPort?(): number | undefined; + + // ── Local filesystem paths ── + /** + * Host-side path to the OpenClaw state directory. + * Local: ~/.openclaw Docker: ~/Desktop/occ-state-dir (or whatever is mounted) + */ + localStateDir?(): string; + + // ── Environment ── + buildExecEnv(): Record; +} + +// ───────────────────────────────────────────── +// 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; + addHost(entry: Omit): Promise; + refreshHost(id: string): Promise; +} diff --git a/apps/editor/extensions/openclaw/src/panels/config.ts b/apps/editor/extensions/openclaw/src/panels/config.ts index ce9eb944..53a90c78 100644 --- a/apps/editor/extensions/openclaw/src/panels/config.ts +++ b/apps/editor/extensions/openclaw/src/panels/config.ts @@ -451,7 +451,7 @@ export class ConfigPanel { - + + + + +

OCC Home

+

Choose a host to open

+ +
+ + + +
+ + + +`; + } + + private _getHostTypeSelectionHtml(iconUri: string): string { + return ` + + + + + + + + +

Welcome to OpenClaw

+

Choose where OpenClaw runs. You can always switch later.

+
+ + + + + + + +
+ + +`; + } + private _getLoadingHtml(iconUri: string): string { return ` @@ -1187,183 +1125,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 nodeVerRaw = await new Promise(resolve => { - cp.exec('node --version', { env, timeout: 5000 }, (err, stdout) => - resolve((stdout || '').toString().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) { - // 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); - - 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'); - } - // 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); - } - resolve(); - }); - child.on('error', err => { - wizardPost(`Error: ${err.message}\n`, true, false); - resolve(); - }); - }); - } - // ── Uninstall ────────────────────────────────────────────────────────────── private _schedulePostUninstallClose(): void { if (this._uninstallCloseWatcher !== undefined) return; @@ -1575,11 +1336,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) { @@ -1638,10 +1395,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; @@ -1662,6 +1418,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; @@ -1698,47 +1455,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; @@ -1753,19 +1472,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; @@ -1777,14 +1494,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 { @@ -1793,660 +1510,16 @@ 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; - } - .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; - } - .modal-confirm:hover { background: #b91c1c; } - - - - -
${userAreaHtml}
- - - -
Set up OpenClaw
-
Follow the steps below to get started
- - -
-
-
${isInstalled ? '✓' : '1'}
-
Install
OpenClaw
-
-
-
2
-
Configure
AI Model
-
-
-
3
-
Ready
-
-
- - -
- -
- - - - - - - - - - - - - - -
-
-
Working
- - - -
- - - - - - -`; - } - - 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) { - 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} -
- - -
- -
- - -
-
- - - - - - - - - - - - -`; - } - - private _getHtml( - isInstalled: boolean, - dirExists: boolean, - cliCheck: { ok: boolean; output?: string; error?: string; command: string }, - iconUri: string, - occJwt: string = '', - occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null, - emojiBaseUri: string = '', - aiModelName = '' - ): string { - // Render user area statically (avoids JS innerHTML escaping / runtime errors) - let userAreaHtml: string; - if (!occUser) { - userAreaHtml = ``; - } else { - const initial = (occUser.email || '?')[0].toUpperCase(); - const safeEmail = occUser.email.replace(/"/g, '"').replace(/` - : initial; - const popoverAvatar = occUser.picture - ? `` - : initial; - const balance = '$' + parseFloat(String(occUser.balance_usd || 0)).toFixed(2); - const keysHtml = (occUser.api_keys && occUser.api_keys.occKey) ? ` -
-
API Key
-
- OpenClaw - ${occUser.api_keys.occKey.slice(0,8)}···${occUser.api_keys.occKey.slice(-4)} - -
-
-
` : ''; - userAreaHtml = ` -
- -
-
-
${popoverAvatar}
-
${safeEmail}
-
${balance} credits
-
- -
- ${keysHtml} - -
-
`; - } - - const statusIcon = isInstalled ? '✅' : '⚠️'; - const statusText = isInstalled ? 'OpenClaw detected' : 'OpenClaw not found'; - const statusClass = isInstalled ? 'detected' : 'not-found'; - 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'; - const cliText = cliCheck.ok ? (cliCheck.output || 'ok') : (cliCheck.output || cliCheck.error || 'not found'); - const cliClass = cliCheck.ok ? 'ok' : 'warn'; - const cliHint = cliCheck.ok ? '' : ` (tried: ${cliCheck.command})`; - - // ── Lucide icons (inline SVG, no CDN needed) ────────────────────────────── - const ic = (d: string, size = 13, opacity = '0.55') => - `${d}`; - const icFolder = ic(''); - const icTerminal = ic(''); - const icServer = ic(''); - const icBot = ic(''); - const icChip = ic('', 13); - // Button icons — slightly larger, full opacity - const icSettings = ic('', 15, '0.9'); - const icDownload = ic('', 15, '0.9'); - const icRefreshCw = ic('', 14, '0.85'); - const icTerminalBtn = ic('', 15, '0.9'); - const icBtnPrimary = isInstalled ? icSettings : icDownload; - - return ` - - - - - - - - + +
- - ${isInstalled ? `
- - -
` : ''} - - - ${isInstalled ? ` - - -
${userAreaHtml}
+ ${userAreaHtml}
- -
-
- -

Under Development

-

This app isn't ready yet — but you can help build it.
Copy the message below and post it in the MBA community.

-
- I want to contribute to [App] - -
-

-
- - -
+ +
+ +
+ +
- -
-
-

Uninstall OpenClaw?

-

This will remove the CLI, stop the gateway, and clean up all config files. This cannot be undone.

-
- - -
+ + - - -
-
-

🔐 Administrator Password Required

-

OpenClaw needs elevated permissions to install globally.
Your password is used once and never stored.

- -
- - -
+
+ +
- - - - ${isInstalled ? ` - -

Welcome to OpenClaw Code

-
-
- ${icFolder} - Config (~/.openclaw/openclaw.json) - ${dirText} -
-
- ${icTerminal} - CLI (openclaw --version) - ${cliText}${cliHint} -
-
- ${icServer} - Gateway - - - Checking… - - + +