diff --git a/README.md b/README.md index 9370cb4..9340f5f 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,17 @@ If your Gateway is already running, pick the scenario that matches where your Ga All setups use the same install/run path (recommended): `npx -y openclaw-studio@latest` +Two links matter: + +1. Browser -> Studio +2. Studio -> Gateway + +`localhost` always means "the Studio host." If Studio and OpenClaw share a machine, the upstream should usually stay at `ws://localhost:18789` even when that machine is a cloud VM. + ## Requirements - Node.js 20.9+ (LTS recommended) -- An OpenClaw Gateway URL + token +- An OpenClaw Gateway URL + token, or a local OpenClaw install Studio can detect - Tailscale (optional, recommended for remote access) ## A) Gateway local, Studio local (same computer) @@ -50,6 +57,9 @@ Recommended (Tailscale Serve on the gateway host): 2. In Studio (on your laptop): - Upstream URL: `wss://.ts.net` - Upstream Token: your gateway token +3. Keep in mind: + - Studio still needs a gateway token here, even if the OpenClaw Control UI can use Tailscale identity headers + - Raw `ws://:18789` is an advanced/manual path and may need extra OpenClaw origin configuration Alternative (SSH tunnel): @@ -60,21 +70,23 @@ Alternative (SSH tunnel): ## C) Studio in the cloud, Gateway in the cloud -This is the “always-on” setup. The easiest secure version is to keep the Gateway private and expose Studio over Tailscale. +This is the “always-on” setup. When Studio and OpenClaw run on the same cloud VM, keep the OpenClaw upstream local and solve browser access to Studio separately. 1. On the VPS that will run Studio: - Run Studio (same commands as above). -2. Expose Studio over tailnet HTTPS: - - `tailscale serve --yes --bg --https 443 http://127.0.0.1:3000` -3. Open Studio from your laptop/phone: - - `https://.ts.net` -4. In Studio, set: - - Upstream URL: `wss://.ts.net` (or whatever your gateway is reachable at from the Studio host) +2. If OpenClaw is on that same VPS, keep Studio's upstream set to: + - Upstream URL: `ws://localhost:18789` - Upstream Token: your gateway token +3. Expose Studio over tailnet HTTPS: + - `tailscale serve --yes --bg --https 443 http://127.0.0.1:3000` +4. Open Studio from your laptop/phone: + - `https://.ts.net` +5. Only use a remote upstream like `wss://.ts.net` if Studio and OpenClaw are on different machines. Notes: - Avoid serving Studio behind `/studio` unless you configure `basePath` and rebuild. - If Studio is reachable beyond loopback, `STUDIO_ACCESS_TOKEN` is required. +- If you bind Studio beyond loopback, open `/?access_token=...` once from each new browser to set the Studio cookie. ## How It Connects (Mental Model) @@ -85,6 +97,8 @@ OpenClaw Studio now runs one runtime architecture with **two primary paths**: This is why `ws://localhost:18789` means “gateway on the Studio host”, not “gateway on your phone”. +If Studio is running on a remote machine over SSH and the terminal says `Open in browser: http://localhost:3000`, that `localhost` is the remote machine. Use Tailscale Serve or an SSH tunnel to open Studio from your own laptop. + ## Install from source (advanced) ```bash @@ -94,6 +108,14 @@ npm install npm run dev ``` +Optional setup helper in a source checkout: + +```bash +npm run studio:setup +``` + +That writes the saved gateway URL/token for this Studio host without opening the UI first. + ## Configuration Paths and key settings: @@ -132,7 +154,10 @@ See `docs/color-system.md` for the semantic color contract, status mappings, and If the UI loads but “Connect” fails, it’s usually Studio->Gateway: - Confirm the upstream URL/token in the UI (stored on the Studio host at `/openclaw-studio/settings.json`). +- If Studio is on a remote host, remember that `ws://localhost:18789` means "OpenClaw on the Studio host," not "OpenClaw on your laptop." +- If Studio is on a remote host and you cannot open `http://localhost:3000` from your laptop, expose Studio with `tailscale serve --yes --bg --https 443 http://127.0.0.1:3000` or use `ssh -L 3000:127.0.0.1:3000 user@host`. - `EPROTO` / “wrong version number”: you used `wss://...` to a non-TLS endpoint (use `ws://...`, or put the gateway behind HTTPS). +- `.ts.net` + `ws://`: use `wss://` instead. - Assets 404 under `/studio`: serve Studio at `/` or configure `basePath` and rebuild. - 401 “Studio access token required”: `STUDIO_ACCESS_TOKEN` is enabled; open `/?access_token=...` once to set the cookie. - Helpful error codes: `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, `studio.upstream_closed`. diff --git a/docs/ui-guide.md b/docs/ui-guide.md index 55468a9..e0ebf48 100644 --- a/docs/ui-guide.md +++ b/docs/ui-guide.md @@ -2,6 +2,34 @@ This doc describes the current Studio IA and behavior. +## Connection Onboarding + +### First-run connection screen +- Studio now uses a full-screen connection flow before agent data loads. +- The onboarding teaches two separate links: + 1. Browser -> Studio + 2. Studio -> OpenClaw +- The screen offers three setup branches: + 1. Everything on this computer + 2. Studio here, OpenClaw in the cloud + 3. Studio and OpenClaw on the same cloud machine + +### Core rule +- `localhost` always means the Studio host. +- If Studio and OpenClaw share a machine, the upstream should usually stay at `ws://localhost:18789`, even if that machine is a VPS. + +### Gateway connection actions +- Connection fields are now draft-based rather than saved on every keystroke. +- The user can: + - Save settings + - Test connection + - Disconnect the live Studio runtime +- Saved gateway tokens remain server-custodied; the browser sees whether a token is already stored, but not the token itself. + +### Advanced connection editing +- The top-right plug menu still exposes Gateway connection settings. +- That panel is now an advanced edit surface for saved-vs-draft review, testing, and reconnecting after onboarding. + ## Agent Surfaces ### Chat (default) diff --git a/server/index.js b/server/index.js index e9cc285..c9b8cb3 100644 --- a/server/index.js +++ b/server/index.js @@ -7,6 +7,7 @@ const { spawnSync } = require("node:child_process"); const next = require("next"); const { createAccessGate } = require("./access-gate"); +const { detectInstallContext, buildStartupGuidance } = require("./install-context"); const { assertPublicHostAllowed, resolveHosts } = require("./network-policy"); const resolvePort = () => { @@ -100,6 +101,22 @@ async function main() { const browserUrl = `http://${hostForBrowser}:${port}`; console.info(`Open in browser: ${browserUrl}`); + try { + const installContext = await detectInstallContext(process.env); + const startupGuidance = buildStartupGuidance({ + installContext, + port, + }); + if (startupGuidance.length > 0) { + console.info(""); + console.info("Studio access guidance:"); + for (const line of startupGuidance) { + console.info(`- ${line}`); + } + } + } catch (error) { + console.error("Failed to print Studio access guidance.", error); + } } main().catch((err) => { diff --git a/server/install-context.d.ts b/server/install-context.d.ts new file mode 100644 index 0000000..fc078f9 --- /dev/null +++ b/server/install-context.d.ts @@ -0,0 +1,29 @@ +import type { StudioInstallContext } from "../src/lib/studio/install-context"; + +export type InstallContextCommandRunner = ( + file: string, + args: string[], + options: { + timeout: number; + maxBuffer: number; + windowsHide: boolean; + encoding: string; + } +) => Promise<{ stdout?: string }>; + +export function detectInstallContext( + env?: NodeJS.ProcessEnv, + options?: { + resolveHosts?: (env?: NodeJS.ProcessEnv) => string[]; + isPublicHost?: (host: string) => boolean; + readOpenclawGatewayDefaults?: ( + env?: NodeJS.ProcessEnv + ) => { url: string; token: string } | null; + runCommand?: InstallContextCommandRunner; + } +): Promise; + +export function buildStartupGuidance(params: { + installContext: StudioInstallContext; + port: number; +}): string[]; diff --git a/server/install-context.js b/server/install-context.js new file mode 100644 index 0000000..6901359 --- /dev/null +++ b/server/install-context.js @@ -0,0 +1,220 @@ +const os = require("node:os"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); + +const { resolveHosts, isPublicHost } = require("./network-policy"); +const { readOpenclawGatewayDefaults } = require("./studio-settings"); + +const execFileAsync = promisify(execFile); +const OPENCLAW_PROBE_TIMEOUT_MS = 1_500; +const TAILSCALE_PROBE_TIMEOUT_MS = 1_200; + +const normalizeErrorCode = (error) => { + if (!error || typeof error !== "object") return ""; + if (typeof error.code === "string") return error.code.trim(); + return ""; +}; + +const normalizeErrorMessage = (error) => { + if (error instanceof Error) { + return error.message.trim(); + } + return ""; +}; + +const normalizeJsonValue = (value) => { + if (!value || typeof value !== "object") return null; + return value; +}; + +const runJsonCommand = async (command, args, timeoutMs, runner = execFileAsync) => { + try { + const { stdout } = await runner(command, args, { + timeout: timeoutMs, + maxBuffer: 1024 * 1024, + windowsHide: true, + encoding: "utf8", + }); + const parsed = JSON.parse(String(stdout ?? "").trim()); + return { + available: true, + ok: true, + value: normalizeJsonValue(parsed), + error: null, + }; + } catch (error) { + const code = normalizeErrorCode(error); + const message = normalizeErrorMessage(error); + const timedOut = + code === "ETIMEDOUT" || + code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || + message.toLowerCase().includes("timed out"); + if (code === "ENOENT") { + return { + available: false, + ok: false, + value: null, + error: "cli_not_found", + }; + } + return { + available: true, + ok: false, + value: null, + error: timedOut ? "probe_timeout" : message || "probe_failed", + }; + } +}; + +const normalizeDnsName = (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return null; + return trimmed.replace(/\.$/, ""); +}; + +const probeTailscale = async (env = process.env, runner = execFileAsync) => { + const result = await runJsonCommand( + "tailscale", + ["status", "--json"], + TAILSCALE_PROBE_TIMEOUT_MS, + runner + ); + if (!result.available) { + return { + installed: false, + loggedIn: false, + dnsName: null, + }; + } + const parsed = result.value; + const backendState = + parsed && typeof parsed.BackendState === "string" ? parsed.BackendState.trim() : ""; + const dnsName = normalizeDnsName(parsed && parsed.Self ? parsed.Self.DNSName : ""); + const loggedIn = + result.ok && + backendState !== "NeedsLogin" && + backendState !== "NoState" && + backendState !== "Stopped"; + return { + installed: true, + loggedIn, + dnsName: loggedIn ? dnsName : null, + }; +}; + +const probeLocalGateway = async (runner = execFileAsync) => { + const [statusProbe, sessionsProbe] = await Promise.all([ + runJsonCommand("openclaw", ["status", "--json"], OPENCLAW_PROBE_TIMEOUT_MS, runner), + runJsonCommand("openclaw", ["sessions", "--json"], OPENCLAW_PROBE_TIMEOUT_MS, runner), + ]); + const issues = Array.from( + new Set([statusProbe.error, sessionsProbe.error].filter((value) => typeof value === "string" && value)) + ); + return { + cliAvailable: statusProbe.available || sessionsProbe.available, + statusProbeOk: statusProbe.ok, + sessionsProbeOk: sessionsProbe.ok, + probeHealthy: statusProbe.ok || sessionsProbe.ok, + issues, + }; +}; + +const resolveRemoteShell = (env = process.env) => { + return Boolean( + String(env.SSH_CONNECTION ?? "").trim() || + String(env.SSH_CLIENT ?? "").trim() || + String(env.SSH_TTY ?? "").trim() + ); +}; + +const resolveHostname = () => { + const hostname = String(os.hostname?.() ?? "").trim(); + return hostname || null; +}; + +async function detectInstallContext(env = process.env, options = {}) { + const resolveHostsImpl = options.resolveHosts || resolveHosts; + const isPublicHostImpl = options.isPublicHost || isPublicHost; + const readOpenclawGatewayDefaultsImpl = + options.readOpenclawGatewayDefaults || readOpenclawGatewayDefaults; + const runCommand = options.runCommand || execFileAsync; + const configuredHosts = Array.from( + new Set(resolveHostsImpl(env).map((value) => String(value ?? "").trim()).filter(Boolean)) + ); + const publicHosts = configuredHosts.filter((host) => isPublicHostImpl(host)); + const localDefaults = readOpenclawGatewayDefaultsImpl(env); + const [localGatewayProbe, tailscale] = await Promise.all([ + probeLocalGateway(runCommand), + probeTailscale(env, runCommand), + ]); + + return { + studioHost: { + hostname: resolveHostname(), + configuredHosts, + publicHosts, + loopbackOnly: publicHosts.length === 0, + remoteShell: resolveRemoteShell(env), + studioAccessTokenConfigured: Boolean(String(env.STUDIO_ACCESS_TOKEN ?? "").trim()), + }, + localGateway: { + defaultsDetected: Boolean(localDefaults?.url), + url: localDefaults?.url ?? null, + hasToken: Boolean(localDefaults?.token), + cliAvailable: localGatewayProbe.cliAvailable, + statusProbeOk: localGatewayProbe.statusProbeOk, + sessionsProbeOk: localGatewayProbe.sessionsProbeOk, + probeHealthy: localGatewayProbe.probeHealthy, + issues: localGatewayProbe.issues, + }, + tailscale, + }; +} + +function buildStartupGuidance(params) { + const installContext = params.installContext; + const port = Number.isFinite(params.port) && params.port > 0 ? params.port : 3000; + const hostLabel = + installContext.tailscale.dnsName || + installContext.studioHost.publicHosts[0] || + ""; + const sshTarget = installContext.tailscale.dnsName || hostLabel; + const lines = []; + + if (installContext.studioHost.remoteShell && installContext.studioHost.loopbackOnly) { + lines.push( + `Studio is running on a remote host. http://localhost:${port} only opens on that machine.` + ); + if (installContext.localGateway.defaultsDetected || installContext.localGateway.probeHealthy) { + lines.push("If OpenClaw is on this same host, keep Studio's upstream at ws://localhost:18789."); + } + if (installContext.tailscale.loggedIn && installContext.tailscale.dnsName) { + lines.push( + `Recommended: tailscale serve --yes --bg --https 443 http://127.0.0.1:${port}` + ); + lines.push(`Then open: https://${installContext.tailscale.dnsName}`); + } else { + lines.push("Recommended: install/login to Tailscale, or keep Studio on loopback and use SSH tunneling."); + } + lines.push(`SSH tunnel fallback: ssh -L ${port}:127.0.0.1:${port} ${sshTarget}`); + return lines; + } + + if (installContext.studioHost.publicHosts.length > 0) { + lines.push(`Studio is exposed on ${installContext.studioHost.publicHosts.join(", ")}.`); + if (installContext.studioHost.studioAccessTokenConfigured) { + lines.push("Open /?access_token=... once from each new browser to set the Studio access cookie."); + } + if (installContext.localGateway.defaultsDetected || installContext.localGateway.probeHealthy) { + lines.push("If OpenClaw is on this same host, keep Studio's upstream at ws://localhost:18789."); + } + return lines; + } + + return lines; +} + +module.exports = { + detectInstallContext, + buildStartupGuidance, +}; diff --git a/server/studio-settings.js b/server/studio-settings.js index 698e87c..7aef29d 100644 --- a/server/studio-settings.js +++ b/server/studio-settings.js @@ -91,4 +91,5 @@ const loadUpstreamGatewaySettings = (env = process.env) => { module.exports = { resolveStudioSettingsPath, loadUpstreamGatewaySettings, + readOpenclawGatewayDefaults, }; diff --git a/src/app/api/runtime/disconnect/route.ts b/src/app/api/runtime/disconnect/route.ts new file mode 100644 index 0000000..8a2e32b --- /dev/null +++ b/src/app/api/runtime/disconnect/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; + +import { deriveRuntimeFreshness } from "@/lib/controlplane/degraded-read"; +import { peekControlPlaneRuntime } from "@/lib/controlplane/runtime"; + +export const runtime = "nodejs"; + +export async function POST() { + try { + const controlPlane = peekControlPlaneRuntime(); + if (!controlPlane) { + const summary = { + status: "stopped" as const, + reason: null, + asOf: null, + outboxHead: 0, + }; + return NextResponse.json({ + enabled: true, + summary, + freshness: deriveRuntimeFreshness(summary, null), + }); + } + + await controlPlane.disconnect(); + const summary = controlPlane.snapshot(); + return NextResponse.json({ + enabled: true, + summary, + freshness: deriveRuntimeFreshness(summary, null), + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to disconnect Studio runtime."; + return NextResponse.json({ enabled: true, error: message }, { status: 500 }); + } +} diff --git a/src/app/api/studio/route.ts b/src/app/api/studio/route.ts index 7609f7a..02bbc63 100644 --- a/src/app/api/studio/route.ts +++ b/src/app/api/studio/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { type StudioSettingsPatch } from "@/lib/studio/settings"; +import { defaultStudioInstallContext } from "@/lib/studio/install-context"; import { isStudioDomainApiModeEnabled, peekControlPlaneRuntime, @@ -12,6 +13,7 @@ import { redactLocalGatewayDefaultsSecrets, redactStudioSettingsSecrets, } from "@/lib/studio/settings-store"; +import { detectInstallContext } from "../../../../server/install-context"; export const runtime = "nodejs"; @@ -92,12 +94,25 @@ const reconnectRuntimeForGatewaySettingsChange = async ( } }; -const buildSettingsResponseBody = (metadata?: RuntimeReconnectMetadata | null) => { +const buildSettingsResponseBody = async (metadata?: RuntimeReconnectMetadata | null) => { const settings = loadStudioSettings(); const localGatewayDefaults = loadLocalGatewayDefaults(); + let installContext = defaultStudioInstallContext(); + try { + installContext = await detectInstallContext(process.env); + } catch (error) { + console.error("Failed to detect Studio install context.", error); + } return { settings: redactStudioSettingsSecrets(settings), localGatewayDefaults: redactLocalGatewayDefaultsSecrets(localGatewayDefaults), + localGatewayDefaultsMeta: { + hasToken: Boolean(localGatewayDefaults?.token?.trim()), + }, + gatewayMeta: { + hasStoredToken: Boolean(settings.gateway?.token?.trim()), + }, + installContext, domainApiModeEnabled: isStudioDomainApiModeEnabled(), ...(metadata ? { runtimeReconnect: metadata } : {}), }; @@ -105,7 +120,7 @@ const buildSettingsResponseBody = (metadata?: RuntimeReconnectMetadata | null) = export async function GET() { try { - return NextResponse.json(buildSettingsResponseBody()); + return NextResponse.json(await buildSettingsResponseBody()); } catch (err) { const message = err instanceof Error ? err.message : "Failed to load studio settings."; console.error(message); @@ -125,7 +140,7 @@ export async function PUT(request: Request) { previousSettings, nextSettings ); - return NextResponse.json(buildSettingsResponseBody(runtimeReconnect)); + return NextResponse.json(await buildSettingsResponseBody(runtimeReconnect)); } catch (err) { const message = err instanceof Error ? err.message : "Failed to save studio settings."; console.error(message); diff --git a/src/app/api/studio/test-connection/route.ts b/src/app/api/studio/test-connection/route.ts new file mode 100644 index 0000000..9390e5a --- /dev/null +++ b/src/app/api/studio/test-connection/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; + +import { OpenClawGatewayAdapter } from "@/lib/controlplane/openclaw-adapter"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; + +export const runtime = "nodejs"; + +type TestConnectionRequestBody = { + gateway?: { + url?: unknown; + token?: unknown; + } | null; + useStoredToken?: unknown; +}; + +const readString = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); + +const resolveStoredToken = (): string => { + return readString(loadStudioSettings().gateway?.token); +}; + +export async function POST(request: Request) { + let adapter: OpenClawGatewayAdapter | null = null; + try { + const body = (await request.json()) as TestConnectionRequestBody; + const url = readString(body?.gateway?.url); + if (!url) { + return NextResponse.json({ ok: false, error: "Gateway URL is required." }, { status: 400 }); + } + + const tokenInput = readString(body?.gateway?.token); + const useStoredToken = body?.useStoredToken !== false; + const token = tokenInput || (useStoredToken ? resolveStoredToken() : ""); + if (!token) { + return NextResponse.json( + { + ok: false, + error: "Gateway token is required. Enter one or keep the stored token.", + }, + { status: 400 } + ); + } + + adapter = new OpenClawGatewayAdapter({ + loadSettings: () => ({ url, token }), + }); + await adapter.start(); + return NextResponse.json({ + ok: true, + checkedAt: new Date().toISOString(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Connection test failed."; + return NextResponse.json( + { + ok: false, + error: message, + checkedAt: new Date().toISOString(), + }, + { status: 200 } + ); + } finally { + if (adapter) { + try { + await adapter.stop(); + } catch {} + } + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 95e83f1..1cf00bb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -17,7 +17,7 @@ import { isHeartbeatPrompt, } from "@/lib/text/message-extract"; import { useStudioGatewaySettings } from "@/lib/studio/useStudioGatewaySettings"; -import type { GatewayStatus } from "@/lib/gateway/gateway-status"; +import { isGatewayConnected, type GatewayStatus } from "@/lib/gateway/gateway-status"; import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts"; import { type GatewayModelChoice, @@ -218,19 +218,35 @@ const AgentStudioPage = () => { client, status, gatewayUrl, + draftGatewayUrl, token, localGatewayDefaults, + localGatewayDefaultsHasToken, + hasStoredToken, + hasUnsavedChanges, + installContext, + statusReason, error: gatewayError, - connect, + testResult, + saving: gatewaySaving, + testing: gatewayTesting, + saveSettings, + testConnection, disconnect, useLocalGatewayDefaults, setGatewayUrl, setToken, + applyRuntimeStatusEvent, } = useStudioGatewaySettings(settingsCoordinator); const gatewayStatus: GatewayStatus = status; - const gatewayConnected = gatewayStatus === "connected"; - const coreConnected = true; - const coreStatus: GatewayStatus = "connected"; + const gatewayConnected = isGatewayConnected(gatewayStatus); + const gatewayConnectionStatus: "disconnected" | "connecting" | "connected" = gatewayConnected + ? "connected" + : gatewayStatus === "connecting" || gatewayStatus === "reconnecting" + ? "connecting" + : "disconnected"; + const coreConnected = gatewayConnected; + const coreStatus = gatewayConnectionStatus; const runtimeStreamResumeKey = useMemo(() => { const normalizedGatewayUrl = gatewayUrl.trim(); if (!normalizedGatewayUrl) return null; @@ -379,7 +395,7 @@ const AgentStudioPage = () => { () => (faviconSeed ? buildAvatarDataUrl(faviconSeed) : null), [faviconSeed] ); - const errorMessage = state.error ?? gatewayModelsError; + const errorMessage = state.error ?? gatewayError ?? gatewayModelsError; const runningAgentCount = useMemo( () => agents.filter((agent) => agent.status === "running").length, [agents] @@ -489,7 +505,7 @@ const AgentStudioPage = () => { ); const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({ - status: gatewayStatus, + status: gatewayConnectionStatus, settingsRouteActive, inspectSidebarAgentId, setGatewayConfigSnapshot, @@ -501,7 +517,7 @@ const AgentStudioPage = () => { const settingsMutationController = useAgentSettingsMutationController({ client, runtimeWriteTransport, - status: gatewayStatus, + status: gatewayConnectionStatus, isLocalGateway, agents, hasCreateBlock: Boolean(createAgentBlock), @@ -549,7 +565,7 @@ const AgentStudioPage = () => { queuedBlockedByRunningAgents, activeConfigMutation, } = useConfigMutationQueue({ - status: gatewayStatus, + status: gatewayConnectionStatus, hasRunningAgents, hasRestartBlockInProgress, }); @@ -842,7 +858,7 @@ const AgentStudioPage = () => { } = useChatInteractionController({ client, runtimeWriteTransport, - status: gatewayStatus, + status: gatewayConnectionStatus, agents, dispatch, setError, @@ -882,7 +898,7 @@ const AgentStudioPage = () => { } = useSettingsRouteController({ settingsRouteActive, settingsRouteAgentId, - status: gatewayStatus, + status: gatewayConnectionStatus, agentsLoadedOnce, selectedAgentId: state.selectedAgentId, focusedAgentId: focusedAgent?.agentId ?? null, @@ -936,7 +952,7 @@ const AgentStudioPage = () => { await runCreateAgentMutationLifecycle( { payload, - status: gatewayStatus, + status: gatewayConnectionStatus, hasCreateBlock: Boolean(createAgentBlock), hasRenameBlock: hasRenameMutationBlock, hasDeleteBlock: hasDeleteMutationBlock, @@ -1037,7 +1053,7 @@ const AgentStudioPage = () => { refreshGatewayConfigSnapshot, runtimeWriteTransport, setError, - gatewayStatus, + gatewayConnectionStatus, ] ); @@ -1270,12 +1286,15 @@ const AgentStudioPage = () => { ingestDomainOutboxEntries, ]); + const gatewayConnecting = gatewayStatus === "connecting" || gatewayStatus === "reconnecting"; + useRuntimeEventStream({ onGatewayEvent: (event) => { runtimeEventHandlerRef.current?.handleEvent(event); domainEventIngressRef.current(event); }, - onRuntimeStatus: () => { + onRuntimeStatus: (event) => { + applyRuntimeStatusEvent(event); void loadSummarySnapshot(); }, resumeKey: runtimeStreamResumeKey ?? undefined, @@ -1338,10 +1357,10 @@ const AgentStudioPage = () => { : null; useEffect(() => { - if (status === "connecting") { + if (gatewayStatus === "connecting" || gatewayStatus === "reconnecting") { setDidAttemptGatewayConnect(true); } - }, [status]); + }, [gatewayStatus]); useEffect(() => { if (gatewayError) { @@ -1349,7 +1368,7 @@ const AgentStudioPage = () => { } }, [gatewayError]); - if (!agentsLoadedOnce && !coreConnected && (!didAttemptGatewayConnect || status === "connecting")) { + if (!agentsLoadedOnce && !coreConnected && (!didAttemptGatewayConnect || gatewayConnecting)) { return (
@@ -1358,7 +1377,7 @@ const AgentStudioPage = () => { OpenClaw Studio
- {status === "connecting" ? "Connecting to gateway…" : "Booting Studio…"} + {gatewayConnecting ? "Connecting to gateway…" : "Booting Studio…"}
@@ -1366,7 +1385,7 @@ const AgentStudioPage = () => { ); } - if (!coreConnected && status === "disconnected" && !agentsLoadedOnce && didAttemptGatewayConnect) { + if (!coreConnected && !agentsLoadedOnce && didAttemptGatewayConnect) { return (
@@ -1387,15 +1406,26 @@ const AgentStudioPage = () => {
) : null} void connect()} + onSaveSettings={() => void saveSettings()} + onTestConnection={() => void testConnection()} + onDisconnect={() => void disconnect()} />
@@ -1437,14 +1467,23 @@ const AgentStudioPage = () => {
void connect()} - onDisconnect={disconnect} + onSaveSettings={() => void saveSettings()} + onTestConnection={() => void testConnection()} + onDisconnect={() => void disconnect()} onClose={() => setShowConnectionPanel(false)} />
diff --git a/src/features/agents/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx index 8f6af22..d2a95c6 100644 --- a/src/features/agents/components/ConnectionPanel.tsx +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -3,30 +3,57 @@ import { X } from "lucide-react"; import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics"; type ConnectionPanelProps = { - gatewayUrl: string; + savedGatewayUrl: string; + draftGatewayUrl: string; token: string; + hasStoredToken: boolean; + localGatewayDefaultsHasToken: boolean; + hasUnsavedChanges: boolean; status: GatewayStatus; + statusReason: string | null; error: string | null; + testResult: + | { + kind: "success" | "error"; + message: string; + } + | null; + saving: boolean; + testing: boolean; onGatewayUrlChange: (value: string) => void; onTokenChange: (value: string) => void; - onConnect: () => void; + onSaveSettings: () => void; + onTestConnection: () => void; onDisconnect: () => void; onClose?: () => void; }; export const ConnectionPanel = ({ - gatewayUrl, + savedGatewayUrl, + draftGatewayUrl, token, + hasStoredToken, + localGatewayDefaultsHasToken, + hasUnsavedChanges, status, + statusReason, error, + testResult, + saving, + testing, onGatewayUrlChange, onTokenChange, - onConnect, + onSaveSettings, + onTestConnection, onDisconnect, onClose, }: ConnectionPanelProps) => { - const isConnected = status === "connected"; - const isConnecting = status === "connecting"; + const actionBusy = saving || testing; + const tokenHelper = hasStoredToken + ? "Stored token available on this Studio host. Leave blank to keep it." + : localGatewayDefaultsHasToken + ? "A local OpenClaw token is available on this host. Leave blank to use it." + : "Enter the token Studio should use for this upstream."; return (
@@ -41,11 +68,29 @@ export const ConnectionPanel = ({ + + {status === "connected" ? ( + + ) : null}
{onClose ? (
+

{tokenHelper}

+ {hasUnsavedChanges ? ( +

+ Unsaved changes +

+ ) : ( +

+ Saved upstream: {savedGatewayUrl || "not configured"} +

+ )} + {statusReason ?

{statusReason}

: null} + {testResult ? ( +

+ {testResult.message} +

+ ) : null} {error ? (

{error} diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx index 593c358..7fd382a 100644 --- a/src/features/agents/components/GatewayConnectScreen.tsx +++ b/src/features/agents/components/GatewayConnectScreen.tsx @@ -1,19 +1,43 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react"; import type { GatewayStatus } from "@/lib/gateway/gateway-status"; -import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; +import { + isStudioLikelyRemote, + resolveDefaultSetupScenario, + resolveGatewayConnectionWarnings, + type StudioConnectionWarning, + type StudioInstallContext, + type StudioSetupScenario, +} from "@/lib/studio/install-context"; import type { StudioGatewaySettings } from "@/lib/studio/settings"; +import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics"; type GatewayConnectScreenProps = { - gatewayUrl: string; + savedGatewayUrl: string; + draftGatewayUrl: string; token: string; localGatewayDefaults: StudioGatewaySettings | null; + localGatewayDefaultsHasToken: boolean; + hasStoredToken: boolean; + hasUnsavedChanges: boolean; + installContext: StudioInstallContext; status: GatewayStatus; + statusReason: string | null; error: string | null; + testResult: + | { + kind: "success" | "error"; + message: string; + } + | null; + saving: boolean; + testing: boolean; onGatewayUrlChange: (value: string) => void; onTokenChange: (value: string) => void; onUseLocalDefaults: () => void; - onConnect: () => void; + onSaveSettings: () => void; + onTestConnection: () => void; + onDisconnect: () => void; }; const resolveLocalGatewayPort = (gatewayUrl: string): number => { @@ -26,52 +50,139 @@ const resolveLocalGatewayPort = (gatewayUrl: string): number => { }; export const GatewayConnectScreen = ({ - gatewayUrl, + savedGatewayUrl, + draftGatewayUrl, token, localGatewayDefaults, + localGatewayDefaultsHasToken, + hasStoredToken, + hasUnsavedChanges, + installContext, status, + statusReason, error, + testResult, + saving, + testing, onGatewayUrlChange, onTokenChange, onUseLocalDefaults, - onConnect, + onSaveSettings, + onTestConnection, + onDisconnect, }: GatewayConnectScreenProps) => { const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle"); const [showToken, setShowToken] = useState(false); - const isLocal = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]); - const localPort = useMemo(() => resolveLocalGatewayPort(gatewayUrl), [gatewayUrl]); + const inferredScenario = useMemo( + () => + resolveDefaultSetupScenario({ + installContext, + gatewayUrl: draftGatewayUrl || savedGatewayUrl, + }), + [draftGatewayUrl, installContext, savedGatewayUrl] + ); + const [selectedScenario, setSelectedScenario] = useState(inferredScenario); + const [scenarioTouched, setScenarioTouched] = useState(false); + useEffect(() => { + if (scenarioTouched) return; + setSelectedScenario(inferredScenario); + }, [inferredScenario, scenarioTouched]); + const localPort = useMemo( + () => resolveLocalGatewayPort(draftGatewayUrl || savedGatewayUrl), + [draftGatewayUrl, savedGatewayUrl] + ); const localGatewayCommand = useMemo( - () => `npx openclaw gateway run --bind loopback --port ${localPort} --verbose`, + () => `openclaw gateway --port ${localPort}`, [localPort] ); - const localGatewayCommandPnpm = useMemo( - () => `pnpm openclaw gateway run --bind loopback --port ${localPort} --verbose`, + const gatewayServeCommand = useMemo( + () => `tailscale serve --yes --bg --https 443 http://127.0.0.1:${localPort}`, [localPort] ); + const studioServeCommand = "tailscale serve --yes --bg --https 443 http://127.0.0.1:3000"; + const studioOpenUrl = installContext.tailscale.loggedIn && installContext.tailscale.dnsName + ? `https://${installContext.tailscale.dnsName}` + : "https://.ts.net"; + const studioSshTarget = + installContext.tailscale.dnsName || + installContext.studioHost.publicHosts[0] || + ""; + const studioTunnelCommand = `ssh -L 3000:127.0.0.1:3000 ${studioSshTarget}`; + const gatewayTunnelCommand = `ssh -L ${localPort}:127.0.0.1:${localPort} user@`; + const warnings = useMemo( + () => + resolveGatewayConnectionWarnings({ + gatewayUrl: draftGatewayUrl, + installContext, + scenario: selectedScenario, + hasStoredToken, + hasLocalGatewayToken: localGatewayDefaultsHasToken, + }), + [ + draftGatewayUrl, + hasStoredToken, + installContext, + localGatewayDefaultsHasToken, + selectedScenario, + ] + ); const statusCopy = useMemo(() => { - if (status === "connecting" && isLocal) { - return `Local gateway detected on port ${localPort}. Connecting…`; + if (status === "connected") { + return "Studio is connected to OpenClaw."; } if (status === "connecting") { - return "Connecting to remote gateway…"; + return "Studio is connecting to OpenClaw…"; } - if (isLocal) { - return "No local gateway found."; + if (status === "reconnecting") { + return "Studio lost the gateway connection and is retrying…"; } - return "Not connected to a gateway."; - }, [isLocal, localPort, status]); - const connectDisabled = status === "connecting"; - const connectLabel = connectDisabled ? "Connecting…" : "Connect"; + if (status === "error") { + return "Studio could not connect to the saved gateway settings."; + } + return "Choose how this Studio should reach OpenClaw."; + }, [status]); + const statusSubcopy = useMemo(() => { + const normalizedReason = statusReason?.trim() ?? ""; + if (normalizedReason === "gateway_closed") { + return "The gateway socket closed. Studio will keep retrying until it reconnects."; + } + if (normalizedReason) return normalizedReason; + if (selectedScenario === "same-cloud-host") { + return "Separate the two links: how you open Studio, and how Studio reaches OpenClaw."; + } + if (selectedScenario === "remote-gateway") { + return "On your laptop, Studio stays local. Only the upstream gateway needs to be remote."; + } + return "When Studio and OpenClaw share a host, the upstream should usually stay on localhost."; + }, [selectedScenario, statusReason]); + const actionBusy = saving || testing; + const saveLabel = saving ? "Saving…" : "Save settings"; + const testLabel = testing ? "Testing…" : "Test connection"; const statusDotClass = status === "connected" ? "ui-dot-status-connected" - : status === "connecting" + : status === "connecting" || status === "reconnecting" ? "ui-dot-status-connecting" : "ui-dot-status-disconnected"; + const tokenHelper = hasStoredToken + ? "A token is already stored on this Studio host. Leave this blank to keep it." + : localGatewayDefaultsHasToken + ? "A local OpenClaw token is available on this host. Leave this blank to use it." + : "Enter the gateway token Studio should use."; + const remoteStudio = isStudioLikelyRemote(installContext); - const copyLocalCommand = async () => { + const setScenario = (value: StudioSetupScenario) => { + setScenarioTouched(true); + setSelectedScenario(value); + }; + + const applyLoopbackUrl = () => { + onGatewayUrlChange(`ws://localhost:${localPort}`); + }; + + const copyCommand = async (value: string) => { try { - await navigator.clipboard.writeText(localGatewayCommand); + await navigator.clipboard.writeText(value); setCopyStatus("copied"); window.setTimeout(() => setCopyStatus("idle"), 1200); } catch { @@ -80,157 +191,335 @@ export const GatewayConnectScreen = ({ } }; - const commandField = ( + const commandField = (params: { + value: string; + label: string; + helper?: string; + }) => (

+
+

+ {params.label} +

+ +
- {localGatewayCommand} + {params.value}
- {copyStatus === "copied" ? ( -

Copied

- ) : copyStatus === "failed" ? ( -

Could not copy command.

- ) : ( -

- In a source checkout, use {localGatewayCommandPnpm}. -

- )} + {params.helper ? ( +

{params.helper}

+ ) : null}
); - const remoteForm = ( -
- + const scenarioButtonClass = (scenario: StudioSetupScenario): string => { + return `ui-card rounded-xl px-4 py-3 text-left transition ${ + selectedScenario === scenario + ? "ui-card-selected border-primary/60" + : "border border-border/70 hover:border-border" + }`; + }; -
-

Using Tailscale?

-

- URL: wss://<your-tailnet-host> -

+ const connectionForm = ( +
+
+
+

+ Studio to OpenClaw +

+

+ Save a gateway URL and token for this Studio host. +

+
+ + {resolveGatewayStatusLabel(status)} +
-
); - return (
-
- {status === "connecting" ? ( +
+ {status === "connecting" || status === "reconnecting" ? ( ) : ( - + )} -

{statusCopy}

+
+

{statusCopy}

+

{statusSubcopy}

+
-
-
-

- Remote gateway (recommended) -

-

Default: enter your URL and token to connect.

-
- {remoteForm} -
- -
-
+
+ + + +
+ +
+
+

+ How you open Studio +

+ {selectedScenario === "same-computer" || selectedScenario === "remote-gateway" ? ( +
+

+ Open http://localhost:3000 on this computer. +

+

+ Only the OpenClaw upstream changes in this setup. Studio itself stays local. +

+
+ ) : ( +
+

+ Studio is on a remote host. http://localhost:3000 only opens on that machine. +

+ {commandField({ + value: studioServeCommand, + label: "Recommended: Tailscale Serve", + helper: `Then open ${studioOpenUrl} from your laptop or phone.`, + })} + {commandField({ + value: studioTunnelCommand, + label: "Fallback: SSH tunnel", + helper: "Use this if Tailscale is not available yet.", + })} + {remoteStudio && installContext.tailscale.loggedIn === false ? ( +
+ Tailscale was not detected on this Studio host. Beginners will usually have a much easier time with Tailscale Serve than with public binds. +
+ ) : null} + {installContext.studioHost.publicHosts.length > 0 ? ( +
+ This Studio is already bound beyond loopback. If you keep it public, STUDIO_ACCESS_TOKEN is required and each browser must open /?access_token=... once. +
+ ) : null} +
+ )}
-
- {commandField} - {localGatewayDefaults ? ( -
-
-

- Use token from ~/.openclaw/openclaw.json. -

-

- {localGatewayDefaults.url} -

+ +
+

+ How Studio reaches OpenClaw +

+ {selectedScenario === "remote-gateway" ? ( +
+

+ Recommended: keep the remote gateway on loopback and expose it with Tailscale Serve. +

+ {commandField({ + value: gatewayServeCommand, + label: "On the gateway host", + helper: "In Studio, use wss://.ts.net plus your gateway token.", + })} + {commandField({ + value: gatewayTunnelCommand, + label: "Fallback: SSH tunnel", + helper: `Then point Studio to ws://localhost:${localPort}.`, + })} +
- ) : null} + ) : ( +
+

+ Keep the upstream local to the Studio host:{" "} + {`ws://localhost:${localPort}`}. +

+ {commandField({ + value: localGatewayCommand, + label: "Start OpenClaw on this host", + helper: "Use the same machine for both processes, even if that machine is a cloud VM.", + })} +
+ + {localGatewayDefaults ? ( + + ) : null} +
+ {localGatewayDefaults ? ( +
+ Local OpenClaw settings were detected at ~/.openclaw/openclaw.json. Studio can reuse that local URL and token. +
+ ) : null} +
+ )}
+ + {warnings.length > 0 ? ( +
+ {warnings.map((warning) => ( +
+ {warning.message} +
+ ))} +
+ ) : null} + + {connectionForm} + + {testResult ? ( +
+ {testResult.message} +
+ ) : null} + + {error ?

{error}

: null}
); }; diff --git a/src/features/agents/components/HeaderBar.tsx b/src/features/agents/components/HeaderBar.tsx index d49fb87..95c4de8 100644 --- a/src/features/agents/components/HeaderBar.tsx +++ b/src/features/agents/components/HeaderBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { ThemeToggle } from "@/components/theme-toggle"; import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import { Plug } from "lucide-react"; -import { resolveGatewayStatusBadgeClass } from "./colorSemantics"; +import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics"; type HeaderBarProps = { status: GatewayStatus; @@ -44,15 +44,13 @@ export const HeaderBar = ({ OpenClaw Studio

- {status === "connecting" ? ( - - Connecting - - ) : null} + + {resolveGatewayStatusLabel(status)} + {showConnectionSettings ? (
diff --git a/src/features/agents/components/colorSemantics.ts b/src/features/agents/components/colorSemantics.ts index fbb61eb..c4da81f 100644 --- a/src/features/agents/components/colorSemantics.ts +++ b/src/features/agents/components/colorSemantics.ts @@ -17,12 +17,16 @@ export const GATEWAY_STATUS_LABEL: Record = { disconnected: "Disconnected", connecting: "Connecting", connected: "Connected", + reconnecting: "Reconnecting", + error: "Error", }; export const GATEWAY_STATUS_BADGE_CLASS: Record = { disconnected: "ui-badge-status-disconnected", connecting: "ui-badge-status-connecting", connected: "ui-badge-status-connected", + reconnecting: "ui-badge-status-connecting", + error: "ui-badge-status-error", }; export const NEEDS_APPROVAL_BADGE_CLASS = "ui-badge-approval"; diff --git a/src/features/agents/operations/agentSettingsMutationWorkflow.ts b/src/features/agents/operations/agentSettingsMutationWorkflow.ts index 851e7cc..7772469 100644 --- a/src/features/agents/operations/agentSettingsMutationWorkflow.ts +++ b/src/features/agents/operations/agentSettingsMutationWorkflow.ts @@ -2,6 +2,7 @@ import { resolveMutationStartGuard, type MutationStartGuardResult, } from "@/features/agents/operations/mutationLifecycleWorkflow"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; const RESERVED_MAIN_AGENT_ID = "main"; @@ -17,7 +18,7 @@ type AgentSettingsMutationRequest = | { kind: CronActionKind; agentId: string; jobId: string }; export type AgentSettingsMutationContext = { - status: "connected" | "connecting" | "disconnected"; + status: GatewayStatus; hasCreateBlock: boolean; hasRenameBlock: boolean; hasDeleteBlock: boolean; @@ -75,7 +76,12 @@ export const planAgentSettingsMutation = ( if (isGuardedAction(request.kind)) { const startGuard = resolveMutationStartGuard({ - status: context.status, + status: + context.status === "connected" + ? "connected" + : context.status === "connecting" || context.status === "reconnecting" + ? "connecting" + : "disconnected", hasCreateBlock: context.hasCreateBlock, hasRenameBlock: context.hasRenameBlock, hasDeleteBlock: context.hasDeleteBlock, diff --git a/src/features/agents/operations/gatewayRestartPolicy.ts b/src/features/agents/operations/gatewayRestartPolicy.ts index 5dc80d4..63876cb 100644 --- a/src/features/agents/operations/gatewayRestartPolicy.ts +++ b/src/features/agents/operations/gatewayRestartPolicy.ts @@ -1,4 +1,6 @@ -export type GatewayStatus = "disconnected" | "connecting" | "connected"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; + +export type { GatewayStatus }; type RestartObservation = { sawDisconnect: boolean; diff --git a/src/features/agents/state/useRuntimeEventStream.ts b/src/features/agents/state/useRuntimeEventStream.ts index 684599f..8fdbddf 100644 --- a/src/features/agents/state/useRuntimeEventStream.ts +++ b/src/features/agents/state/useRuntimeEventStream.ts @@ -18,6 +18,12 @@ export type RuntimeEventStreamSource = { type RuntimeEventStreamFactory = (url: string) => RuntimeEventStreamSource; +export type RuntimeStatusStreamEvent = { + status?: unknown; + reason?: unknown; + asOf?: unknown; +}; + const createBrowserRuntimeEventStreamSource: RuntimeEventStreamFactory = (url) => new EventSource(url) as unknown as RuntimeEventStreamSource; @@ -70,7 +76,7 @@ const withLastEventId = (url: string, lastEventId: number | null): string => { export function useRuntimeEventStream(params: { onGatewayEvent: (event: EventFrame) => void; - onRuntimeStatus: () => void; + onRuntimeStatus: (event: RuntimeStatusStreamEvent | null) => void; url?: string; resumeKey?: string; createSource?: RuntimeEventStreamFactory; @@ -128,7 +134,17 @@ export function useRuntimeEventStream(params: { source.addEventListener("runtime.status", (raw) => { recordLastEventId(raw); - onRuntimeStatusRef.current(); + const data = toText(raw?.data); + if (!data) { + onRuntimeStatusRef.current(null); + return; + } + try { + const parsed = JSON.parse(data) as RuntimeStatusStreamEvent; + onRuntimeStatusRef.current(parsed); + } catch { + onRuntimeStatusRef.current(null); + } }); source.onerror = () => {}; diff --git a/src/lib/gateway/gateway-status.ts b/src/lib/gateway/gateway-status.ts index 10fcbed..53aca6d 100644 --- a/src/lib/gateway/gateway-status.ts +++ b/src/lib/gateway/gateway-status.ts @@ -1,4 +1,14 @@ -export type GatewayStatus = "disconnected" | "connecting" | "connected"; +export type GatewayStatus = + | "disconnected" + | "connecting" + | "connected" + | "reconnecting" + | "error"; + +export const isGatewayConnected = (status: GatewayStatus): boolean => status === "connected"; + +export const isGatewayTransitioning = (status: GatewayStatus): boolean => + status === "connecting" || status === "reconnecting"; export type GatewayGapInfo = { expected: number; diff --git a/src/lib/studio/coordinator.ts b/src/lib/studio/coordinator.ts index c72e9d4..a5d0da9 100644 --- a/src/lib/studio/coordinator.ts +++ b/src/lib/studio/coordinator.ts @@ -5,11 +5,26 @@ import type { StudioSettings, StudioSettingsPatch, } from "@/lib/studio/settings"; +import type { StudioInstallContext } from "@/lib/studio/install-context"; export type StudioSettingsResponse = { settings: StudioSettings; localGatewayDefaults?: StudioGatewaySettings | null; + localGatewayDefaultsMeta?: { + hasToken: boolean; + }; + gatewayMeta?: { + hasStoredToken: boolean; + }; + installContext?: StudioInstallContext; domainApiModeEnabled?: boolean; + runtimeReconnect?: { + attempted: boolean; + restarted: boolean; + reason?: string; + previousStatus?: string; + error?: string; + } | null; }; type FocusedPatch = Record | null>; diff --git a/src/lib/studio/install-context.ts b/src/lib/studio/install-context.ts new file mode 100644 index 0000000..da87db9 --- /dev/null +++ b/src/lib/studio/install-context.ts @@ -0,0 +1,209 @@ +import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; + +export type StudioInstallContext = { + studioHost: { + hostname: string | null; + configuredHosts: string[]; + publicHosts: string[]; + loopbackOnly: boolean; + remoteShell: boolean; + studioAccessTokenConfigured: boolean; + }; + localGateway: { + defaultsDetected: boolean; + url: string | null; + hasToken: boolean; + cliAvailable: boolean; + statusProbeOk: boolean; + sessionsProbeOk: boolean; + probeHealthy: boolean; + issues: string[]; + }; + tailscale: { + installed: boolean; + loggedIn: boolean; + dnsName: string | null; + }; +}; + +export type StudioSetupScenario = + | "same-computer" + | "remote-gateway" + | "same-cloud-host"; + +export type StudioConnectionWarningTone = "info" | "warn"; + +export type StudioConnectionWarning = { + id: string; + tone: StudioConnectionWarningTone; + message: string; +}; + +export const defaultStudioInstallContext = (): StudioInstallContext => ({ + studioHost: { + hostname: null, + configuredHosts: [], + publicHosts: [], + loopbackOnly: true, + remoteShell: false, + studioAccessTokenConfigured: false, + }, + localGateway: { + defaultsDetected: false, + url: null, + hasToken: false, + cliAvailable: false, + statusProbeOk: false, + sessionsProbeOk: false, + probeHealthy: false, + issues: [], + }, + tailscale: { + installed: false, + loggedIn: false, + dnsName: null, + }, +}); + +const isPrivateIpv4 = (hostname: string): boolean => { + const match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!match) return false; + const octets = match.slice(1).map((part) => Number(part)); + if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) { + return false; + } + const [first, second] = octets; + if (first === 10) return true; + if (first === 127) return true; + if (first === 192 && second === 168) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 100 && second >= 64 && second <= 127) return true; + if (first === 169 && second === 254) return true; + return false; +}; + +const isPrivateIpv6 = (hostname: string): boolean => { + const normalized = hostname.trim().toLowerCase(); + if (!normalized) return false; + if (normalized === "::1" || normalized === "[::1]") return true; + return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:"); +}; + +const isPrivateHost = (hostname: string): boolean => { + const normalized = hostname.trim().toLowerCase(); + if (!normalized || normalized === "localhost") return false; + return isPrivateIpv4(normalized) || isPrivateIpv6(normalized); +}; + +const isTailscaleHostname = (hostname: string): boolean => { + return hostname.trim().toLowerCase().endsWith(".ts.net"); +}; + +const normalizeUrl = (value: string): string => value.trim(); + +const resolveParsedUrl = (gatewayUrl: string): URL | null => { + const trimmed = normalizeUrl(gatewayUrl); + if (!trimmed) return null; + try { + return new URL(trimmed); + } catch { + return null; + } +}; + +export const isStudioLikelyRemote = (installContext: StudioInstallContext | null): boolean => { + if (!installContext) return false; + return installContext.studioHost.remoteShell || installContext.studioHost.publicHosts.length > 0; +}; + +export const resolveDefaultSetupScenario = (params: { + installContext: StudioInstallContext | null; + gatewayUrl: string; +}): StudioSetupScenario => { + const trimmedGatewayUrl = normalizeUrl(params.gatewayUrl); + if (trimmedGatewayUrl && !isLocalGatewayUrl(trimmedGatewayUrl)) { + return "remote-gateway"; + } + if (isStudioLikelyRemote(params.installContext)) { + return "same-cloud-host"; + } + return "same-computer"; +}; + +export const resolveGatewayConnectionWarnings = (params: { + gatewayUrl: string; + installContext: StudioInstallContext | null; + scenario: StudioSetupScenario; + hasStoredToken: boolean; + hasLocalGatewayToken: boolean; +}): StudioConnectionWarning[] => { + const warnings: StudioConnectionWarning[] = []; + const trimmedGatewayUrl = normalizeUrl(params.gatewayUrl); + if (!trimmedGatewayUrl) { + return warnings; + } + + const parsed = resolveParsedUrl(trimmedGatewayUrl); + if (!parsed) { + warnings.push({ + id: "invalid-url", + tone: "warn", + message: "Enter a full gateway URL such as ws://localhost:18789 or wss://your-host.ts.net.", + }); + return warnings; + } + + const hostname = parsed.hostname.trim().toLowerCase(); + const localGateway = isLocalGatewayUrl(trimmedGatewayUrl); + const storedTokenAvailable = params.hasStoredToken || params.hasLocalGatewayToken; + + if (isTailscaleHostname(hostname) && parsed.protocol === "ws:") { + warnings.push({ + id: "tailscale-ws", + tone: "warn", + message: "Use wss:// for .ts.net gateway URLs. Tailscale Serve exposes HTTPS and secure WebSocket upgrades.", + }); + } + + if (!localGateway && isPrivateHost(hostname)) { + warnings.push({ + id: "private-ip-advanced", + tone: "warn", + message: + "Direct private-IP WebSocket URLs are an advanced path. For beginners, prefer Tailscale Serve or keep the gateway on loopback and use an SSH tunnel.", + }); + } + + if ( + params.scenario === "same-cloud-host" && + localGateway && + isStudioLikelyRemote(params.installContext) + ) { + warnings.push({ + id: "remote-localhost", + tone: "info", + message: + "localhost points to the cloud machine running Studio. This is the right upstream when Studio and OpenClaw share that host.", + }); + } + + if (params.scenario === "same-cloud-host" && !localGateway) { + warnings.push({ + id: "prefer-localhost-same-host", + tone: "info", + message: + "If Studio and OpenClaw are on the same cloud machine, prefer ws://localhost:18789 for the upstream and solve browser access to Studio separately.", + }); + } + + if (isTailscaleHostname(hostname) && !storedTokenAvailable) { + warnings.push({ + id: "tailscale-still-needs-token", + tone: "info", + message: + "Studio still needs a gateway token for upstream connections, even when the OpenClaw Control UI can use Tailscale identity headers.", + }); + } + + return warnings; +}; diff --git a/src/lib/studio/useStudioGatewaySettings.ts b/src/lib/studio/useStudioGatewaySettings.ts index 53bf132..ede1be0 100644 --- a/src/lib/studio/useStudioGatewaySettings.ts +++ b/src/lib/studio/useStudioGatewaySettings.ts @@ -5,10 +5,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import { fetchJson } from "@/lib/http"; -import type { - StudioGatewaySettings, - StudioSettings, - StudioSettingsPatch, +import { + defaultStudioInstallContext, + type StudioInstallContext, +} from "@/lib/studio/install-context"; +import { + defaultStudioSettings, + type StudioGatewaySettings, + type StudioSettings, + type StudioSettingsPatch, } from "@/lib/studio/settings"; import type { StudioSettingsResponse } from "@/lib/studio/coordinator"; @@ -40,6 +45,7 @@ const formatGatewayError = (error: unknown): string => { type RuntimeSummaryEnvelope = { summary?: { status?: unknown; + reason?: unknown; } | null; error?: unknown; }; @@ -47,52 +53,187 @@ type RuntimeSummaryEnvelope = { const mapRuntimeStatusToGatewayStatus = (value: unknown): GatewayStatus => { const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; if (normalized === "connected") return "connected"; - if (normalized === "connecting" || normalized === "reconnecting") return "connecting"; + if (normalized === "connecting") return "connecting"; + if (normalized === "reconnecting") return "reconnecting"; + if (normalized === "error") return "error"; return "disconnected"; }; +type TestConnectionResponse = { + ok?: unknown; + error?: unknown; +}; + type StudioSettingsCoordinatorLike = { loadSettings: () => Promise; loadSettingsEnvelope?: () => Promise; - schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void; flushPending: () => Promise; }; type StudioGatewaySettingsState = { client: GatewayClient; status: GatewayStatus; + statusReason: string | null; gatewayUrl: string; + draftGatewayUrl: string; token: string; localGatewayDefaults: StudioGatewaySettings | null; + localGatewayDefaultsHasToken: boolean; + hasStoredToken: boolean; + hasUnsavedChanges: boolean; + installContext: StudioInstallContext; domainApiModeEnabled: boolean; error: string | null; - connect: () => Promise; - disconnect: () => void; + testResult: + | { + kind: "success" | "error"; + message: string; + } + | null; + saving: boolean; + testing: boolean; + saveSettings: () => Promise; + testConnection: () => Promise; + disconnect: () => Promise; useLocalGatewayDefaults: () => void; setGatewayUrl: (value: string) => void; setToken: (value: string) => void; + applyRuntimeStatusEvent: (event: { status?: unknown; reason?: unknown } | null) => void; clearError: () => void; }; +const readString = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); + +const fetchRuntimeSummaryEnvelope = async (): Promise => { + const response = await fetch("/api/runtime/summary", { + cache: "no-store", + }); + const text = await response.text(); + let data: RuntimeSummaryEnvelope = {}; + if (text) { + try { + data = JSON.parse(text) as RuntimeSummaryEnvelope; + } catch { + data = {}; + } + } + if (!response.ok && !readString(data.error)) { + throw new Error(`Request failed with status ${response.status}.`); + } + return data; +}; + export const useStudioGatewaySettings = ( settingsCoordinator: StudioSettingsCoordinatorLike ): StudioGatewaySettingsState => { const [gatewayUrl, setGatewayUrlState] = useState(DEFAULT_UPSTREAM_GATEWAY_URL); + const [draftGatewayUrl, setDraftGatewayUrlState] = useState(DEFAULT_UPSTREAM_GATEWAY_URL); const [token, setTokenState] = useState(""); const [localGatewayDefaults, setLocalGatewayDefaults] = useState( null ); + const [localGatewayDefaultsHasToken, setLocalGatewayDefaultsHasToken] = useState(false); + const [hasStoredToken, setHasStoredToken] = useState(false); + const [installContext, setInstallContext] = useState( + defaultStudioInstallContext() + ); const domainApiModeEnabled = true; const [status, setStatus] = useState("disconnected"); - const [error, setError] = useState(null); + const [statusReason, setStatusReason] = useState(null); + const [connectionError, setConnectionError] = useState(null); + const [actionError, setActionError] = useState(null); + const [testResult, setTestResult] = useState<{ + kind: "success" | "error"; + message: string; + } | null>(null); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false); const manualDisconnectRef = useRef(false); const didAutoConnectRef = useRef(false); + const error = actionError ?? connectionError; const clearError = useCallback(() => { - setError(null); + setActionError(null); + setConnectionError(null); + setTestResult(null); }, []); + const applyRuntimeSummary = useCallback((summary: RuntimeSummaryEnvelope) => { + const nextStatus = mapRuntimeStatusToGatewayStatus(summary?.summary?.status); + const nextReason = readString(summary?.summary?.reason); + const nextError = readString(summary?.error); + setStatus(nextStatus); + setStatusReason(nextReason || null); + if (nextStatus === "error") { + setConnectionError(nextReason || nextError || "Gateway connection failed."); + return; + } + if (nextStatus === "disconnected" && nextError) { + setConnectionError(nextError); + return; + } + setConnectionError(null); + }, []); + + const applyRuntimeStatusEvent = useCallback( + (event: { status?: unknown; reason?: unknown } | null) => { + const nextStatus = mapRuntimeStatusToGatewayStatus(event?.status); + const nextReason = readString(event?.reason); + setStatus(nextStatus); + setStatusReason(nextReason || null); + if (nextStatus === "error") { + setConnectionError(nextReason || "Gateway connection failed."); + return; + } + if (nextStatus === "connected") { + setConnectionError(null); + return; + } + if (nextStatus === "connecting" || nextStatus === "reconnecting") { + setConnectionError(null); + return; + } + if (nextStatus === "disconnected" && manualDisconnectRef.current) { + setConnectionError(null); + return; + } + if (nextStatus === "disconnected" && !nextReason) { + setConnectionError(null); + } + }, + [] + ); + + const refreshRuntimeStatus = useCallback(async () => { + const summary = await fetchRuntimeSummaryEnvelope(); + applyRuntimeSummary(summary); + return summary; + }, [applyRuntimeSummary]); + + const applySettingsEnvelope = useCallback( + ( + envelope: StudioSettingsResponse, + options: { + resetDraft: boolean; + } = { resetDraft: true } + ) => { + const settings = envelope.settings ?? null; + const gateway = settings?.gateway ?? null; + const nextUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL; + setGatewayUrlState(nextUrl); + setHasStoredToken(Boolean(envelope.gatewayMeta?.hasStoredToken)); + setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults)); + setLocalGatewayDefaultsHasToken(Boolean(envelope.localGatewayDefaultsMeta?.hasToken)); + setInstallContext(envelope.installContext ?? defaultStudioInstallContext()); + if (options.resetDraft) { + setDraftGatewayUrlState(nextUrl); + setTokenState(""); + } + }, + [] + ); + useEffect(() => { let cancelled = false; const loadSettings = async () => { @@ -100,20 +241,15 @@ export const useStudioGatewaySettings = ( const envelope = typeof settingsCoordinator.loadSettingsEnvelope === "function" ? await settingsCoordinator.loadSettingsEnvelope() - : { settings: await settingsCoordinator.loadSettings(), localGatewayDefaults: null }; - const settings = envelope.settings ?? null; - const gateway = settings?.gateway ?? null; + : { + settings: (await settingsCoordinator.loadSettings()) ?? defaultStudioSettings(), + localGatewayDefaults: null, + }; if (cancelled) return; - - const nextUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL; - const nextToken = typeof gateway?.token === "string" ? gateway.token : ""; - setGatewayUrlState(nextUrl); - setTokenState(nextToken); - setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults)); - + applySettingsEnvelope(envelope); } catch (nextError) { if (!cancelled) { - setError(formatGatewayError(nextError)); + setActionError(formatGatewayError(nextError)); } } finally { if (!cancelled) { @@ -125,42 +261,129 @@ export const useStudioGatewaySettings = ( return () => { cancelled = true; }; - }, [settingsCoordinator]); + }, [applySettingsEnvelope, settingsCoordinator]); - const connect = useCallback(async () => { - const trimmedGatewayUrl = gatewayUrl.trim(); + const saveSettings = useCallback(async () => { + const trimmedGatewayUrl = draftGatewayUrl.trim(); + const trimmedToken = token.trim(); + const canUseExistingToken = hasStoredToken || localGatewayDefaultsHasToken; if (!trimmedGatewayUrl) { - setStatus("disconnected"); - setError("Gateway URL is required."); - return; + setActionError("Gateway URL is required."); + setTestResult(null); + return false; } + if (!trimmedToken && !canUseExistingToken) { + setActionError("Gateway token is required. Enter one or keep the stored token."); + setTestResult(null); + return false; + } + setSaving(true); + setActionError(null); + setTestResult(null); setStatus("connecting"); - setError(null); - manualDisconnectRef.current = false; + setStatusReason(null); + setConnectionError(null); + manualDisconnectRef.current = true; + didAutoConnectRef.current = true; try { await settingsCoordinator.flushPending(); - const summary = await fetchJson("/api/runtime/summary", { - cache: "no-store", + const patch: StudioSettingsPatch = { + gateway: trimmedToken + ? { url: trimmedGatewayUrl, token: trimmedToken } + : { url: trimmedGatewayUrl }, + }; + const envelope = await fetchJson("/api/studio", { + method: "PUT", + keepalive: true, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), }); - const nextStatus = mapRuntimeStatusToGatewayStatus(summary?.summary?.status); - setStatus(nextStatus); - const runtimeError = - typeof summary?.error === "string" ? summary.error.trim() : ""; - if (nextStatus === "connected" || !runtimeError) { - setError(null); - } else { - setError(runtimeError); - } + manualDisconnectRef.current = false; + applySettingsEnvelope(envelope); + await refreshRuntimeStatus(); + return true; } catch (nextError) { - setStatus("disconnected"); - setError(formatGatewayError(nextError)); + manualDisconnectRef.current = false; + const message = formatGatewayError(nextError); + setStatus("error"); + setStatusReason(message); + setActionError(message); + return false; + } finally { + setSaving(false); } - }, [gatewayUrl, settingsCoordinator]); + }, [ + applySettingsEnvelope, + draftGatewayUrl, + hasStoredToken, + localGatewayDefaultsHasToken, + refreshRuntimeStatus, + settingsCoordinator, + token, + ]); - const disconnect = useCallback(() => { + const testConnection = useCallback(async () => { + const trimmedGatewayUrl = draftGatewayUrl.trim(); + if (!trimmedGatewayUrl) { + setActionError("Gateway URL is required."); + setTestResult(null); + return false; + } + setTesting(true); + setActionError(null); + setTestResult(null); + try { + const response = await fetchJson("/api/studio/test-connection", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + gateway: { + url: trimmedGatewayUrl, + token: token.trim(), + }, + useStoredToken: token.trim().length === 0, + }), + }); + if (response.ok !== true) { + const message = readString(response.error) || "Connection test failed."; + setActionError(message); + setTestResult({ kind: "error", message }); + return false; + } + setTestResult({ + kind: "success", + message: "Connection test succeeded. Save settings to use this upstream.", + }); + return true; + } catch (nextError) { + const message = formatGatewayError(nextError); + setActionError(message); + setTestResult({ kind: "error", message }); + return false; + } finally { + setTesting(false); + } + }, [draftGatewayUrl, token]); + + const disconnect = useCallback(async () => { manualDisconnectRef.current = true; + setActionError(null); + setTestResult(null); setStatus("disconnected"); - }, []); + setStatusReason(null); + setConnectionError(null); + try { + const summary = await fetchJson("/api/runtime/disconnect", { + method: "POST", + }); + applyRuntimeSummary(summary); + } catch (nextError) { + const message = formatGatewayError(nextError); + setStatus("error"); + setStatusReason(message); + setActionError(message); + } + }, [applyRuntimeSummary]); useEffect(() => { if (!settingsLoaded) return; @@ -169,73 +392,96 @@ export const useStudioGatewaySettings = ( if (status !== "disconnected") return; if (!gatewayUrl.trim()) return; didAutoConnectRef.current = true; - void connect(); - }, [connect, gatewayUrl, settingsLoaded, status]); + setStatus("connecting"); + setStatusReason(null); + setConnectionError(null); + void refreshRuntimeStatus().catch((nextError) => { + const message = formatGatewayError(nextError); + setStatus("error"); + setStatusReason(message); + setConnectionError(message); + }); + }, [gatewayUrl, refreshRuntimeStatus, settingsLoaded, status]); const setGatewayUrl = useCallback( (value: string) => { - setGatewayUrlState(value); - manualDisconnectRef.current = false; - setStatus("disconnected"); - setError(null); - settingsCoordinator.schedulePatch({ gateway: { url: value, token } }, 350); + setDraftGatewayUrlState(value); + setActionError(null); + setTestResult(null); }, - [settingsCoordinator, token] + [] ); const setToken = useCallback( (value: string) => { setTokenState(value); - manualDisconnectRef.current = false; - setStatus("disconnected"); - setError(null); - settingsCoordinator.schedulePatch({ gateway: { url: gatewayUrl, token: value } }, 350); + setActionError(null); + setTestResult(null); }, - [gatewayUrl, settingsCoordinator] + [] ); const useLocalGatewayDefaults = useCallback(() => { if (!localGatewayDefaults) return; - manualDisconnectRef.current = false; - setGatewayUrlState(localGatewayDefaults.url); - setTokenState(localGatewayDefaults.token ?? ""); - setStatus("disconnected"); - setError(null); - settingsCoordinator.schedulePatch( - { - gateway: { url: localGatewayDefaults.url, token: localGatewayDefaults.token ?? "" }, - }, - 350 - ); - }, [localGatewayDefaults, settingsCoordinator]); + setDraftGatewayUrlState(localGatewayDefaults.url); + setTokenState(""); + setActionError(null); + setTestResult(null); + }, [localGatewayDefaults]); + + const hasUnsavedChanges = useMemo(() => { + return draftGatewayUrl.trim() !== gatewayUrl.trim() || token.trim().length > 0; + }, [draftGatewayUrl, gatewayUrl, token]); return useMemo( () => ({ client: removedGatewayClient, status, + statusReason, gatewayUrl, + draftGatewayUrl, token, localGatewayDefaults, + localGatewayDefaultsHasToken, + hasStoredToken, + hasUnsavedChanges, + installContext, domainApiModeEnabled, error, - connect, + testResult, + saving, + testing, + saveSettings, + testConnection, disconnect, useLocalGatewayDefaults, setGatewayUrl, setToken, + applyRuntimeStatusEvent, clearError, }), [ + applyRuntimeStatusEvent, clearError, - connect, disconnect, + draftGatewayUrl, domainApiModeEnabled, error, gatewayUrl, + hasStoredToken, + hasUnsavedChanges, + installContext, localGatewayDefaults, + localGatewayDefaultsHasToken, + saveSettings, + saving, setGatewayUrl, setToken, status, + statusReason, + testConnection, + testResult, + testing, token, useLocalGatewayDefaults, ] diff --git a/tests/e2e/connection-settings.spec.ts b/tests/e2e/connection-settings.spec.ts index eff1374..c617530 100644 --- a/tests/e2e/connection-settings.spec.ts +++ b/tests/e2e/connection-settings.spec.ts @@ -1,8 +1,9 @@ import { expect, test } from "@playwright/test"; import { stubStudioRoute } from "./helpers/studioRoute"; import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; +import { defaultStudioInstallContext } from "@/lib/studio/install-context"; -test("connection settings persist to the studio settings API", async ({ page }) => { +test("connection settings save to the studio settings API", async ({ page }) => { await stubStudioRoute(page); await stubRuntimeRoutes(page); @@ -14,7 +15,7 @@ test("connection settings persist to the studio settings API", async ({ page }) await page.getByLabel(/Upstream (gateway )?URL/i).fill("ws://gateway.example:18789"); await page.getByLabel("Upstream token").fill("token-123"); - const request = await page.waitForRequest((req) => { + const requestPromise = page.waitForRequest((req) => { if (!req.url().includes("/api/studio") || req.method() !== "PUT") { return false; } @@ -22,12 +23,74 @@ test("connection settings persist to the studio settings API", async ({ page }) const gateway = (payload.gateway ?? {}) as { url?: string; token?: string }; return gateway.url === "ws://gateway.example:18789" && gateway.token === "token-123"; }); + await page.getByRole("button", { name: "Save settings" }).click(); + const request = await requestPromise; const payload = JSON.parse(request.postData() ?? "{}") as Record; const gateway = (payload.gateway ?? {}) as { url?: string; token?: string }; expect(gateway.url).toBe("ws://gateway.example:18789"); expect(gateway.token).toBe("token-123"); + await expect(page.getByRole("button", { name: "Test connection" })).toBeVisible(); +}); + +test("same-host cloud onboarding keeps the upstream on localhost", async ({ page }) => { + const installContext = defaultStudioInstallContext(); + installContext.studioHost.remoteShell = true; + installContext.tailscale.loggedIn = true; + installContext.tailscale.dnsName = "studio-host.tailnet.ts.net"; + + await stubStudioRoute( + page, + { + version: 1, + gateway: null, + focused: {}, + avatars: {}, + }, + { + localGatewayDefaults: { + url: "ws://localhost:18789", + token: "", + }, + localGatewayDefaultsMeta: { + hasToken: true, + }, + installContext, + } + ); + await stubRuntimeRoutes(page, { + summary: { + status: "disconnected", + reason: null, + error: "Control-plane start failed: Studio gateway token is not configured.", + }, + }); + + await page.goto("/"); + await expect(page.getByText("Studio and OpenClaw on the same cloud machine")).toBeVisible(); + await page.getByRole("button", { name: /Studio and OpenClaw on the same cloud machine/i }).click(); + await expect(page.getByText(/Studio is on a remote host\./i)).toBeVisible(); await expect( - page.getByRole("button", { name: /^(Connect|Disconnect)$/ }) + page.getByText("tailscale serve --yes --bg --https 443 http://127.0.0.1:3000") + ).toBeVisible(); + await page.getByRole("button", { name: "Use local defaults" }).click(); + await expect(page.getByLabel("Upstream URL")).toHaveValue("ws://localhost:18789"); +}); + +test("remote gateway onboarding warns about ws tailscale urls", async ({ page }) => { + await stubStudioRoute(page); + await stubRuntimeRoutes(page, { + summary: { + status: "disconnected", + reason: null, + error: "Control-plane start failed: Studio gateway token is not configured.", + }, + }); + + await page.goto("/"); + await page.getByRole("button", { name: /Studio here, OpenClaw in the cloud/i }).click(); + await page.getByLabel("Upstream URL").fill("ws://gateway-host.ts.net"); + await expect( + page.getByText(/Use wss:\/\/ for \.ts\.net gateway URLs\./i) ).toBeVisible(); }); diff --git a/tests/e2e/helpers/runtimeRoute.ts b/tests/e2e/helpers/runtimeRoute.ts index 6b32a46..d92beba 100644 --- a/tests/e2e/helpers/runtimeRoute.ts +++ b/tests/e2e/helpers/runtimeRoute.ts @@ -2,6 +2,11 @@ import type { Page } from "@playwright/test"; import type { AgentStoreSeed } from "@/features/agents/state/store"; type RuntimeRouteFixture = { + summary?: { + status: string; + reason?: string | null; + error?: string | null; + }; fleetResult?: { seeds: AgentStoreSeed[]; sessionCreatedAgentIds: string[]; @@ -12,6 +17,12 @@ type RuntimeRouteFixture = { }; }; +const DEFAULT_SUMMARY: RuntimeRouteFixture["summary"] = { + status: "connected", + reason: null, + error: null, +}; + const DEFAULT_FLEET_RESULT: RuntimeRouteFixture["fleetResult"] = { seeds: [], sessionCreatedAgentIds: [], @@ -43,20 +54,22 @@ export const stubRuntimeRoutes = async (page: Page, fixture: RuntimeRouteFixture return; } const asOf = new Date().toISOString(); + const summary = fixture.summary ?? DEFAULT_SUMMARY; await route.fulfill({ - status: 200, + status: summary.error ? 503 : 200, contentType: "application/json", body: JSON.stringify({ enabled: true, summary: { - status: "connected", - reason: null, + status: summary.status, + reason: summary.reason ?? null, asOf, outboxHead: 0, }, + ...(summary.error ? { error: summary.error } : {}), freshness: { - source: "gateway", - stale: false, + source: summary.error ? "projection" : "gateway", + stale: Boolean(summary.error), asOf, }, }), diff --git a/tests/e2e/helpers/studioRoute.ts b/tests/e2e/helpers/studioRoute.ts index e38eac2..99b13f2 100644 --- a/tests/e2e/helpers/studioRoute.ts +++ b/tests/e2e/helpers/studioRoute.ts @@ -1,4 +1,5 @@ import type { Page, Route, Request } from "@playwright/test"; +import type { StudioInstallContext } from "@/lib/studio/install-context"; type StudioSettingsFixture = { version: 1; @@ -9,6 +10,9 @@ type StudioSettingsFixture = { type StudioRouteEnvelopeFixture = { localGatewayDefaults?: { url: string; token: string } | null; + localGatewayDefaultsMeta?: { hasToken: boolean }; + gatewayMeta?: { hasStoredToken: boolean }; + installContext?: StudioInstallContext; domainApiModeEnabled?: boolean; }; @@ -32,6 +36,13 @@ const createStudioRoute = ( const responseEnvelope = () => ({ settings, localGatewayDefaults: envelope.localGatewayDefaults ?? null, + localGatewayDefaultsMeta: envelope.localGatewayDefaultsMeta ?? { + hasToken: Boolean(envelope.localGatewayDefaults?.token), + }, + gatewayMeta: envelope.gatewayMeta ?? { + hasStoredToken: Boolean(settings.gateway?.token), + }, + installContext: envelope.installContext, domainApiModeEnabled: envelope.domainApiModeEnabled ?? true, }); @@ -53,7 +64,18 @@ const createStudioRoute = ( const next = { ...settings }; if ("gateway" in patch) { - next.gateway = (patch.gateway as StudioSettingsFixture["gateway"]) ?? null; + const gatewayPatch = (patch.gateway ?? null) as + | { url?: string; token?: string } + | null; + if (gatewayPatch === null) { + next.gateway = null; + } else { + const existing = next.gateway ?? { url: "", token: "" }; + next.gateway = { + url: gatewayPatch.url ?? existing.url, + token: gatewayPatch.token ?? existing.token, + }; + } } if (patch.focused && typeof patch.focused === "object") { diff --git a/tests/unit/connectionPanel-close.test.ts b/tests/unit/connectionPanel-close.test.ts index 026b228..776029c 100644 --- a/tests/unit/connectionPanel-close.test.ts +++ b/tests/unit/connectionPanel-close.test.ts @@ -3,6 +3,26 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { ConnectionPanel } from "@/features/agents/components/ConnectionPanel"; +const buildProps = () => ({ + savedGatewayUrl: "ws://127.0.0.1:18789", + draftGatewayUrl: "ws://127.0.0.1:18789", + token: "token", + hasStoredToken: true, + localGatewayDefaultsHasToken: false, + hasUnsavedChanges: false, + status: "disconnected" as const, + statusReason: null, + error: null, + testResult: null, + saving: false, + testing: false, + onGatewayUrlChange: vi.fn(), + onTokenChange: vi.fn(), + onSaveSettings: vi.fn(), + onTestConnection: vi.fn(), + onDisconnect: vi.fn(), +}); + describe("ConnectionPanel close control", () => { afterEach(() => { cleanup(); @@ -10,17 +30,11 @@ describe("ConnectionPanel close control", () => { it("renders close control and calls handler when provided", () => { const onClose = vi.fn(); + const props = buildProps(); render( createElement(ConnectionPanel, { - gatewayUrl: "ws://127.0.0.1:18789", - token: "token", - status: "disconnected", - error: null, - onGatewayUrlChange: vi.fn(), - onTokenChange: vi.fn(), - onConnect: vi.fn(), - onDisconnect: vi.fn(), + ...props, onClose, }) ); @@ -30,18 +44,7 @@ describe("ConnectionPanel close control", () => { }); it("does not render close control when handler is missing", () => { - render( - createElement(ConnectionPanel, { - gatewayUrl: "ws://127.0.0.1:18789", - token: "token", - status: "disconnected", - error: null, - onGatewayUrlChange: vi.fn(), - onTokenChange: vi.fn(), - onConnect: vi.fn(), - onDisconnect: vi.fn(), - }) - ); + render(createElement(ConnectionPanel, buildProps())); expect(screen.queryByTestId("gateway-connection-close")).not.toBeInTheDocument(); }); @@ -49,14 +52,7 @@ describe("ConnectionPanel close control", () => { it("renders semantic gateway status class markers", () => { const { rerender } = render( createElement(ConnectionPanel, { - gatewayUrl: "ws://127.0.0.1:18789", - token: "token", - status: "disconnected", - error: null, - onGatewayUrlChange: vi.fn(), - onTokenChange: vi.fn(), - onConnect: vi.fn(), - onDisconnect: vi.fn(), + ...buildProps(), }) ); @@ -66,14 +62,8 @@ describe("ConnectionPanel close control", () => { rerender( createElement(ConnectionPanel, { - gatewayUrl: "ws://127.0.0.1:18789", - token: "token", + ...buildProps(), status: "connected", - error: null, - onGatewayUrlChange: vi.fn(), - onTokenChange: vi.fn(), - onConnect: vi.fn(), - onDisconnect: vi.fn(), }) ); diff --git a/tests/unit/installContext.test.ts b/tests/unit/installContext.test.ts new file mode 100644 index 0000000..a93ab32 --- /dev/null +++ b/tests/unit/installContext.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + defaultStudioInstallContext, + resolveDefaultSetupScenario, + resolveGatewayConnectionWarnings, +} from "@/lib/studio/install-context"; + +describe("studio install context helpers", () => { + it("defaults to same-cloud-host when Studio looks remote and the upstream is localhost", () => { + const installContext = defaultStudioInstallContext(); + installContext.studioHost.remoteShell = true; + + const scenario = resolveDefaultSetupScenario({ + installContext, + gatewayUrl: "ws://localhost:18789", + }); + + expect(scenario).toBe("same-cloud-host"); + }); + + it("defaults to remote-gateway when the upstream is remote", () => { + const scenario = resolveDefaultSetupScenario({ + installContext: defaultStudioInstallContext(), + gatewayUrl: "wss://gateway.example.ts.net", + }); + + expect(scenario).toBe("remote-gateway"); + }); + + it("warns when a tailscale hostname uses ws without TLS", () => { + const warnings = resolveGatewayConnectionWarnings({ + gatewayUrl: "ws://gateway-host.ts.net", + installContext: defaultStudioInstallContext(), + scenario: "remote-gateway", + hasStoredToken: false, + hasLocalGatewayToken: false, + }); + + expect(warnings.map((warning) => warning.id)).toContain("tailscale-ws"); + expect(warnings.map((warning) => warning.id)).toContain("tailscale-still-needs-token"); + }); + + it("warns when a remote setup uses a raw private IP websocket", () => { + const warnings = resolveGatewayConnectionWarnings({ + gatewayUrl: "ws://100.99.1.5:18789", + installContext: defaultStudioInstallContext(), + scenario: "remote-gateway", + hasStoredToken: true, + hasLocalGatewayToken: false, + }); + + expect(warnings.map((warning) => warning.id)).toContain("private-ip-advanced"); + }); + + it("explains localhost when Studio is running on a remote host", () => { + const installContext = defaultStudioInstallContext(); + installContext.studioHost.remoteShell = true; + + const warnings = resolveGatewayConnectionWarnings({ + gatewayUrl: "ws://localhost:18789", + installContext, + scenario: "same-cloud-host", + hasStoredToken: true, + hasLocalGatewayToken: false, + }); + + expect(warnings.map((warning) => warning.id)).toContain("remote-localhost"); + }); +}); diff --git a/tests/unit/serverInstallContext.test.ts b/tests/unit/serverInstallContext.test.ts new file mode 100644 index 0000000..916e4ed --- /dev/null +++ b/tests/unit/serverInstallContext.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +describe("server install context detector", () => { + it("detects a remote shell with local OpenClaw defaults and tailscale", async () => { + const { detectInstallContext } = await import("../../server/install-context"); + + const runCommand = async (file: string, args: string[]) => { + if (file === "openclaw" && args[0] === "status") { + return { stdout: JSON.stringify({ runtime: "running" }) }; + } + if (file === "openclaw" && args[0] === "sessions") { + return { stdout: JSON.stringify({ sessions: [] }) }; + } + if (file === "tailscale") { + return { + stdout: JSON.stringify({ + BackendState: "Running", + Self: { DNSName: "studio-host.tailnet.ts.net." }, + }), + }; + } + throw Object.assign(new Error("unexpected command"), { code: "ENOENT" }); + }; + + const context = await detectInstallContext( + { + NODE_ENV: "test", + SSH_CONNECTION: "1 2 3 4", + STUDIO_ACCESS_TOKEN: "studio-secret", + }, + { + resolveHosts: () => ["127.0.0.1"], + isPublicHost: () => false, + readOpenclawGatewayDefaults: () => ({ + url: "ws://localhost:18789", + token: "local-token", + }), + runCommand, + } + ); + + expect(context.studioHost.remoteShell).toBe(true); + expect(context.studioHost.loopbackOnly).toBe(true); + expect(context.studioHost.studioAccessTokenConfigured).toBe(true); + expect(context.localGateway.defaultsDetected).toBe(true); + expect(context.localGateway.hasToken).toBe(true); + expect(context.localGateway.probeHealthy).toBe(true); + expect(context.tailscale.loggedIn).toBe(true); + expect(context.tailscale.dnsName).toBe("studio-host.tailnet.ts.net"); + }); + + it("falls back cleanly when openclaw and tailscale are missing", async () => { + const { detectInstallContext } = await import("../../server/install-context"); + + const runCommand = async () => { + throw Object.assign(new Error("not found"), { code: "ENOENT" }); + }; + + const context = await detectInstallContext( + { + NODE_ENV: "test", + }, + { + resolveHosts: () => ["127.0.0.1"], + isPublicHost: () => false, + readOpenclawGatewayDefaults: () => null, + runCommand, + } + ); + + expect(context.localGateway.defaultsDetected).toBe(false); + expect(context.localGateway.cliAvailable).toBe(false); + expect(context.localGateway.probeHealthy).toBe(false); + expect(context.localGateway.issues).toContain("cli_not_found"); + expect(context.tailscale.installed).toBe(false); + expect(context.tailscale.loggedIn).toBe(false); + }); + + it("uses a placeholder ssh target when no reachable host is known", async () => { + const { buildStartupGuidance } = await import("../../server/install-context"); + + const lines = buildStartupGuidance({ + port: 3000, + installContext: { + studioHost: { + hostname: "ip-10-0-1-35", + configuredHosts: ["127.0.0.1"], + publicHosts: [], + loopbackOnly: true, + remoteShell: true, + studioAccessTokenConfigured: false, + }, + localGateway: { + defaultsDetected: true, + url: "ws://localhost:18789", + hasToken: true, + cliAvailable: true, + statusProbeOk: true, + sessionsProbeOk: true, + probeHealthy: true, + issues: [], + }, + tailscale: { + installed: false, + loggedIn: false, + dnsName: null, + }, + }, + }); + + expect(lines).toContain("SSH tunnel fallback: ssh -L 3000:127.0.0.1:3000 "); + }); +}); diff --git a/tests/unit/studioSettingsRoute.test.ts b/tests/unit/studioSettingsRoute.test.ts index cd302ec..10da181 100644 --- a/tests/unit/studioSettingsRoute.test.ts +++ b/tests/unit/studioSettingsRoute.test.ts @@ -32,12 +32,18 @@ describe("studio settings route", () => { const body = (await response.json()) as { settings?: Record; localGatewayDefaults?: unknown; + localGatewayDefaultsMeta?: { hasToken?: unknown }; + gatewayMeta?: { hasStoredToken?: unknown }; + installContext?: Record; domainApiModeEnabled?: unknown; }; expect(response.status).toBe(200); expect(body.settings?.gateway).toBe(null); expect(body.localGatewayDefaults ?? null).toBeNull(); + expect(body.localGatewayDefaultsMeta?.hasToken).toBe(false); + expect(body.gatewayMeta?.hasStoredToken).toBe(false); + expect(body.installContext).toBeTruthy(); expect(typeof body.domainApiModeEnabled).toBe("boolean"); expect(body.settings?.version).toBe(1); }); @@ -67,6 +73,8 @@ describe("studio settings route", () => { const body = (await response.json()) as { settings?: { gateway?: { url?: string; token?: string } | null }; localGatewayDefaults?: { url?: string; token?: string } | null; + localGatewayDefaultsMeta?: { hasToken?: unknown }; + gatewayMeta?: { hasStoredToken?: unknown }; }; expect(response.status).toBe(200); @@ -74,6 +82,8 @@ describe("studio settings route", () => { url: "ws://localhost:18791", token: "", }); + expect(body.localGatewayDefaultsMeta?.hasToken).toBe(true); + expect(body.gatewayMeta?.hasStoredToken).toBe(true); expect(body.settings?.gateway).toEqual({ url: "ws://localhost:18791", token: "", @@ -110,10 +120,12 @@ describe("studio settings route", () => { const getResponse = await GET(); const body = (await getResponse.json()) as { settings?: { gateway?: { url?: string; token?: string } | null }; + gatewayMeta?: { hasStoredToken?: unknown }; }; expect(getResponse.status).toBe(200); expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "" }); + expect(body.gatewayMeta?.hasStoredToken).toBe(true); const settingsPath = path.join(tempDir, "openclaw-studio", "settings.json"); expect(fs.existsSync(settingsPath)).toBe(true); @@ -149,9 +161,11 @@ describe("studio settings route", () => { const getResponse = await GET(); const body = (await getResponse.json()) as { settings?: { gateway?: { url?: string; token?: string } | null }; + gatewayMeta?: { hasStoredToken?: unknown }; }; expect(getResponse.status).toBe(200); expect(body.settings?.gateway).toEqual({ url: "ws://new.example:18789", token: "" }); + expect(body.gatewayMeta?.hasStoredToken).toBe(true); const persisted = JSON.parse( fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8")