Improve Studio install onboarding for local and cloud setups

Make first-run connection setup explain how users open Studio separately from how Studio reaches OpenClaw, so remote and same-host cloud installs are less confusing for beginners.

Made-with: Cursor
This commit is contained in:
George Pickett
2026-03-05 16:19:54 -08:00
parent ff2dfc4339
commit d2c10958bc
28 changed files with 1930 additions and 304 deletions
+33 -8
View File
@@ -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://<gateway-host>.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://<private-ip>: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://<studio-host>.ts.net`
4. In Studio, set:
- Upstream URL: `wss://<gateway-host>.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://<studio-host>.ts.net`
5. Only use a remote upstream like `wss://<gateway-host>.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, its usually Studio->Gateway:
- Confirm the upstream URL/token in the UI (stored on the Studio host at `<state dir>/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`.
+28
View File
@@ -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)
+17
View File
@@ -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) => {
+29
View File
@@ -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<StudioInstallContext>;
export function buildStartupGuidance(params: {
installContext: StudioInstallContext;
port: number;
}): string[];
+220
View File
@@ -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] ||
"<studio-host>";
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,
};
+1
View File
@@ -91,4 +91,5 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
module.exports = {
resolveStudioSettingsPath,
loadUpstreamGatewaySettings,
readOpenclawGatewayDefaults,
};
+36
View File
@@ -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 });
}
}
+18 -3
View File
@@ -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);
@@ -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 {}
}
}
}
+63 -24
View File
@@ -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 (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="flex min-h-screen items-center justify-center px-6">
@@ -1358,7 +1377,7 @@ const AgentStudioPage = () => {
OpenClaw Studio
</div>
<div className="mt-3 text-sm text-muted-foreground">
{status === "connecting" ? "Connecting to gateway…" : "Booting Studio…"}
{gatewayConnecting ? "Connecting to gateway…" : "Booting Studio…"}
</div>
</div>
</div>
@@ -1366,7 +1385,7 @@ const AgentStudioPage = () => {
);
}
if (!coreConnected && status === "disconnected" && !agentsLoadedOnce && didAttemptGatewayConnect) {
if (!coreConnected && !agentsLoadedOnce && didAttemptGatewayConnect) {
return (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="relative z-10 flex h-screen flex-col">
@@ -1387,15 +1406,26 @@ const AgentStudioPage = () => {
</div>
) : null}
<GatewayConnectScreen
gatewayUrl={gatewayUrl}
savedGatewayUrl={gatewayUrl}
draftGatewayUrl={draftGatewayUrl}
token={token}
localGatewayDefaults={localGatewayDefaults}
localGatewayDefaultsHasToken={localGatewayDefaultsHasToken}
hasStoredToken={hasStoredToken}
hasUnsavedChanges={hasUnsavedChanges}
installContext={installContext}
status={gatewayStatus}
statusReason={statusReason}
error={gatewayError}
testResult={testResult}
saving={gatewaySaving}
testing={gatewayTesting}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onUseLocalDefaults={useLocalGatewayDefaults}
onConnect={() => void connect()}
onSaveSettings={() => void saveSettings()}
onTestConnection={() => void testConnection()}
onDisconnect={() => void disconnect()}
/>
</div>
</div>
@@ -1437,14 +1467,23 @@ const AgentStudioPage = () => {
<div className="pointer-events-none fixed inset-x-0 top-12 z-[140] flex justify-center px-3 sm:px-4 md:px-5">
<div className="glass-panel pointer-events-auto w-full max-w-4xl !bg-card px-4 py-4 sm:px-6 sm:py-6">
<ConnectionPanel
gatewayUrl={gatewayUrl}
savedGatewayUrl={gatewayUrl}
draftGatewayUrl={draftGatewayUrl}
token={token}
hasStoredToken={hasStoredToken}
localGatewayDefaultsHasToken={localGatewayDefaultsHasToken}
hasUnsavedChanges={hasUnsavedChanges}
status={gatewayStatus}
statusReason={statusReason}
error={gatewayError}
testResult={testResult}
saving={gatewaySaving}
testing={gatewayTesting}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onConnect={() => void connect()}
onDisconnect={disconnect}
onSaveSettings={() => void saveSettings()}
onTestConnection={() => void testConnection()}
onDisconnect={() => void disconnect()}
onClose={() => setShowConnectionPanel(false)}
/>
</div>
@@ -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 (
<div className="fade-up-delay flex flex-col gap-3">
@@ -41,11 +68,29 @@ export const ConnectionPanel = ({
<button
className="ui-btn-secondary px-4 py-2 text-xs font-semibold tracking-[0.05em] text-foreground disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={isConnected ? onDisconnect : onConnect}
disabled={isConnecting || !gatewayUrl.trim()}
onClick={onSaveSettings}
disabled={actionBusy || !draftGatewayUrl.trim()}
>
{isConnected ? "Disconnect" : "Connect"}
{saving ? "Saving…" : "Save settings"}
</button>
<button
className="ui-btn-ghost px-4 py-2 text-xs font-semibold tracking-[0.05em] text-foreground disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={onTestConnection}
disabled={actionBusy || !draftGatewayUrl.trim()}
>
{testing ? "Testing…" : "Test connection"}
</button>
{status === "connected" ? (
<button
className="ui-btn-ghost px-4 py-2 text-xs font-semibold tracking-[0.05em] text-foreground disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={onDisconnect}
disabled={actionBusy}
>
Disconnect
</button>
) : null}
</div>
{onClose ? (
<button
@@ -66,9 +111,9 @@ export const ConnectionPanel = ({
<input
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
type="text"
value={gatewayUrl}
value={draftGatewayUrl}
onChange={(event) => onGatewayUrlChange(event.target.value)}
placeholder="ws://localhost:18789"
placeholder="ws://localhost:18789 or wss://your-gateway.ts.net"
spellCheck={false}
/>
</label>
@@ -84,6 +129,28 @@ export const ConnectionPanel = ({
/>
</label>
</div>
<p className="text-xs text-muted-foreground">{tokenHelper}</p>
{hasUnsavedChanges ? (
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Unsaved changes
</p>
) : (
<p className="text-xs text-muted-foreground">
Saved upstream: <span className="font-mono">{savedGatewayUrl || "not configured"}</span>
</p>
)}
{statusReason ? <p className="text-xs text-muted-foreground">{statusReason}</p> : null}
{testResult ? (
<p
className={
testResult.kind === "error"
? "ui-alert-danger rounded-md px-4 py-2 text-sm"
: "ui-card rounded-md px-4 py-2 text-sm text-muted-foreground"
}
>
{testResult.message}
</p>
) : null}
{error ? (
<p className="ui-alert-danger rounded-md px-4 py-2 text-sm">
{error}
@@ -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<StudioSetupScenario>(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://<studio-host>.ts.net";
const studioSshTarget =
installContext.tailscale.dnsName ||
installContext.studioHost.publicHosts[0] ||
"<studio-host>";
const studioTunnelCommand = `ssh -L 3000:127.0.0.1:3000 ${studioSshTarget}`;
const gatewayTunnelCommand = `ssh -L ${localPort}:127.0.0.1:${localPort} user@<gateway-host>`;
const warnings = useMemo<StudioConnectionWarning[]>(
() =>
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;
}) => (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
{params.label}
</p>
<button
type="button"
className="ui-btn-ghost h-7 px-2 text-[11px]"
onClick={() => void copyCommand(params.value)}
>
{copyStatus === "copied" ? "Copied" : copyStatus === "failed" ? "Copy failed" : "Copy"}
</button>
</div>
<div className="ui-command-surface flex items-center gap-2 rounded-md px-3 py-2">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12px]">
{localGatewayCommand}
{params.value}
</code>
<button
type="button"
className="ui-btn-icon ui-command-copy h-7 w-7 shrink-0"
onClick={copyLocalCommand}
aria-label="Copy local gateway command"
onClick={() => void copyCommand(params.value)}
aria-label={`Copy ${params.label}`}
title="Copy command"
>
{copyStatus === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
{copyStatus === "copied" ? (
<p className="text-xs text-muted-foreground">Copied</p>
) : copyStatus === "failed" ? (
<p className="ui-text-danger text-xs">Could not copy command.</p>
) : (
<p className="text-xs leading-snug text-muted-foreground">
In a source checkout, use <span className="font-mono">{localGatewayCommandPnpm}</span>.
</p>
)}
{params.helper ? (
<p className="text-xs leading-snug text-muted-foreground">{params.helper}</p>
) : null}
</div>
);
const remoteForm = (
<div className="mt-2.5 flex flex-col gap-3">
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/80">
Upstream URL
<input
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
type="text"
value={gatewayUrl}
onChange={(event) => onGatewayUrlChange(event.target.value)}
placeholder="wss://your-gateway.example.com"
spellCheck={false}
/>
</label>
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"
}`;
};
<div className="space-y-0.5 text-xs text-muted-foreground/90">
<p className="font-medium text-foreground/85">Using Tailscale?</p>
<p>
URL: <span className="font-mono">wss://&lt;your-tailnet-host&gt;</span>
</p>
const connectionForm = (
<div className="ui-card px-4 py-4 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Studio to OpenClaw
</p>
<p className="mt-1 text-sm text-foreground/85">
Save a gateway URL and token for this Studio host.
</p>
</div>
<span
className={`ui-chip inline-flex items-center px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass(status)}`}
data-status={status}
>
{resolveGatewayStatusLabel(status)}
</span>
</div>
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/80">
Upstream token
<div className="relative">
<div className="mt-4 grid gap-3 lg:grid-cols-[1.35fr_1fr]">
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/80">
Upstream URL
<input
className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none"
type={showToken ? "text" : "password"}
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder="gateway token"
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
type="text"
value={draftGatewayUrl}
onChange={(event) => onGatewayUrlChange(event.target.value)}
placeholder={
selectedScenario === "remote-gateway"
? "wss://your-gateway.ts.net"
: `ws://localhost:${localPort}`
}
spellCheck={false}
/>
<button
type="button"
className="ui-btn-icon absolute inset-y-0 right-1 my-auto h-8 w-8 border-transparent bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground"
aria-label={showToken ? "Hide token" : "Show token"}
onClick={() => setShowToken((prev) => !prev)}
>
{showToken ? (
<EyeOff className="h-4 w-4 transition-transform duration-150" />
) : (
<Eye className="h-4 w-4 transition-transform duration-150" />
)}
</button>
</div>
</label>
</label>
<button
type="button"
className="ui-btn-primary mt-1 h-11 w-full px-4 text-xs font-semibold tracking-[0.05em] disabled:cursor-not-allowed disabled:opacity-60"
onClick={onConnect}
disabled={connectDisabled || !gatewayUrl.trim()}
>
{connectLabel}
</button>
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/80">
Upstream token
<div className="relative">
<input
className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none"
type={showToken ? "text" : "password"}
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder={hasStoredToken || localGatewayDefaultsHasToken ? "keep existing token" : "gateway token"}
spellCheck={false}
/>
<button
type="button"
className="ui-btn-icon absolute inset-y-0 right-1 my-auto h-8 w-8 border-transparent bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground"
aria-label={showToken ? "Hide token" : "Show token"}
onClick={() => setShowToken((prev) => !prev)}
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</label>
</div>
{status === "connecting" ? (
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Connecting
<p className="mt-2 text-xs leading-snug text-muted-foreground">{tokenHelper}</p>
{hasUnsavedChanges ? (
<p className="mt-2 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Unsaved changes
</p>
) : null}
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
className="ui-btn-primary h-10 px-4 text-xs font-semibold tracking-[0.05em] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void onSaveSettings()}
disabled={actionBusy || !draftGatewayUrl.trim()}
>
{saveLabel}
</button>
<button
type="button"
className="ui-btn-secondary h-10 px-4 text-xs font-semibold tracking-[0.05em] text-foreground disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void onTestConnection()}
disabled={actionBusy || !draftGatewayUrl.trim()}
>
{testLabel}
</button>
{status === "connected" ? (
<button
type="button"
className="ui-btn-ghost h-10 px-4 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={() => void onDisconnect()}
disabled={actionBusy}
>
Disconnect
</button>
) : null}
</div>
</div>
);
return (
<div className="mx-auto flex min-h-0 w-full max-w-[820px] flex-1 flex-col gap-5">
<div className="ui-card px-4 py-2">
<div className="flex items-center gap-2">
{status === "connecting" ? (
<div className="flex items-start gap-3">
{status === "connecting" || status === "reconnecting" ? (
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--status-connecting-fg)]" />
) : (
<span
className={`h-2.5 w-2.5 ${statusDotClass}`}
/>
<span className={`mt-1 h-2.5 w-2.5 ${statusDotClass}`} />
)}
<p className="text-sm font-semibold text-foreground">{statusCopy}</p>
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">{statusCopy}</p>
<p className="text-sm text-muted-foreground">{statusSubcopy}</p>
</div>
</div>
</div>
<div className="ui-card px-4 py-5 sm:px-6">
<div>
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-muted-foreground">
Remote gateway (recommended)
</p>
<p className="mt-2 text-sm text-foreground/85">Default: enter your URL and token to connect.</p>
</div>
{remoteForm}
</div>
<div className="ui-card px-4 py-4 sm:px-6 sm:py-5">
<div className="space-y-1.5">
<div className="grid gap-3 md:grid-cols-3">
<button type="button" className={scenarioButtonClass("same-computer")} onClick={() => setScenario("same-computer")}>
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Run locally (optional)
Everything on this computer
</p>
<p className="text-sm text-foreground/85">
Start a local gateway process on this machine, then connect.
<p className="mt-2 text-sm text-foreground/85">
Studio and OpenClaw both run on the same machine.
</p>
</button>
<button type="button" className={scenarioButtonClass("remote-gateway")} onClick={() => setScenario("remote-gateway")}>
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Studio here, OpenClaw in the cloud
</p>
<p className="mt-2 text-sm text-foreground/85">
Keep Studio on your laptop and point it at a remote gateway.
</p>
</button>
<button type="button" className={scenarioButtonClass("same-cloud-host")} onClick={() => setScenario("same-cloud-host")}>
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Studio and OpenClaw on the same cloud machine
</p>
<p className="mt-2 text-sm text-foreground/85">
Use localhost for the upstream, then solve how you open Studio.
</p>
</button>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="ui-card px-4 py-4 sm:px-5">
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
How you open Studio
</p>
{selectedScenario === "same-computer" || selectedScenario === "remote-gateway" ? (
<div className="mt-3 space-y-3">
<p className="text-sm text-foreground/85">
Open <span className="font-mono">http://localhost:3000</span> on this computer.
</p>
<p className="text-xs leading-snug text-muted-foreground">
Only the OpenClaw upstream changes in this setup. Studio itself stays local.
</p>
</div>
) : (
<div className="mt-3 space-y-3">
<p className="text-sm text-foreground/85">
Studio is on a remote host. <span className="font-mono">http://localhost:3000</span> only opens on that machine.
</p>
{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 ? (
<div className="ui-card rounded-md px-3 py-3 text-sm text-muted-foreground">
Tailscale was not detected on this Studio host. Beginners will usually have a much easier time with Tailscale Serve than with public binds.
</div>
) : null}
{installContext.studioHost.publicHosts.length > 0 ? (
<div className="ui-card rounded-md px-3 py-3 text-sm text-muted-foreground">
This Studio is already bound beyond loopback. If you keep it public, <span className="font-mono">STUDIO_ACCESS_TOKEN</span> is required and each browser must open <span className="font-mono">/?access_token=...</span> once.
</div>
) : null}
</div>
)}
</div>
<div className="mt-3 space-y-3">
{commandField}
{localGatewayDefaults ? (
<div className="ui-input rounded-md px-3 py-3">
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Use token from <span className="font-mono">~/.openclaw/openclaw.json</span>.
</p>
<p className="font-mono text-[11px] text-foreground/85">
{localGatewayDefaults.url}
</p>
<div className="ui-card px-4 py-4 sm:px-5">
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
How Studio reaches OpenClaw
</p>
{selectedScenario === "remote-gateway" ? (
<div className="mt-3 space-y-3">
<p className="text-sm text-foreground/85">
Recommended: keep the remote gateway on loopback and expose it with Tailscale Serve.
</p>
{commandField({
value: gatewayServeCommand,
label: "On the gateway host",
helper: "In Studio, use wss://<gateway-host>.ts.net plus your gateway token.",
})}
{commandField({
value: gatewayTunnelCommand,
label: "Fallback: SSH tunnel",
helper: `Then point Studio to ws://localhost:${localPort}.`,
})}
<div className="flex flex-wrap gap-2">
<button
type="button"
className="ui-btn-secondary h-9 w-full px-3 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={onUseLocalDefaults}
className="ui-btn-secondary h-9 px-3 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={applyLoopbackUrl}
>
Use local defaults
Use SSH tunnel URL
</button>
</div>
</div>
) : null}
) : (
<div className="mt-3 space-y-3">
<p className="text-sm text-foreground/85">
Keep the upstream local to the Studio host:{" "}
<span className="font-mono">{`ws://localhost:${localPort}`}</span>.
</p>
{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.",
})}
<div className="flex flex-wrap gap-2">
<button
type="button"
className="ui-btn-secondary h-9 px-3 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={applyLoopbackUrl}
>
Use localhost upstream
</button>
{localGatewayDefaults ? (
<button
type="button"
className="ui-btn-secondary h-9 px-3 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={onUseLocalDefaults}
>
Use local defaults
</button>
) : null}
</div>
{localGatewayDefaults ? (
<div className="ui-card rounded-md px-3 py-3 text-sm text-muted-foreground">
Local OpenClaw settings were detected at <span className="font-mono">~/.openclaw/openclaw.json</span>. Studio can reuse that local URL and token.
</div>
) : null}
</div>
)}
</div>
</div>
{warnings.length > 0 ? (
<div className="space-y-2">
{warnings.map((warning) => (
<div
key={warning.id}
className={
warning.tone === "warn"
? "ui-alert-danger rounded-md px-4 py-2 text-sm"
: "ui-card rounded-md px-4 py-2 text-sm text-muted-foreground"
}
>
{warning.message}
</div>
))}
</div>
) : null}
{connectionForm}
{testResult ? (
<div
className={
testResult.kind === "error"
? "ui-alert-danger rounded-md px-4 py-2 text-sm"
: "ui-card rounded-md px-4 py-2 text-sm text-muted-foreground"
}
>
{testResult.message}
</div>
) : null}
{error ? <p className="ui-text-danger text-sm leading-snug">{error}</p> : null}
</div>
);
};
+8 -10
View File
@@ -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
</p>
<div className="flex items-center justify-end gap-1">
{status === "connecting" ? (
<span
className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass("connecting")}`}
data-testid="gateway-connecting-indicator"
data-status="connecting"
>
Connecting
</span>
) : null}
<span
className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass(status)}`}
data-testid="gateway-status-indicator"
data-status={status}
>
{resolveGatewayStatusLabel(status)}
</span>
<ThemeToggle />
{showConnectionSettings ? (
<div className="relative z-[210]" ref={menuRef}>
@@ -17,12 +17,16 @@ export const GATEWAY_STATUS_LABEL: Record<GatewayStatus, string> = {
disconnected: "Disconnected",
connecting: "Connecting",
connected: "Connected",
reconnecting: "Reconnecting",
error: "Error",
};
export const GATEWAY_STATUS_BADGE_CLASS: Record<GatewayStatus, string> = {
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";
@@ -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,
@@ -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;
@@ -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 = () => {};
+11 -1
View File
@@ -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;
+15
View File
@@ -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<string, Partial<StudioFocusedPreference> | null>;
+209
View File
@@ -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;
};
+318 -72
View File
@@ -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<StudioSettings | null>;
loadSettingsEnvelope?: () => Promise<StudioSettingsResponse>;
schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void;
flushPending: () => Promise<void>;
};
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<void>;
disconnect: () => void;
testResult:
| {
kind: "success" | "error";
message: string;
}
| null;
saving: boolean;
testing: boolean;
saveSettings: () => Promise<boolean>;
testConnection: () => Promise<boolean>;
disconnect: () => Promise<void>;
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<RuntimeSummaryEnvelope> => {
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<StudioGatewaySettings | null>(
null
);
const [localGatewayDefaultsHasToken, setLocalGatewayDefaultsHasToken] = useState(false);
const [hasStoredToken, setHasStoredToken] = useState(false);
const [installContext, setInstallContext] = useState<StudioInstallContext>(
defaultStudioInstallContext()
);
const domainApiModeEnabled = true;
const [status, setStatus] = useState<GatewayStatus>("disconnected");
const [error, setError] = useState<string | null>(null);
const [statusReason, setStatusReason] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(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<RuntimeSummaryEnvelope>("/api/runtime/summary", {
cache: "no-store",
const patch: StudioSettingsPatch = {
gateway: trimmedToken
? { url: trimmedGatewayUrl, token: trimmedToken }
: { url: trimmedGatewayUrl },
};
const envelope = await fetchJson<StudioSettingsResponse>("/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<TestConnectionResponse>("/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<RuntimeSummaryEnvelope>("/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,
]
+66 -3
View File
@@ -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<string, unknown>;
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();
});
+18 -5
View File
@@ -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,
},
}),
+23 -1
View File
@@ -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") {
+25 -35
View File
@@ -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(),
})
);
+70
View File
@@ -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");
});
});
+113
View File
@@ -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 <studio-host>");
});
});
+14
View File
@@ -32,12 +32,18 @@ describe("studio settings route", () => {
const body = (await response.json()) as {
settings?: Record<string, unknown>;
localGatewayDefaults?: unknown;
localGatewayDefaultsMeta?: { hasToken?: unknown };
gatewayMeta?: { hasStoredToken?: unknown };
installContext?: Record<string, unknown>;
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")