Skip to content

Commit c77ca10

Browse files
AhmedTMMclaudelouisgv
authored
feat: ssh tunnel + browser auto-open for OpenClaw web dashboard (#2452)
OpenClaw runs a web dashboard on port 18791 of the remote VM. This change SSH-tunnels that port to localhost and auto-opens the browser, giving users a web UI with zero CLI knowledge needed. - Add TunnelConfig to AgentConfig interface (agents.ts) - Add startSshTunnel function with port-finding logic (ssh.ts) - Capture gateway token in closure so the same token is used for both the remote config and the browser URL (agent-setup.ts) - Wire tunnel into orchestration pipeline between preLaunch and interactiveSession (orchestrate.ts) - Add getConnectionInfo to CloudOrchestrator interface and implement in all SSH-based clouds (DO, Hetzner, AWS, GCP) - Local: opens browser directly at localhost:18791 - Sprite: gracefully skipped (no standard SSH) - Add USER.md bootstrap to guide OpenClaw users to web dashboard Closes #2449 Supersedes #2418 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
1 parent a46a92a commit c77ca10

12 files changed

Lines changed: 235 additions & 28 deletions

File tree

packages/cli/src/aws/aws.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,17 @@ export function getState() {
198198
};
199199
}
200200

201+
/** Return SSH connection info for tunnel support. */
202+
export function getConnectionInfo(): {
203+
host: string;
204+
user: string;
205+
} {
206+
return {
207+
host: _state.instanceIp,
208+
user: SSH_USER,
209+
};
210+
}
211+
201212
// ─── SSH Config ─────────────────────────────────────────────────────────────
202213

203214
const SSH_USER = "ubuntu";

packages/cli/src/aws/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createInstance,
1313
ensureAwsCli,
1414
ensureSshKey,
15+
getConnectionInfo,
1516
getServerName,
1617
interactiveSession,
1718
promptBundle,
@@ -58,6 +59,7 @@ async function main() {
5859
await waitForCloudInit();
5960
},
6061
interactiveSession,
62+
getConnectionInfo,
6163
};
6264

6365
await runOrchestration(cloud, agent, agentName);

packages/cli/src/digitalocean/digitalocean.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ const _state: DigitalOceanState = {
105105
serverIp: "",
106106
};
107107

108+
/** Return SSH connection info for tunnel support. */
109+
export function getConnectionInfo(): {
110+
host: string;
111+
user: string;
112+
} {
113+
return {
114+
host: _state.serverIp,
115+
user: "root",
116+
};
117+
}
118+
108119
// ─── API Client ──────────────────────────────────────────────────────────────
109120

110121
async function doApi(method: string, endpoint: string, body?: string, maxRetries = 3): Promise<string> {

packages/cli/src/digitalocean/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ensureDoToken,
1414
ensureSshKey,
1515
findSpawnSnapshot,
16+
getConnectionInfo,
1617
getServerName,
1718
interactiveSession,
1819
promptDoRegion,
@@ -75,6 +76,7 @@ async function main() {
7576
}
7677
},
7778
interactiveSession,
79+
getConnectionInfo,
7880
};
7981

8082
await runOrchestration(cloud, agent, agentName);

packages/cli/src/gcp/gcp.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ const _state: GcpState = {
155155
username: "",
156156
};
157157

158+
/** Return SSH connection info for tunnel support. */
159+
export function getConnectionInfo(): {
160+
host: string;
161+
user: string;
162+
} {
163+
return {
164+
host: _state.serverIp,
165+
user: resolveUsername(),
166+
};
167+
}
168+
158169
// ─── gcloud CLI Wrapper ─────────────────────────────────────────────────────
159170

160171
function getGcloudCmd(): string | null {

packages/cli/src/gcp/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
checkBillingEnabled,
1313
createInstance,
1414
ensureGcloudCli,
15+
getConnectionInfo,
1516
getServerName,
1617
interactiveSession,
1718
promptMachineType,
@@ -64,6 +65,7 @@ async function main() {
6465
await waitForCloudInit();
6566
},
6667
interactiveSession,
68+
getConnectionInfo,
6769
};
6870

6971
await runOrchestration(cloud, agent, agentName);

packages/cli/src/hetzner/hetzner.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ const _state: HetznerState = {
5252
serverIp: "",
5353
};
5454

55+
/** Return SSH connection info for tunnel support. */
56+
export function getConnectionInfo(): {
57+
host: string;
58+
user: string;
59+
} {
60+
return {
61+
host: _state.serverIp,
62+
user: "root",
63+
};
64+
}
65+
5566
// ─── API Client ──────────────────────────────────────────────────────────────
5667

5768
async function hetznerApi(method: string, endpoint: string, body?: string, maxRetries = 3): Promise<string> {

packages/cli/src/hetzner/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createServer as createHetznerServer,
1212
ensureHcloudToken,
1313
ensureSshKey,
14+
getConnectionInfo,
1415
getServerName,
1516
interactiveSession,
1617
promptLocation,
@@ -58,6 +59,7 @@ async function main() {
5859
await waitForCloudInit();
5960
},
6061
interactiveSession,
62+
getConnectionInfo,
6163
};
6264

6365
await runOrchestration(cloud, agent, agentName);

packages/cli/src/shared/agent-setup.ts

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -334,15 +334,20 @@ async function installChromeBrowser(runner: CloudRunner): Promise<void> {
334334
}
335335
}
336336

