mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
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:
@@ -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, it’s 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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Vendored
+29
@@ -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[];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -91,4 +91,5 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
|
||||
module.exports = {
|
||||
resolveStudioSettingsPath,
|
||||
loadUpstreamGatewaySettings,
|
||||
readOpenclawGatewayDefaults,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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://<your-tailnet-host></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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user