Skip to content

feat: multihost support — local, Docker, SSH adapter architecture#53

Merged
damoahdominic merged 19 commits intodamoahdominic:mainfrom
FletcherFrimpong:multihost
Mar 27, 2026
Merged

feat: multihost support — local, Docker, SSH adapter architecture#53
damoahdominic merged 19 commits intodamoahdominic:mainfrom
FletcherFrimpong:multihost

Conversation

@FletcherFrimpong
Copy link
Copy Markdown
Collaborator

@FletcherFrimpong FletcherFrimpong commented Mar 26, 2026

Summary

  • Adds pluggable multi-host adapter system to manage OpenClaw on local, Docker, and SSH (stub) instances from a single window
  • Introduces HostRegistry (~/.occ/hosts.json), HostManager, and OpenClawCoreAPI for adapter registration
  • Extracts openclaw-local and openclaw-docker as dedicated extensions with full connection implementations
  • Adds window-level host binding and smart home routing logic
  • Fixes package-lock.json files for all openclaw extensions

Security Fixes

✅ JWT migrated from globalStateSecretStorage (OS keychain)

JWT was previously stored in plaintext on disk, readable by any VS Code extension or OS process. Now encrypted at rest via macOS Keychain / Windows Credential Manager / libsecret.

On first launch after this update, the extension automatically moves any existing JWT from globalState into SecretStorage then deletes the plaintext copy — existing users will not need to sign in again.

Protects against:

  • Any VS Code extension (malicious or compromised) reading your JWT from the globalState JSON file
  • The JWT being included in bug reports, backups, or accidental git add .
  • Other OS users on shared machines reading your credentials

✅ npm package integrity verification before install

Before running npm install, OCC now:

  1. Fetches package metadata from registry.npmjs.org
  2. Downloads the tarball to a temp file
  3. Verifies SHA-512 hash against dist.integrity (SRI format)
  4. Only installs from the verified local file — no re-download

All three install paths covered: main, sudo retry, and nvm fallback. If the hash does not match, installation is aborted with a clear error message.

Protects against: MITM attacks, CDN compromise, corrupted downloads.

✅ Additional security fixes

  • Removed unsafe-eval from Content Security Policy in config panel
  • HTML-escaped version strings before innerHTML to prevent XSS from a compromised npm registry
  • Sanitized maintainerName/URL before inserting into innerHTML
  • Replaced dynamic require('child_process') with already-imported cp module
  • Redacted API keys from diagnostic logs (no longer logs first 12 chars)
  • Added null byte + absolute path validation for SSH file writes
  • Added localResourceRoots to restrict webview resource loading
  • Upgraded Next.js to 16.2.1 (fixes 5 CVEs: CSRF bypass, HTTP smuggling, DoS)

Test plan

  • Launch OCCode and verify local host auto-detected
  • Add a Docker host and verify container connection
  • Verify smart routing (local → local panel, Docker running → Docker panel, both → host picker)
  • Verify window-level host binding persists across reloads
  • Sign in, restart OCC, verify session is preserved (JWT migration working)
  • Click "Install OpenClaw" — verify integrity check runs and install succeeds
  • Confirm JWT is no longer present in VS Code globalState JSON file after update

🤖 Generated with Claude Code

damoahdominic and others added 19 commits March 20, 2026 04:42
…onfigs

Creates openclaw-local, openclaw-docker, openclaw-ssh extension scaffolds
and new src/hosts, src/ui, src/api directories in core extension.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- apps/editor/extensions/openclaw/src/hosts/types.ts: complete shared
  interface definitions — HostAdapter, HostConnection, HostEntry,
  HostsFile, HostCache, OpenClawCoreAPI, all connection configs
  (Local/Docker/SSH/Cloud), LogFn, ExecOpts/Result, GatewayStatus, etc.
- apps/editor/extensions/openclaw-local/: new standalone extension stub
  (package.json + tsconfig.json)