337-
async function setupOpenclawConfig(runner: CloudRunner, apiKey: string, modelId: string): Promise<void> {
337+
async function setupOpenclawConfig(
338+
runner: CloudRunner,
339+
apiKey: string,
340+
modelId: string,
341+
token?: string,
342+
): Promise<void> {
338343
logStep("Configuring openclaw...");
339344
await runner.runServer("mkdir -p ~/.openclaw");
340345

341346
// Chrome must be installed before config is written (config references its path).
342347
// This runs in configure() — not install() — so it works even with tarball installs.
343348
await installChromeBrowser(runner);
344349

345-
const gatewayToken = crypto.randomUUID().replace(/-/g, "");
350+
const gatewayToken = token ?? crypto.randomUUID().replace(/-/g, "");
346351
const escapedKey = jsonEscape(apiKey);
347352
const escapedToken = jsonEscape(gatewayToken);
348353
const escapedModel = jsonEscape(modelId);
@@ -380,6 +385,25 @@ async function setupOpenclawConfig(runner: CloudRunner, apiKey: string, modelId:
380385
} catch {
381386
logWarn("Browser config setup failed (non-fatal)");
382387
}
388+
389+
// Write USER.md bootstrap file — guides users to the web dashboard for
390+
// visual tasks like WhatsApp QR code scanning that don't work in the TUI.
391+
const userMd = [
392+
"# User",
393+
"",
394+
"## Web Dashboard",
395+
"",
396+
"This machine has a web dashboard running on port 18791.",
397+
"When helping the user set up channels that require QR code scanning",
398+
"(WhatsApp, Telegram, etc.), always guide them to use the web dashboard",
399+
"instead of the TUI — QR codes cannot be scanned from a terminal.",
400+
"",
401+
"The dashboard URL is: http://localhost:18791",
402+
"(It may also be SSH-tunneled to the user's local machine automatically.)",
403+
"",
404+
].join("\n");
405+
await runner.runServer("mkdir -p ~/.openclaw/workspace");
406+
await uploadConfigFile(runner, userMd, "$HOME/.openclaw/workspace/USER.md");
383407
}
384408

385409
export async function startGateway(runner: CloudRunner): Promise<void> {
@@ -612,30 +636,37 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
612636
launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; codex",
613637
},
614638

615-
openclaw: {
616-
name: "OpenClaw",
617-
cloudInitTier: "full",
618-
preProvision: detectGithubAuth,
619-
modelDefault: "moonshotai/kimi-k2.5",
620-
install: async () => {
621-
await installAgent(
622-
runner,
623-
"openclaw",
624-
`source ~/.bashrc 2>/dev/null; ${NPM_PREFIX_SETUP} && npm install -g \${_NPM_G_FLAGS} openclaw && ${NPM_GLOBAL_PATH_PERSIST}`,
625-
);
626-
},
627-
envVars: (apiKey) => [
628-
`OPENROUTER_API_KEY=${apiKey}`,
629-
`ANTHROPIC_API_KEY=${apiKey}`,
630-
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
631-
],
632-
configure: (apiKey, modelId) => setupOpenclawConfig(runner, apiKey, modelId || "moonshotai/kimi-k2.5"),
633-
preLaunch: () => startGateway(runner),
634-
preLaunchMsg:
635-
"Set up one channel at a time in the OpenClaw TUI. Wait for each channel to fully complete before pasting the next token — concurrent token pastes can cause setup to hang.",
636-
launchCmd: () =>
637-
"source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; openclaw tui",
638-
},
639+
openclaw: (() => {
640+
const dashboardToken = crypto.randomUUID().replace(/-/g, "");
641+
return {
642+
name: "OpenClaw",
643+
cloudInitTier: "full" satisfies AgentConfig["cloudInitTier"],
644+
preProvision: detectGithubAuth,
645+
modelDefault: "moonshotai/kimi-k2.5",
646+
install: async () => {
647+
await installAgent(
648+
runner,
649+
"openclaw",
650+
`source ~/.bashrc 2>/dev/null; ${NPM_PREFIX_SETUP} && npm install -g \${_NPM_G_FLAGS} openclaw && ${NPM_GLOBAL_PATH_PERSIST}`,
651+
);
652+
},
653+
envVars: (apiKey: string) => [
654+
`OPENROUTER_API_KEY=${apiKey}`,
655+
`ANTHROPIC_API_KEY=${apiKey}`,
656+
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
657+
],
658+
configure: (apiKey: string, modelId?: string) =>
659+
setupOpenclawConfig(runner, apiKey, modelId || "moonshotai/kimi-k2.5", dashboardToken),
660+
preLaunch: () => startGateway(runner),
661+
preLaunchMsg: "Your web dashboard will open automatically. If it doesn't, check the terminal for the URL.",
662+
launchCmd: () =>
663+
"source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; openclaw tui",
664+
tunnel: {
665+
remotePort: 18791,
666+
browserUrl: (localPort: number) => `http://localhost:${localPort}/?token=${dashboardToken}`,
667+
},
668+
};
669+
})(),
639670

640671
opencode: {
641672
name: "OpenCode",

packages/cli/src/shared/agents.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export interface AgentConfig {
2929
cloudInitTier?: CloudInitTier;
3030
/** Skip tarball install attempt (e.g., already using snapshot). */
3131
skipTarball?: boolean;
32+
/** SSH tunnel config for web dashboards. */
33+
tunnel?: TunnelConfig;
34+
}
35+
36+
/** Configuration for SSH-tunneling a remote port to localhost. */
37+
export interface TunnelConfig {
38+
remotePort: number;
39+
browserUrl?: (localPort: number) => string | undefined;
3240
}
3341

3442
// ─── Shared Helpers ──────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)