From 93953d5a01ecc490314300ad7250373ab34976c1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Apr 2026 02:16:54 +0000 Subject: [PATCH 1/6] fix: register missing openclaw.host.setup.{local,docker,ssh} commands These commands were being called by routeHome() and the HomePanel host picker but were never registered, causing silent failures. Each command now: 1. Sets the WindowHostBinding for the selected host type 2. Calls HomePanel.createOrShow() to display the appropriate view This enables the docker/local/ssh buttons to work correctly. Co-Authored-By: Claude Haiku 4.5 --- .../extensions/openclaw/src/extension.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index d506cb47..cb876a07 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -717,6 +717,31 @@ export async function activate(context: vscode.ExtensionContext): Promise { routeHome(context.extensionUri, context, true); }), + // Host-specific setup commands — invoked by routeHome() and the host picker in HomePanel. + // Each sets the window binding for this host type then opens the home panel, + // which renders the appropriate dashboard or setup wizard based on the binding. + vscode.commands.registerCommand('openclaw.host.setup.local', async () => { + const existing = context.workspaceState.get(WINDOW_HOST_KEY); + if (!existing || existing.type !== 'local') { + await context.workspaceState.update(WINDOW_HOST_KEY, { + type: 'local', hostId: 'local:main', port: getConfiguredGatewayPort(), label: 'Local', + } satisfies WindowHostBinding); + } + HomePanel.createOrShow(context.extensionUri); + }), + vscode.commands.registerCommand('openclaw.host.setup.docker', async () => { + const existing = context.workspaceState.get(WINDOW_HOST_KEY); + if (!existing || existing.type !== 'docker') { + await context.workspaceState.update(WINDOW_HOST_KEY, { + type: 'docker', hostId: 'docker:occ-openclaw', port: DEFAULT_GATEWAY_PORT, label: 'Docker', + } satisfies WindowHostBinding); + } + HomePanel.createOrShow(context.extensionUri); + }), + vscode.commands.registerCommand('openclaw.host.setup.ssh', () => { + // SSH host details are entered via the home panel UI — open it and let the panel drive. + HomePanel.createOrShow(context.extensionUri); + }), vscode.commands.registerCommand('openclaw.configure', async () => { const windowHostBinding = context.workspaceState.get(WINDOW_HOST_KEY); From 60e0d4badb1f6cb775ab1c3067f339536e91f5e0 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Apr 2026 03:36:32 +0000 Subject: [PATCH 2/6] fix(ticket-051): restore host picker flow and kill workspace-folder reload loop - Revert duplicate openclaw.host.setup.{local,docker,ssh} command registrations in the main extension (93953d5). The adapter extensions openclaw-{local,docker,ssh} already register them and route to the dedicated setup panels; the duplicates were clobbering that and making the host-picker cards a no-op. - Collapse HomePanel picker rendering to a single 3-card view (Local / Docker / SSH). Delete the 2-card _getHostsOverviewHtml and its _handleCheckHostsStatus helper. Cancel from Docker setup now returns to the same 3-card picker the user came from, not a different 2-card screen. - Fuse remove+add into one atomic updateWorkspaceFolders call in _applyActiveOpenClawWorkspaceFolder. VS Code rejects concurrent update ops, so the old two-step version often dropped the add and left folders=[] in the .code-workspace file; openOpenClawFolder then rewrote it to [~/.openclaw] on every activate, and the resulting external file change triggered a reload loop that flipped the user between the home picker and the status page. - Stop rewriting My OpenClaw Workspace.code-workspace once it exists. The status controllers legitimately swap folders per-host; rewriting back to a hard-coded ~/.openclaw was half of the loop above. - Demote the 5 GB Docker data-dir check from a hard error to a soft warning. _runValidationChecks now returns { errors, warnings }; the data-dir field shows an amber advisory instead of a red blocker and the Next button stays enabled. Copy is plain English, no jargon. Co-Authored-By: Claude Haiku 4.5 --- .../prd.md | 117 +++++++++ .../openclaw-docker/src/setup-panel.ts | 67 +++-- .../extensions/openclaw/src/extension.ts | 43 +-- .../extensions/openclaw/src/panels/home.ts | 244 +++--------------- .../openclaw/src/panels/statusController.ts | 34 ++- 5 files changed, 222 insertions(+), 283 deletions(-) create mode 100644 .tickets/ticket-051-restore-host-setup-commands/prd.md diff --git a/.tickets/ticket-051-restore-host-setup-commands/prd.md b/.tickets/ticket-051-restore-host-setup-commands/prd.md new file mode 100644 index 00000000..895e7a67 --- /dev/null +++ b/.tickets/ticket-051-restore-host-setup-commands/prd.md @@ -0,0 +1,117 @@ +# Ticket 051 — Restore Host Setup Commands (Fix Gateway Button Regression) + +## 2.1 Problem Statement + +After v3.6.1, commit `93953d5` registered `openclaw.host.setup.local`, `openclaw.host.setup.docker`, and `openclaw.host.setup.ssh` inside the main `openclaw` extension (`apps/editor/extensions/openclaw/src/extension.ts:723-744`). Those same command IDs are already registered by three adapter extensions that ship pre-bundled in the fork: + +| Command | Registered by | Target | +|---|---|---| +| `openclaw.host.setup.local` | `openclaw-local/src/extension.ts:27` | `LocalSetupPanel` | +| `openclaw.host.setup.docker` | `openclaw-docker/src/extension.ts:90` | `DockerSetupPanel` | +| `openclaw.host.setup.ssh` | `openclaw-ssh/src/extension.ts:20` | `SSHSetupPanel` | + +Because the main extension activates first (the adapters declare `extensionDependencies: ["openclaw.home"]`), its generic handler wins and the subsequent adapter registrations throw `A command '…' already exists`. Two user-visible regressions follow: + +1. **Gateway button loop.** Clicking **Docker** (or **Local**) on the OCC Home host picker disposes the picker and runs `openclaw.host.setup.docker`. The winning (main-extension) handler just calls `HomePanel.createOrShow()`. `_update()` detects that Docker is running and re-renders the picker — the button visually "does nothing". +2. **Dedicated setup wizards are unreachable.** `LocalSetupPanel` / `DockerSetupPanel` / `SSHSetupPanel` are never opened because their adapter-registered commands are clobbered. The adapter `activate()` throws before later subscriptions run. + +Tag `v3.6.1` is the last known-good commit: only adapters registered these commands and they routed to the correct setup panels. + +## 2.2 Proposed Solution + +Remove the three `openclaw.host.setup.{local,docker,ssh}` registrations from `apps/editor/extensions/openclaw/src/extension.ts` (the block added by commit `93953d5`). The adapter extensions are authoritative. + +No other behavior change is needed. `routeHome()`, the home-panel host picker, and `openclaw.install` already `executeCommand('openclaw.host.setup.*')`, so once the adapter handlers are re-exposed they take over automatically. + +### Architecture (after fix) + +``` +HomePanel host picker (Docker/Local cards) + └─ vscode.commands.executeCommand('openclaw.host.setup.docker') + └─ openclaw-docker.activate() ← registers this command + └─ DockerSetupPanel.createOrShow() + +routeHome() binding=docker + └─ same path → DockerSetupPanel +``` + +## 2.3 Acceptance Criteria + +```gherkin +Feature: Host picker buttons route to the correct setup wizard + +Scenario: Clicking Docker on the host picker opens the Docker setup panel + Given the OCC Home host picker is visible + And the Docker container "occ-openclaw" is running + When the user clicks the "Docker" card + Then the Home panel disposes + And the Docker setup panel ("OpenClaw Docker Setup") opens + And the host picker does NOT re-appear + +Scenario: Clicking Local on the host picker opens the Local setup panel + Given the OCC Home host picker is visible + When the user clicks the "Local" card + Then the Home panel disposes + And the Local setup panel opens + +Scenario: routeHome with an existing binding opens the correct panel + Given the window has WindowHostBinding type="docker" + When the extension activates and calls routeHome + Then the Docker setup panel opens without showing the host picker + +Scenario: No duplicate command registration errors + Given all openclaw extensions have activated + When the extension host log is inspected + Then there are no "A command 'openclaw.host.setup.*' already exists" errors + And openclaw-local / openclaw-docker / openclaw-ssh report successful adapter registration +``` + +## 2.4 Technical Considerations + +- **No new code.** This is a surgical revert of the three command-registration blocks in the main extension. The adapter code already provides the correct behavior. +- **Activation order.** Main extension activates first; adapters depend on it and activate after. With the duplicates removed, adapter registrations succeed and their `activate()` completes (HostAdapter + setup command both registered). +- **No tests changed.** Existing Playwright onboarding-auth / docker-to-ide-flow tests cover this path at the UI level; verifying them passes is sufficient. +- **Commit `93953d5` message claims the commands were "never registered" — this was incorrect.** The adapters were (and still are) responsible for them. The duplicate made things worse, not better. +- **No effect on other commands.** `openclaw.install` at extension.ts:805-808 already delegates to `openclaw.host.setup.local` via `executeCommand`, which is unchanged. + +## 2.5 Dependencies + +- None. + +--- + +## Tasks + +- [ ] Task 1: Remove the duplicate host-setup command registrations + - **Problem**: `apps/editor/extensions/openclaw/src/extension.ts` currently registers `openclaw.host.setup.{local,docker,ssh}`, clobbering the adapter extensions. + - **Test**: `grep -n "openclaw.host.setup" apps/editor/extensions/openclaw/src/extension.ts` returns only the three `executeCommand` callsites (in `routeHome` and `openclaw.install`), no `registerCommand` lines. + - **Depends on**: None + - **Subtasks**: + - [ ] Subtask 1.1: Delete lines 720-744 (the 25-line block introduced by commit `93953d5`) from extension.ts + - **Objective**: Restore the pre-`93953d5` state of this file. + - **Test**: `git diff v3.6.1 -- apps/editor/extensions/openclaw/src/extension.ts` is empty. + - **Depends on**: None + +- [ ] Task 2: Recompile the extension and verify + - **Problem**: TypeScript output under `out/` must be regenerated so the running editor picks up the change. + - **Test**: `docker exec occ-editor-dev bash -c "cd /workspace/apps/editor/extensions/openclaw && npx tsc -p ./"` exits 0. + - **Depends on**: Task 1 + - **Subtasks**: + - [ ] Subtask 2.1: Recompile the `openclaw` extension inside the dev container + - **Objective**: Emit fresh `out/extension.js`. + - **Test**: `out/extension.js` mtime is newer than `src/extension.ts`. + - **Depends on**: Task 1 + - [ ] Subtask 2.2: Reload the extension host and click Docker on the host picker + - **Objective**: Confirm `DockerSetupPanel` opens instead of a blank re-render. + - **Test**: Visual / Playwright verification — the DockerSetupPanel tab replaces the picker. + - **Depends on**: Subtask 2.1 + +- [ ] Task 3: Run the relevant Playwright E2E suite + - **Problem**: Catch any surface regressions (home panel onboarding, docker-to-ide flow). + - **Test**: `npm run test:e2e -- --workers=1 tests/e2e/docker-to-ide-flow.spec.ts` passes. + - **Depends on**: Task 2 + - **Subtasks**: + - [ ] Subtask 3.1: Run docker-to-ide-flow spec + - **Objective**: Confirm no regressions in the Docker setup path. + - **Test**: All tests green. + - **Depends on**: Task 2 diff --git a/apps/editor/extensions/openclaw-docker/src/setup-panel.ts b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts index 4c72c3d2..8de23e6f 100644 --- a/apps/editor/extensions/openclaw-docker/src/setup-panel.ts +++ b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts @@ -299,12 +299,18 @@ export class DockerSetupPanel { } /** Run all validation checks for form fields */ - private async _runValidationChecks(config: Partial): Promise<{ [key: string]: string | null }> { - const errors: { [key: string]: string | null } = { - image: null, - port: null, - bindHost: null, - dataDir: null, + private async _runValidationChecks(config: Partial): Promise<{ + errors: { image: string | null; port: string | null; bindHost: string | null; dataDir: string | null }; + warnings: { dataDir: string | null }; + }> { + const errors = { + image: null as string | null, + port: null as string | null, + bindHost: null as string | null, + dataDir: null as string | null, + }; + const warnings = { + dataDir: null as string | null, }; // Validate image — blank is fine (defaults to DEFAULT_CONFIG.image) @@ -329,7 +335,8 @@ export class DockerSetupPanel { errors.bindHost = 'Bind host must be 127.0.0.1 or 0.0.0.0'; } - // Validate dataDir + // Validate dataDir — "required" and path-access remain blocking errors. + // Disk space is a soft recommendation surfaced as a warning, never blocking. if (!config.dataDir || config.dataDir.trim() === '') { errors.dataDir = 'Data directory is required'; } else { @@ -337,15 +344,11 @@ export class DockerSetupPanel { const accessError = await this._checkPathAccess(resolvedPath); if (accessError) { errors.dataDir = accessError; - } else { - const spaceError = await this._checkDiskSpace(resolvedPath); - if (spaceError) { - errors.dataDir = spaceError; - } } + warnings.dataDir = await this._checkDiskSpace(resolvedPath); } - return errors; + return { errors, warnings }; } /** Check if a port is in use */ @@ -387,7 +390,7 @@ export class DockerSetupPanel { }); } - /** Check available disk space (minimum 5GB) */ + /** Soft disk-space recommendation (5 GB). Returns advisory text, never blocks. */ private async _checkDiskSpace(fsPath: string): Promise { return new Promise((resolve) => { try { @@ -403,13 +406,14 @@ export class DockerSetupPanel { if (lines.length > 1) { const parts = lines[1].split(/\s+/); const available = parseInt(parts[3], 10); - const requiredBytes = 5 * 1024 * 1024 * 1024; // 5GB + const recommendedBytes = 5 * 1024 * 1024 * 1024; // 5 GB recommended - if (available < requiredBytes) { + if (available < recommendedBytes) { const availableGB = (available / (1024 * 1024 * 1024)).toFixed(1); - resolve(`Insufficient disk space: ${availableGB}GB available, 5GB required`); - } else { - resolve(null); + resolve( + `Only ${availableGB} GB free here — we recommend at least 5 GB. You can continue, but setup may run out of space.`, + ); + return; } } } @@ -576,9 +580,9 @@ ${logs.substring(0, 3000)} } private async _handleValidateFields(msg: { image: string; port: string; dataDir: string; bindHost: string }): Promise { - const errors = await this._runValidationChecks(msg); + const { errors, warnings } = await this._runValidationChecks(msg); try { - this._panel.webview.postMessage({ type: 'dockerValidationErrors', errors }); + this._panel.webview.postMessage({ type: 'dockerValidationErrors', errors, warnings }); } catch { /* ignore */ } } @@ -1115,6 +1119,8 @@ ${logs.substring(0, 3000)} .field-error { border-color: #f87171 !important; } .error-msg { color: #f87171; font-size: 11px; margin-top: 4px; display: none; } .error-msg.show { display: block; } + .warn-msg { color: #fbbf24; font-size: 11px; margin-top: 4px; display: none; line-height: 1.4; } + .warn-msg.show { display: block; } .validation-badge { display: inline-block; margin-left: 6px; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px; @@ -1211,6 +1217,7 @@ ${logs.substring(0, 3000)}
+
@@ -1273,7 +1280,7 @@ ${logs.substring(0, 3000)} } } - function updateValidationUI(errors) { + function updateValidationUI(errors, warnings) { validationState = errors; // Update error messages and field styling @@ -1292,7 +1299,19 @@ ${logs.substring(0, 3000)} } }); - // Update Next button state + // Render soft warnings (non-blocking advisories — amber, Next stays enabled) + const warnEl = document.getElementById('dataDir-warn'); + if (warnEl) { + const w = warnings && warnings.dataDir; + if (w) { + warnEl.textContent = w; + warnEl.classList.add('show'); + } else { + warnEl.classList.remove('show'); + } + } + + // Update Next button state — warnings never gate submission const allValid = Object.values(errors).every(e => e === null); document.getElementById('btn-next').disabled = !allValid; } @@ -1324,7 +1343,7 @@ ${logs.substring(0, 3000)} } else if (msg.type === 'dockerEnvironmentCheck') { updateDockerWarning(msg.result); } else if (msg.type === 'dockerValidationErrors') { - updateValidationUI(msg.errors); + updateValidationUI(msg.errors, msg.warnings); } else if (msg.type === 'dockerConfigError') { const err = document.getElementById('error'); err.textContent = msg.message; diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index cb876a07..6d89a8f5 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -272,19 +272,13 @@ async function openOpenClawFolder(context?: vscode.ExtensionContext): Promise { routeHome(context.extensionUri, context, true); }), - // Host-specific setup commands — invoked by routeHome() and the host picker in HomePanel. - // Each sets the window binding for this host type then opens the home panel, - // which renders the appropriate dashboard or setup wizard based on the binding. - vscode.commands.registerCommand('openclaw.host.setup.local', async () => { - const existing = context.workspaceState.get(WINDOW_HOST_KEY); - if (!existing || existing.type !== 'local') { - await context.workspaceState.update(WINDOW_HOST_KEY, { - type: 'local', hostId: 'local:main', port: getConfiguredGatewayPort(), label: 'Local', - } satisfies WindowHostBinding); - } - HomePanel.createOrShow(context.extensionUri); - }), - vscode.commands.registerCommand('openclaw.host.setup.docker', async () => { - const existing = context.workspaceState.get(WINDOW_HOST_KEY); - if (!existing || existing.type !== 'docker') { - await context.workspaceState.update(WINDOW_HOST_KEY, { - type: 'docker', hostId: 'docker:occ-openclaw', port: DEFAULT_GATEWAY_PORT, label: 'Docker', - } satisfies WindowHostBinding); - } - HomePanel.createOrShow(context.extensionUri); - }), - vscode.commands.registerCommand('openclaw.host.setup.ssh', () => { - // SSH host details are entered via the home panel UI — open it and let the panel drive. - HomePanel.createOrShow(context.extensionUri); - }), vscode.commands.registerCommand('openclaw.configure', async () => { const windowHostBinding = context.workspaceState.get(WINDOW_HOST_KEY); diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 1cf3a9c3..d75d26d2 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -278,8 +278,6 @@ export class HomePanel { } else if (t === 'ssh') { void vscode.commands.executeCommand('openclaw.host.setup.ssh'); } - } else if (msg.command === 'checkHostsStatus') { - void this._handleCheckHostsStatus(); } }, null, this._disposables); } @@ -401,58 +399,43 @@ export class HomePanel { ? (await this._checkGatewayStatusRaw()) === 'running' : false; - // Show hosts overview when Docker container is up (regardless of local config), - // or when both modes are active, or when forced (e.g. after disconnect). - if (isDockerRunning || this._forcePicker) { + // Host picker — single 3-card view (Local / Docker / SSH). + // Shown when Docker is up, when forced (e.g. after disconnect/cancel), or + // when there's no evidence of a running gateway anywhere. + if (isDockerRunning || this._forcePicker || (!isConfigured && !isGatewayReachable)) { this._stopPolling(); - let localPort = 18789; - try { - const raw = fs.readFileSync(path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf-8'); - const cfg = JSON.parse(raw) as Record; - const gateway = cfg['gateway'] as Record | undefined; - const p = gateway?.['port'] ?? cfg['port'] ?? cfg['gateway_port'] ?? cfg['gatewayPort']; - const n = typeof p === 'string' ? parseInt(p, 10) : typeof p === 'number' ? p : NaN; - if (Number.isFinite(n) && n > 0 && n < 65536) { localPort = n; } - } catch { /* use default */ } - this._panel.webview.html = this._getHostsOverviewHtml(iconUri.toString(), localPort); + this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString()); + this._autoUpdateTriggered = false; return; } - // Show unified setup view only when there is no evidence of a running gateway. - // If the gateway is already reachable (e.g. container running, editor reloaded after - // setup) skip straight to the dashboard so the user doesn't see the host picker again. - if (!isConfigured && !isGatewayReachable) { - this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString()); - this._autoUpdateTriggered = false; // reset so check fires when they reach the dashboard - } else { - // Local is configured and Docker is not running — show local status. - setActiveOpenClawWorkspaceFolder(path.join(os.homedir(), '.openclaw')); - - const emojiBaseUri = this._panel.webview.asWebviewUri( - vscode.Uri.joinPath(this._extensionUri, 'media', 'emojis') - ).toString(); - let aiModelName = ''; - try { - const cfg = await this._host.readConfig() as Record; - const primaryModel = (cfg as Record>>>) - ?.agents?.defaults?.model?.primary ?? ''; - if (primaryModel) { - const slashIdx = primaryModel.indexOf('/'); - const providerId = slashIdx >= 0 ? primaryModel.slice(0, slashIdx) : ''; - const modelId = slashIdx >= 0 ? primaryModel.slice(slashIdx + 1) : primaryModel; - const providers = (cfg as Record>>>) - ?.models?.providers ?? {}; - const providerModels = providers[providerId]?.models ?? []; - const modelDef = providerModels.find((m: { id: string; name?: string; input?: string[] }) => m.id === modelId); - aiModelName = modelDef?.name ?? primaryModel; - } - } catch { /* openclaw.json unreadable or missing fields */ } + // Local is configured and Docker is not running — show local status. + setActiveOpenClawWorkspaceFolder(path.join(os.homedir(), '.openclaw')); - this._panel.webview.html = this._getHtml(isInstalled, dirExists, cliCheck, iconUri.toString(), occJwt, occUser, emojiBaseUri, aiModelName); - if (!this._autoUpdateTriggered) { - this._autoUpdateTriggered = true; - setTimeout(() => void this._autoUpdateIfOutdated(), 3000); + const emojiBaseUri = this._panel.webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'media', 'emojis') + ).toString(); + let aiModelName = ''; + try { + const cfg = await this._host.readConfig() as Record; + const primaryModel = (cfg as Record>>>) + ?.agents?.defaults?.model?.primary ?? ''; + if (primaryModel) { + const slashIdx = primaryModel.indexOf('/'); + const providerId = slashIdx >= 0 ? primaryModel.slice(0, slashIdx) : ''; + const modelId = slashIdx >= 0 ? primaryModel.slice(slashIdx + 1) : primaryModel; + const providers = (cfg as Record>>>) + ?.models?.providers ?? {}; + const providerModels = providers[providerId]?.models ?? []; + const modelDef = providerModels.find((m: { id: string; name?: string; input?: string[] }) => m.id === modelId); + aiModelName = modelDef?.name ?? primaryModel; } + } catch { /* openclaw.json unreadable or missing fields */ } + + this._panel.webview.html = this._getHtml(isInstalled, dirExists, cliCheck, iconUri.toString(), occJwt, occUser, emojiBaseUri, aiModelName); + if (!this._autoUpdateTriggered) { + this._autoUpdateTriggered = true; + setTimeout(() => void this._autoUpdateIfOutdated(), 3000); } this._startPolling(); if (isInstalled) { @@ -757,171 +740,6 @@ export class HomePanel { }); } - private async _handleCheckHostsStatus(): Promise { - // Local: "running" = config file exists (OpenClaw is installed & configured). - // Gateway port check is unreliable because the gateway may not be auto-started. - const localConfigured = fs.existsSync(path.join(os.homedir(), '.openclaw', 'openclaw.json')); - const localStatus: 'running' | 'stopped' = localConfigured ? 'running' : 'stopped'; - - // Docker: "running" = container is up (regardless of whether gateway is started inside it). - const dockerContainerRunning = await new Promise(resolve => { - try { - const result = cp.spawnSync( - 'docker', - ['ps', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], - { timeout: 3000, windowsHide: true }, - ); - const st = (result.stdout?.toString() ?? '').trim(); - resolve(st.length > 0 && st.toLowerCase().startsWith('up')); - } catch { resolve(false); } - }); - const dockerStatus: 'running' | 'stopped' = dockerContainerRunning ? 'running' : 'stopped'; - - try { - this._panel.webview.postMessage({ type: 'hostsStatus', local: localStatus, docker: dockerStatus }); - } catch { /* ignore */ } - } - - private _getHostsOverviewHtml(iconUri: string, localPort: number): string { - return ` - - - - - - - - -

OCC Home

-

Choose a host to open

- -
- - - -
- - - -`; - } - private _getHostTypeSelectionHtml(iconUri: string): string { return ` diff --git a/apps/editor/extensions/openclaw/src/panels/statusController.ts b/apps/editor/extensions/openclaw/src/panels/statusController.ts index 84ac565a..c01022e5 100644 --- a/apps/editor/extensions/openclaw/src/panels/statusController.ts +++ b/apps/editor/extensions/openclaw/src/panels/statusController.ts @@ -91,17 +91,33 @@ function _applyActiveOpenClawWorkspaceFolder(targetPath: string): void { if (targetFound && toRemove.length === 0) return; // already correct - // Remove stale dirs in descending index order so indices stay valid - for (const idx of [...toRemove].sort((a, b) => b - a)) { - vscode.workspace.updateWorkspaceFolders(idx, 1); + // VS Code only permits one pending updateWorkspaceFolders operation at a time; + // separate remove+add calls drop the second, leaving folders=[] which then + // triggers openOpenClawFolder() to rewrite the file on the next activate — + // a reload loop. Fuse the remove+add into a single atomic call. + const addSpec = targetFound ? undefined : { uri: targetUri, name: path.basename(targetPath) }; + + if (toRemove.length > 0) { + const sorted = [...toRemove].sort((a, b) => a - b); + const start = sorted[0]; + const contiguous = sorted.every((v, i) => v === start + i); + if (contiguous) { + if (addSpec) { + vscode.workspace.updateWorkspaceFolders(start, sorted.length, addSpec); + } else { + vscode.workspace.updateWorkspaceFolders(start, sorted.length); + } + return; + } + // Non-contiguous: remove highest index first; the add (if needed) will + // happen on the next debounced call. Safe — next call sees correct state. + vscode.workspace.updateWorkspaceFolders(sorted[sorted.length - 1], 1); + if (addSpec) { _wsUpdateTarget = targetPath; } + return; } - if (!targetFound) { - const count = vscode.workspace.workspaceFolders?.length ?? 0; - vscode.workspace.updateWorkspaceFolders(count, null, { - uri: targetUri, - name: path.basename(targetPath), - }); + if (addSpec) { + vscode.workspace.updateWorkspaceFolders(folders.length, null, addSpec); } } catch { /* non-fatal */ } } From 85a1576607d627e9902b34387b95517ef37e242c Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Apr 2026 03:58:35 +0000 Subject: [PATCH 3/6] fix(config): open gateway dashboard externally to bypass Chrome LNA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Open Web Control" used ConfigPanel.createOrShow to render an iframe at http://127.0.0.1:/ inside a webview. When the editor is served from any host other than `localhost` (the normal case for code-server, SSH port forwards, or a remote dev box), Chrome's Local Network Access policy classifies the top-level page as "public" and blocks the private-network iframe load: "The connection is blocked because it was initiated by a public page to connect to devices or servers on your local network." Swap the webview-iframe path for vscode.env.openExternal(dashInfo.url). A top-level navigation isn't subject to LNA, so it works regardless of where the editor is hosted. Desktop VS Code gets the same UX (system browser tab) it already had for external links. Keep ConfigPanel exported for any future caller and harden its _load() with asExternalUri() in case it gets wired back in — in VS Code Web asExternalUri tunnels a loopback URL through the editor origin, which is the only reliable way to iframe a local port from a public-origin page. Add tests/e2e/open-web-control.spec.ts as a regression pin: asserts no 127.0.0.1 iframe is mounted in the editor DOM after the button click. (Spec runs cleanly against a Playwright-owned browser; in the repo's CDP-shared-Chrome dev setup it can hit the documented Chrome-tab-state flake from AGENTS.md.) Co-Authored-By: Claude Haiku 4.5 --- .../extensions/openclaw/src/extension.ts | 20 ++++-- .../extensions/openclaw/src/panels/config.ts | 18 +++-- tests/e2e/open-web-control.spec.ts | 67 +++++++++++++++++++ 3 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/open-web-control.spec.ts diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index 6d89a8f5..e8151512 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -8,7 +8,7 @@ import * as https from 'https'; import { HomePanel } from './panels/home'; import { StatusPanel } from './panels/status'; import { setActiveOpenClawWorkspaceFolder } from './panels/statusController'; -import { stopConfigProxy, getDashboardUrl, ConfigPanel } from './panels/config'; +import { stopConfigProxy, getDashboardUrl } from './panels/config'; import { HostRegistry } from './hosts/registry'; import { HostManager } from './hosts/manager'; import { HostStatusBarItem } from './hosts/statusbar'; @@ -730,8 +730,16 @@ export async function activate(context: vscode.ExtensionContext): Promise uri.toString(true), () => loopbackSrc); + this._panel.webview.html = this._iframeHtml(proxySrc, externalSrc); } catch (err) { this._panel.webview.html = this._errorHtml(String(err)); diff --git a/tests/e2e/open-web-control.spec.ts b/tests/e2e/open-web-control.spec.ts new file mode 100644 index 00000000..0f0c2681 --- /dev/null +++ b/tests/e2e/open-web-control.spec.ts @@ -0,0 +1,67 @@ +/** + * open-web-control.spec.ts + * + * Regression test for the "Open Web Control" button. Previously it rendered a + * ConfigPanel webview with an