- apps/editor/extensions/openclaw-docker/: new standalone extension stub
- apps/editor/extensions/openclaw-ssh/: new standalone extension stub
  (preview, 0.1.0)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…, API export

- hosts/registry.ts: reads/writes ~/.occ/hosts.json, seeds local default,
  file-watches for external changes, CRUD for hosts + activeHostId
- hosts/manager.ts: implements OpenClawCoreAPI; owns live HostConnection map;
  registers adapters, connects persisted hosts, drives status events
- hosts/statusbar.ts: status bar item showing active host with icon
- hosts/tree.ts: Tree view provider listing all hosts (openclaw.hosts view)
- extension.ts: bootstraps registry + manager, exports OpenClawCoreAPI from
  activate(), registers openclaw.pickHost / setActiveHost / refreshHost commands
- package.json: adds views (openclaw.hosts in explorer), 3 new commands

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…ostConnection

LocalHostConnection implements full HostConnection interface for the local machine:
- exec / execStream: child_process.spawn with full opts support
- Filesystem: readFile/writeFile/exists/mkdir/stat via Node fs
- CLI detection: user config → which/where → well-known paths (darwin/win32/linux)
- installCli: curl|bash on unix, irm|iex on windows
- readConfig/writeConfig: ~/.openclaw/openclaw.json
- gatewayHealthCheck: `openclaw gateway status --json`, falls back to string parse
- gatewayStart/Stop/Restart, runSetup (openclaw onboard)

LocalHostAdapter: discovers one local host, testConnection returns CLI + gateway state.

openclaw-local/extension.ts: grabs OpenClawCoreAPI from openclaw.home exports,
registers LocalHostAdapter, adds disposable to subscriptions.

tsconfig.json: removed rootDir on all three adapter extensions so cross-extension
relative imports (../../openclaw/src/hosts/types) compile correctly.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
openclaw-docker:
- preflight.ts: three-state Docker health check (cli_missing / daemon_down /
  permission_denied) with platform-specific remedies for darwin (OrbStack),
  win32 (Docker Desktop + WSL2), linux (systemd + apt/dnf)
- connection.ts: DockerHostConnection — all HostConnection methods via
  `docker exec`; writeFile via stdin+tee; portMapping aware gatewayHealthCheck
- adapter.ts: DockerHostAdapter — discovers running containers via `docker ps`;
  resolves containerId from label or compose service; testConnection runs
  preflight + CLI check inside container; full getConfigFields / validateConfig
- extension.ts: registers DockerHostAdapter against openclaw.home exports

openclaw-ssh:
- src/extension.ts: activation stub (logs "coming soon", no-op)

All four extensions compile cleanly with zero TypeScript errors.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
… inference

TypeScript infers the rootDir as apps/editor/extensions/ (common ancestor) when
import-type references span across extension directories. This causes output files
to land in out/openclaw-local/src/ and out/openclaw-docker/src/ rather than out/.

Fix: update "main" in each adapter's package.json to match the actual compiled path:
- openclaw-local: ./out/openclaw-local/src/extension
- openclaw-docker: ./out/openclaw-docker/src/extension
- openclaw-ssh: ./out/extension (no cross-extension imports, unaffected)

All four extensions verified with clean builds.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
HomePanel now delegates all host I/O through a HostConnection interface,
making it host-agnostic and ready for Docker/SSH in future:

Key changes in home.ts:
- _host: HostConnection property (DefaultLocalHostConnection by default);
  swapped to active host when HostManager fires onDidChangeActiveHost
- _buildExecEnv()     → this._host.buildExecEnv()   (eliminates 55-line method)
- _testOpenClawCli()  → this._host.testOpenClawCli() (eliminates 103-line method)
- _findOpenClawPath() → this._host.findOpenClawPath() (eliminates 65-line method)
- _quickInstallCheck() → async, delegates to this._host.exists(configPath)
- _getConfiguredPort()  → reads _cachedGatewayPort (updated async in _update())
- _update()           → host.exists()/readConfig()/getConfigPath() for config detection
- _runSetup()         → this._host.execStream() for openclaw onboard subprocess;
                         host.readConfig()/writeConfig() for openclaw.json patching;
                         host.exec() for Node.js version check
New files:
- hosts/localDefault.ts: DefaultLocalHostConnection — self-contained local
  implementation within the core extension; no cross-extension source imports
- hosts/types.ts: added shell?: boolean to ExecOpts (needed for Windows .cmd shims)
- openclaw-local/connection.ts: propagate shell option to cp.spawn

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…er tabs

- Extract _getHtml() from HomePanel into shared renderStatusHtml() in statusHtml.ts
- Create StatusPanelController (statusController.ts) with all behavioral logic:
  gateway polling, version checks, sign-in/out, CASS/Better Memory setup,
  workspace file shortcuts — usable by any adapter panel
- LocalSetupPanel: if OpenClaw already installed, show full status panel
  immediately instead of wizard; after setup success, show status panel
  in-tab instead of delegating to HomePanel
- DockerSetupPanel: after configure success, show status panel in-tab
  instead of disposing the panel
- Adapters now delegate all webview messages to StatusPanelController
  once the status panel is active

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…ey prompt

Docker wizard now automatically onboards with the OCC free tier (occ-legacy
model at occ.mba.sh/v1) after the CLI installs — no API key required.
Users can sign in and change the model later from the status panel.

- Remove step 4 provider grid / API key form entirely
- After CLI install, immediately trigger onboard with occ-legacy flags
- Patch openclaw.json in container to set correct model metadata
- Write moltpilot-tier.json to the mounted host state dir

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Replace 4-step wizard (Docker Check → Container+Install → Configure →
  Status) with new 4-step flow:
  1. Docker Check (docker info)
  2. Pull Image (ghcr.io/openclaw/openclaw:latest + create state dir)
  3. Onboard (one-shot docker run --rm with occ-legacy auto-config)
  4. Launch Gateway (persistent container, port 18790:18789)

- Volume mount: ~/Desktop/occ-state-dir:/home/node/.openclaw
- Port mapping: 18790:18789 (avoids collision with local install)
- Config path fixed to /home/node/.openclaw/openclaw.json (user 'node')
- occ-legacy patched directly on host state dir after onboard

Add gatewayHostPort() to HostConnection interface so StatusPanelController
polls the host-side port (18790) instead of the container port (18789).
Implemented in DockerHostConnection (returns portMappings.gateway) and
DefaultLocalHostConnection (returns undefined / no remapping).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Smart routing on openclaw.home command:
- Only Local installed  → open LocalSetupPanel directly
- Only Docker running   → open DockerSetupPanel directly
- Both installed        → HomePanel with hosts overview
- Neither               → HomePanel (install wizard)

Hosts overview (shown when both are active):
- Two cards: Local + Docker with live gateway status pills
  (checking/running/stopped), port labels, polls every 5 seconds
- Clicking a card opens the respective setup panel

Panel tab titles updated when status panel is active:
- LocalSetupPanel  → "OCC Home {Local:18789}" (reads port from openclaw.json)
- DockerSetupPanel → "OCC Home {Docker:18790}"

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Two root causes fixed:

1. StatusPanelController: for non-local hosts dirExists was always
   checking local ~/.openclaw (doesn't exist for Docker-only users).
   Now uses isConfigured for non-local. isInstalled also simplified:
   non-local hosts are always considered installed (status panel is
   only opened after setup — if we're here, openclaw is in the image).

2. DockerSetupPanel: add _initHtml() with auto-detection (like
   LocalSetupPanel). On open, checks synchronously if occ-openclaw
   container is running, then verifies openclaw config/CLI inside it.
   If already set up → jumps directly to status panel. If not →
   shows the setup wizard.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Global state (workspaceState) tracks which host this window is bound to.
MoltPilot and any component can read it via occ.window.getHost command.

Commands registered:
  occ.window.setHost   — set { type, hostId, port, label }
  occ.window.clearHost — remove binding
  occ.window.getHost   — read binding (returns null if unbound)

Routing (openclaw.home / startup):
  - Bound window → routes directly to bound host panel (no picker)
  - Unbound → existing detection logic (local/docker/both/neither)

Single-tab enforcement:
  - HomePanel disposes itself before opening adapter panel (picker closes)
  - Adapter panels set occ.window.setHost when status panel activates

Disconnect flow (... menu → "Disconnect host"):
  - Clears occ.window.clearHost
  - Disposes adapter panel
  - Reopens host picker (openclaw.home)
  - StatusPanelController now accepts optional onDisconnect callback

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…st-selector bounce

Removes addHost()/setActiveHost() from DockerSetupPanel._showStatusPanel().
Firing onDidChangeActiveHost was triggering HomePanel._update() which re-rendered
the hosts overview (bouncing user back to host selector). Also eliminates the
blocking dockerPreflight() spawnSync call that was piggybacked on addHost().

Other MultiHost changes included in this commit:
- statusController: debounced setActiveOpenClawWorkspaceFolder to prevent
  concurrent .code-workspace writes ("File Modified Since" error)
- home.ts: read local port directly from disk in _getHostsOverviewHtml to
  avoid _cachedGatewayPort contamination from Docker host
- home.ts: add "Best if:" context bullets on host selection cards
- DockerSetupPanel: show loading spinner immediately on open (fire-and-forget
  _showStatusPanel) instead of blank panel
- DockerSetupPanel: _disposed guard to prevent double-dispose

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…ties

- config.ts: remove unsafe-eval from Content Security Policy
- statusController.ts: HTML-escape version strings before innerHTML injection
- statusHtml.ts: sanitize maintainerName/URL before inserting into innerHTML
- extension.ts: use imported cp module instead of require(); redact API keys from logs
- openclaw-ssh/connection.ts: validate file paths to reject null bytes and non-absolute paths
- status.ts: add localResourceRoots to restrict webview resource loading
- apps/web: upgrade Next.js to 16.2.1 to fix CSRF bypass and HTTP smuggling CVEs

Co-Authored-By: FletcherFrimpong <[email protected]>
JWT was stored in plaintext globalState (a JSON file on disk), readable
by any process running as the same OS user or any VS Code extension.

Changes:
- All reads: context.globalState.get(OCC_JWT_KEY) → context.secrets.get()
- All writes: context.globalState.update() → context.secrets.store/delete()
- One-time migration on activate: moves existing JWT from globalState to
  SecretStorage, then deletes plaintext copy
- Smoke test no longer logs any part of the JWT
- Deep-link URI handler (occode://auth) stores token in SecretStorage

JWT is now encrypted at rest via OS keychain (Keychain on macOS,
Credential Manager on Windows, libsecret on Linux).

Co-Authored-By: FletcherFrimpong <[email protected]>
Before running npm install, OCC now:
1. Fetches package metadata from registry.npmjs.org
2. Downloads the tarball to a temp file
3. Verifies SHA-512 hash against dist.integrity (SRI format)
4. Only installs from the verified local file — no re-download

All three install paths are covered:
- Main npm install path
- Sudo retry path (re-downloads and re-verifies)
- nvm fallback path

If the hash does not match, installation is aborted with a clear
error message. Temp files are always cleaned up after install.

Protects against: MITM attacks, CDN compromise, corrupted downloads.

Co-Authored-By: FletcherFrimpong <[email protected]>
…export

The site uses Next.js static export (output: "export"), which means
server-side fetches only run at build time. Moving the GitHub releases
API fetch to a client-side useEffect ensures users always get the
latest release download URLs at runtime.
@damoahdominic damoahdominic merged commit 6c74792 into damoahdominic:main Mar 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants