From a18c8c630c20cd77be9a458965fd93a5295b875d Mon Sep 17 00:00:00 2001
From: gsknnft <123185582+gsknnft@users.noreply.github.com>
Date: Fri, 3 Apr 2026 18:57:36 -0400
Subject: [PATCH] fix: surface upstream gateway timeout for remote
OpenClaw/Tailscale connections (#94)
* surface gateway timeout for tailscale
* talescale fix #2 - attempt 1
* luke findings fix#1
* add narrow log for clientId
* prod safe proxy log
* fix log visibility
* LAN connection & subagent SOUL|IDENTITY fixes
* Initialize missing files for subagent SOUL|IDENTITY
* surface missing files in UI
* capturing agent - runtime,identity,session
* plugin-install fix
* fix: recover agent workspace for marketplace installs
* fix: recover agent workspace and identity name from file provenance
* fix: tolerate webchat session patch blocks during permission updates
---
README.md | 11 ++
server/gateway-proxy.js | 99 ++++++++++++-
server/index.js | 2 +
.../components/GatewayConnectScreen.tsx | 10 ++
.../components/inspect/AgentBrainPanel.tsx | 111 +++++++++++---
.../agents/hooks/useAgentFilesEditor.ts | 69 ++++++++-
.../agents/operations/agentFleetHydration.ts | 50 ++++++-
.../agentFleetHydrationDerivation.ts | 21 ++-
.../operations/agentPermissionsOperation.ts | 53 ++++---
src/features/agents/state/store.tsx | 3 +
.../panels/SkillsMarketplacePanel.tsx | 7 +-
.../hooks/useOfficeSkillsMarketplace.ts | 7 +-
src/lib/agents/agentFiles.ts | 4 +-
src/lib/agents/personalityBuilder.ts | 5 +-
src/lib/gateway/GatewayClient.ts | 40 ++++-
src/lib/gateway/agentFiles.ts | 11 +-
.../gateway/openclaw/GatewayBrowserClient.ts | 9 ++
src/lib/skills/install-gateway.ts | 51 ++++++-
src/lib/skills/types.ts | 63 +++++++-
tests/unit/agentBrainPanel.test.ts | 56 ++++++-
tests/unit/agentFleetHydration.test.ts | 33 ++++-
tests/unit/agentPermissionsOperation.test.ts | 137 +++++++++++++++++-
tests/unit/gatewayConnectRetryPolicy.test.ts | 32 ++++
tests/unit/gatewayProxy.test.ts | 69 +++++++++
tests/unit/personalityBuilder.test.ts | 27 +++-
tests/unit/skillsGatewayClient.test.ts | 67 +++++++++
tests/unit/skillsInstallGateway.test.ts | 88 +++++++++++
tests/unit/useGatewayConnection.test.ts | 115 ++++++++++++++-
28 files changed, 1174 insertions(+), 76 deletions(-)
diff --git a/README.md b/README.md
index 3c9dba2..6eb8193 100644
--- a/README.md
+++ b/README.md
@@ -208,6 +208,17 @@ Alternative with SSH:
3. Set `STUDIO_ACCESS_TOKEN` if Studio binds to a public host.
4. Configure the gateway URL and token inside Studio.
+### Studio on LAN or Tailscale for other devices
+
+1. Start Studio with `HOST=0.0.0.0` (or a specific LAN/Tailscale host).
+2. Set `STUDIO_ACCESS_TOKEN` before exposing Studio beyond localhost.
+3. Open Claw3D from the LAN/Tailscale address instead of `localhost`.
+4. If you are connecting to a remote OpenClaw gateway, remember device approval is per browser/device. A new browser may still require:
+
+```bash
+openclaw devices approve --latest
+```
+
## Tech Stack
- Next.js App Router, React, and TypeScript for the main web application.
diff --git a/server/gateway-proxy.js b/server/gateway-proxy.js
index ef5cab5..1a82081 100644
--- a/server/gateway-proxy.js
+++ b/server/gateway-proxy.js
@@ -1,6 +1,8 @@
const { Buffer } = require("node:buffer");
const { WebSocket, WebSocketServer } = require("ws");
+const DEFAULT_UPSTREAM_HANDSHAKE_TIMEOUT_MS = 10_000;
+
/** Maximum frame payload size (256 KB). */
const MAX_FRAME_SIZE = 256 * 1024;
@@ -29,11 +31,17 @@ const safeJsonParse = (raw) => {
/** Per-connection frame rate limiter. */
const createFrameRateLimiter = (maxPerSecond = MAX_FRAMES_PER_SECOND) => {
let count = 0;
- const interval = setInterval(() => { count = 0; }, 1000);
+ const interval = setInterval(() => {
+ count = 0;
+ }, 1000);
interval.unref();
return {
- check() { return ++count <= maxPerSecond; },
- destroy() { clearInterval(interval); },
+ check() {
+ return ++count <= maxPerSecond;
+ },
+ destroy() {
+ clearInterval(interval);
+ },
};
};
@@ -125,6 +133,7 @@ function createGatewayProxy(options) {
allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws",
log = () => {},
logError = (msg, err) => console.error(msg, err),
+ upstreamHandshakeTimeoutMs = DEFAULT_UPSTREAM_HANDSHAKE_TIMEOUT_MS,
} = options || {};
const { verifyClient } = options || {};
@@ -147,10 +156,16 @@ function createGatewayProxy(options) {
let pendingUpstreamSetupError = null;
let closed = false;
const frameRateLimiter = createFrameRateLimiter();
+ let upstreamHandshakeTimeoutId = null;
+
const closeBoth = (code, reason) => {
if (closed) return;
closed = true;
frameRateLimiter.destroy();
+ if (upstreamHandshakeTimeoutId !== null) {
+ clearTimeout(upstreamHandshakeTimeoutId);
+ upstreamHandshakeTimeoutId = null;
+ }
try {
browserWs.close(code, reason);
} catch {}
@@ -251,9 +266,30 @@ function createGatewayProxy(options) {
return;
}
- upstreamWs = new WebSocket(upstreamUrl, { origin: upstreamOrigin });
+ upstreamWs = new WebSocket(upstreamUrl, {
+ origin: upstreamOrigin,
+ handshakeTimeout: upstreamHandshakeTimeoutMs,
+ });
+
+ upstreamHandshakeTimeoutId = setTimeout(() => {
+ const timeoutError = {
+ code: "studio.upstream_timeout",
+ message: "Timed out connecting Studio to the upstream gateway WebSocket.",
+ };
+ pendingUpstreamSetupError = timeoutError;
+ try {
+ upstreamWs?.terminate();
+ } catch {}
+ if (connectRequestId) {
+ sendConnectError(timeoutError.code, timeoutError.message);
+ }
+ }, upstreamHandshakeTimeoutMs);
upstreamWs.on("open", () => {
+ if (upstreamHandshakeTimeoutId !== null) {
+ clearTimeout(upstreamHandshakeTimeoutId);
+ upstreamHandshakeTimeoutId = null;
+ }
upstreamReady = true;
maybeForwardPendingConnect();
});
@@ -271,22 +307,60 @@ function createGatewayProxy(options) {
}
});
- upstreamWs.on("close", (ev) => {
- const reason = typeof ev?.reason === "string" ? ev.reason : "";
+ upstreamWs.on("close", (code, reasonBuffer) => {
+ if (upstreamHandshakeTimeoutId !== null) {
+ clearTimeout(upstreamHandshakeTimeoutId);
+ upstreamHandshakeTimeoutId = null;
+ }
+ const reason =
+ typeof reasonBuffer === "string"
+ ? reasonBuffer
+ : Buffer.isBuffer(reasonBuffer)
+ ? reasonBuffer.toString()
+ : "";
+ if (!connectRequestId) {
+ pendingUpstreamSetupError ||= {
+ code: "studio.upstream_closed",
+ message: `Upstream gateway closed (${code}): ${reason}`,
+ };
+ return;
+ }
if (!connectResponseSent && connectRequestId) {
+ connectResponseSent = true;
sendToBrowser(
buildErrorResponse(
connectRequestId,
- "studio.upstream_closed",
- `Upstream gateway closed (${ev.code}): ${reason}`
+ code === 1008 ? "studio.upstream_rejected" : "studio.upstream_closed",
+ code === 1008
+ ? `Upstream gateway rejected connect (${code}): ${reason || "no reason provided"}`
+ : `Upstream gateway closed (${code}): ${reason}`
)
);
+ return;
}
closeBoth(1012, "upstream closed");
});
upstreamWs.on("error", (err) => {
+ if (upstreamHandshakeTimeoutId !== null) {
+ clearTimeout(upstreamHandshakeTimeoutId);
+ upstreamHandshakeTimeoutId = null;
+ }
logError("Upstream gateway WebSocket error.", err);
+ if (!connectRequestId) {
+ pendingUpstreamSetupError ||= {
+ code: "studio.upstream_error",
+ message: "Failed to connect to upstream gateway WebSocket.",
+ };
+ return;
+ }
+ if (
+ pendingUpstreamSetupError?.code === "studio.upstream_timeout" &&
+ pendingUpstreamSetupError?.message
+ ) {
+ sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
+ return;
+ }
sendConnectError(
"studio.upstream_error",
"Failed to connect to upstream gateway WebSocket."
@@ -331,6 +405,15 @@ function createGatewayProxy(options) {
return;
}
connectRequestId = id;
+ const params = isObject(parsed.params) ? parsed.params : null;
+ const client = params && isObject(params.client) ? params.client : null;
+ log(
+ `[gateway-proxy] connect frame client.id=${
+ typeof client?.id === "string" ? client.id : "n/a"
+ } client.mode=${
+ typeof client?.mode === "string" ? client.mode : "n/a"
+ } hasToken=${hasNonEmptyToken(params)} hasDevice=${hasCompleteDeviceAuth(params)}`
+ );
if (pendingUpstreamSetupError) {
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
return;
diff --git a/server/index.js b/server/index.js
index 878a63c..0dd2769 100644
--- a/server/index.js
+++ b/server/index.js
@@ -93,6 +93,8 @@ async function main() {
const settings = loadUpstreamGatewaySettings(process.env);
return { url: settings.url, token: settings.token, adapterType: settings.adapterType };
},
+ log: (message) => console.info(message),
+ logError: (message, error) => console.error(message, error),
allowWs: (req) => {
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
return true;
diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx
index e2d2e53..c9bca44 100644
--- a/src/features/agents/components/GatewayConnectScreen.tsx
+++ b/src/features/agents/components/GatewayConnectScreen.tsx
@@ -313,6 +313,16 @@ export const GatewayConnectScreen = ({
metadata scaffold are now in place.
+
+
Opening Claw3D from another machine?
+
+ Start Studio with HOST=0.0.0.0 (or a
+ specific LAN/Tailscale host) and set
+ STUDIO_ACCESS_TOKEN before exposing it
+ beyond localhost. Gateway settings are stored on the Studio host, but OpenClaw device approval
+ remains per browser/device.
+
+
{localGatewayDefaults ? (
diff --git a/src/features/agents/components/inspect/AgentBrainPanel.tsx b/src/features/agents/components/inspect/AgentBrainPanel.tsx
index fa1e8d1..0ee9d1c 100644
--- a/src/features/agents/components/inspect/AgentBrainPanel.tsx
+++ b/src/features/agents/components/inspect/AgentBrainPanel.tsx
@@ -7,10 +7,14 @@ import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { AgentIdentityFields } from "@/features/agents/components/AgentIdentityFields";
import {
AGENT_FILE_META,
- AGENT_FILE_PLACEHOLDERS,
+ PERSONALITY_FILE_NAMES,
type AgentFileName,
} from "@/lib/agents/agentFiles";
-import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder";
+import {
+ createEmptyPersonalityDraft,
+ parsePersonalityFiles,
+ serializePersonalityFiles,
+} from "@/lib/agents/personalityBuilder";
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
export type AgentBrainPanelProps = {
@@ -36,6 +40,30 @@ const AgentBrainPanelSection = ({
);
+const AgentFileProvenance = ({
+ path,
+ workspace,
+}: {
+ path: string | null;
+ workspace: string | null;
+}) => {
+ if (!path && !workspace) return null;
+ return (
+
+ {workspace ? (
+
+ Workspace: {workspace}
+
+ ) : null}
+ {path ? (
+
+ File: {path}
+
+ ) : null}
+
+ );
+};
+
export const AgentBrainPanel = ({
client,
agents,
@@ -61,9 +89,14 @@ export const AgentBrainPanel = ({
agentFilesError,
setAgentFileContent,
saveAgentFiles,
+ initializeAgentFiles,
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
const [saveError, setSaveError] = useState
(null);
+ const missingPersonalityFiles = useMemo(
+ () => PERSONALITY_FILE_NAMES.filter((name) => !agentFiles[name].exists),
+ [agentFiles]
+ );
const setIdentityField = useCallback(
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
@@ -101,6 +134,19 @@ export const AgentBrainPanel = ({
selectedAgent,
]);
+ const handleInitializeMissingFiles = useCallback(async () => {
+ if (!selectedAgent) return;
+ setSaveError(null);
+ const nextDraft = createEmptyPersonalityDraft();
+ nextDraft.identity.name = selectedAgent.name.trim();
+ nextDraft.identity.creature = selectedAgent.role?.trim() ?? "";
+ const serialized = serializePersonalityFiles(nextDraft);
+ const missingEntries = Object.fromEntries(
+ missingPersonalityFiles.map((name) => [name, serialized[name]])
+ ) as Partial>;
+ await initializeAgentFiles(missingEntries);
+ }, [initializeAgentFiles, missingPersonalityFiles, selectedAgent]);
+
useEffect(() => {
onUnsavedChangesChange?.(agentFilesDirty);
}, [agentFilesDirty, onUnsavedChangesChange]);
@@ -112,21 +158,36 @@ export const AgentBrainPanel = ({
}, [onUnsavedChangesChange]);
const renderMarkdownEditor = useCallback(
- (name: Exclude) => (
-
- {AGENT_FILE_META[name].hint}
-
- ),
+ (name: Exclude) => {
+ const file = agentFiles[name];
+ const trimmedContent = file.content.trim();
+ const statusCopy = !file.exists
+ ? `This agent does not have a custom ${name} yet. Saving here will create the real workspace file.`
+ : !trimmedContent
+ ? `This agent's ${name} exists, but it is currently empty.`
+ : null;
+ return (
+
+ {AGENT_FILE_META[name].hint}
+ {statusCopy ? (
+
+ {statusCopy}
+
+ ) : null}
+
+
+ );
+ },
[agentFiles, agentFilesLoading, agentFilesSaving, setAgentFileContent],
);
@@ -141,6 +202,10 @@ export const AgentBrainPanel = ({
Changing Name here also renames the live agent
when you save.
+
+ {missingPersonalityFiles.length > 0 ? (
+ {
+ void handleInitializeMissingFiles();
+ }}
+ >
+ Initialize missing files
+
+ ) : null}
void;
saveAgentFiles: () => Promise;
+ initializeAgentFiles: (files: Partial>) => Promise;
discardAgentFileChanges: () => void;
};
@@ -67,7 +72,13 @@ export const useAgentFilesEditor = (params: {
const results = await Promise.all(
AGENT_FILE_NAMES.map(async (name) => {
const file = await readGatewayAgentFile({ client, agentId: trimmedAgentId, name });
- return { name, content: file.content, exists: file.exists };
+ return {
+ name,
+ content: file.content,
+ exists: file.exists,
+ path: file.path,
+ workspace: file.workspace,
+ };
})
);
@@ -77,6 +88,8 @@ export const useAgentFilesEditor = (params: {
nextState[file.name] = {
content: file.content ?? "",
exists: Boolean(file.exists),
+ path: file.path ?? null,
+ workspace: file.workspace ?? null,
};
}
@@ -123,6 +136,8 @@ export const useAgentFilesEditor = (params: {
nextState[name] = {
content: agentFiles[name].content,
exists: true,
+ path: agentFiles[name].path,
+ workspace: agentFiles[name].workspace,
};
}
@@ -139,6 +154,55 @@ export const useAgentFilesEditor = (params: {
}
}, [agentFiles, agentId, client]);
+ const initializeAgentFiles = useCallback(
+ async (files: Partial>) => {
+ setAgentFilesSaving(true);
+ setAgentFilesError(null);
+
+ try {
+ const trimmedAgentId = agentId?.trim();
+ if (!trimmedAgentId) {
+ setAgentFilesError("Agent ID is missing for this agent.");
+ return false;
+ }
+
+ if (!client) {
+ setAgentFilesError("Gateway client is not available.");
+ return false;
+ }
+
+ await writeGatewayAgentFiles({
+ client,
+ agentId: trimmedAgentId,
+ files,
+ });
+
+ const nextState = cloneAgentFilesState(savedAgentFilesRef.current);
+ for (const [name, content] of Object.entries(files)) {
+ if (!isAgentFileName(name) || typeof content !== "string") continue;
+ nextState[name] = {
+ content,
+ exists: true,
+ path: nextState[name].path,
+ workspace: nextState[name].workspace,
+ };
+ }
+
+ savedAgentFilesRef.current = nextState;
+ setAgentFiles(nextState);
+ setAgentFilesDirty(false);
+ return true;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Failed to initialize agent files.";
+ setAgentFilesError(message);
+ return false;
+ } finally {
+ setAgentFilesSaving(false);
+ }
+ },
+ [agentId, client, cloneAgentFilesState]
+ );
+
const setAgentFileContent = useCallback((name: AgentFileName, value: string) => {
if (!isAgentFileName(name)) return;
@@ -167,6 +231,7 @@ export const useAgentFilesEditor = (params: {
agentFilesError,
setAgentFileContent,
saveAgentFiles,
+ initializeAgentFiles,
discardAgentFileChanges,
};
};
diff --git a/src/features/agents/operations/agentFleetHydration.ts b/src/features/agents/operations/agentFleetHydration.ts
index 6e60ecd..1592b04 100644
--- a/src/features/agents/operations/agentFleetHydration.ts
+++ b/src/features/agents/operations/agentFleetHydration.ts
@@ -63,6 +63,19 @@ type ExecApprovalsSnapshot = {
const isRecord = (value: unknown): value is Record =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
+const parseIdentityNameFromContent = (content: string): string | null => {
+ for (const line of content.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (/^##\s+/.test(trimmed)) break;
+ const normalized = trimmed.replace(/^[-*]\s*/, "");
+ const match = /^name\s*:\s*(.+)$/i.exec(normalized);
+ if (!match) continue;
+ const value = match[1]?.trim().replace(/^[*_]+|[*_]+$/g, "").trim() ?? "";
+ if (value) return value;
+ }
+ return null;
+};
+
const resolveAgentsListFromHelloSnapshot = (snapshot: unknown): AgentsListResult | null => {
if (!isRecord(snapshot)) return null;
const health = isRecord(snapshot.health) ? snapshot.health : null;
@@ -175,8 +188,41 @@ export async function hydrateAgentFleetFromGateway(params: {
}
agentsResult = {
...agentsResult,
- agents: agentsResult.agents.filter(
- (agent) => !isTemporarySkillAgentName(agent.name ?? agent.identity?.name)
+ agents: await Promise.all(
+ agentsResult.agents.map(async (agent) => {
+ const identityName =
+ typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
+ if (identityName) {
+ return agent;
+ }
+ try {
+ const result = (await params.client.call("agents.files.get", {
+ agentId: agent.id,
+ name: "IDENTITY.md",
+ })) as { file?: { missing?: unknown; content?: unknown } };
+ const file = result?.file;
+ const record =
+ file && typeof file === "object" ? (file as Record) : null;
+ if (record?.missing === true || typeof record?.content !== "string") {
+ return agent;
+ }
+ const recoveredName = parseIdentityNameFromContent(record.content);
+ if (!recoveredName) {
+ return agent;
+ }
+ return {
+ ...agent,
+ identity: {
+ ...(agent.identity ?? {}),
+ name: recoveredName,
+ },
+ };
+ } catch {
+ return agent;
+ }
+ })
+ ).then((agents) =>
+ agents.filter((agent) => !isTemporarySkillAgentName(agent.name ?? agent.identity?.name))
),
};
const mainKey = agentsResult.mainKey?.trim() || "main";
diff --git a/src/features/agents/operations/agentFleetHydrationDerivation.ts b/src/features/agents/operations/agentFleetHydrationDerivation.ts
index 0033598..3b33144 100644
--- a/src/features/agents/operations/agentFleetHydrationDerivation.ts
+++ b/src/features/agents/operations/agentFleetHydrationDerivation.ts
@@ -119,13 +119,23 @@ const normalizeExecAsk = (raw: string | null | undefined): ExecAsk | undefined =
};
const resolveAgentName = (agent: AgentsListResult["agents"][number]) => {
- const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
- if (fromList) return fromList;
const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
if (fromIdentity) return fromIdentity;
+ const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
+ if (fromList) return fromList;
return agent.id;
};
+const resolveRuntimeName = (agent: AgentsListResult["agents"][number]) => {
+ const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
+ return fromList || null;
+};
+
+const resolveIdentityName = (agent: AgentsListResult["agents"][number]) => {
+ const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
+ return fromIdentity || null;
+};
+
const resolveAgentAvatarUrl = (agent: AgentsListResult["agents"][number]) => {
const candidate = agent.identity?.avatarUrl ?? agent.identity?.avatar ?? null;
if (typeof candidate !== "string") return null;
@@ -215,7 +225,11 @@ export const deriveHydrateAgentFleetResult = (
const avatarSeed = persistedSeed ?? avatarProfile.seed ?? agent.id;
const avatarUrl = resolveAgentAvatarUrl(agent);
const name = resolveAgentName(agent);
+ const runtimeName = resolveRuntimeName(agent);
+ const identityName = resolveIdentityName(agent);
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
+ const sessionDisplayName =
+ typeof mainSession?.displayName === "string" ? mainSession.displayName.trim() || null : null;
const modelProvider =
typeof mainSession?.modelProvider === "string" ? mainSession.modelProvider.trim() : "";
const modelId = typeof mainSession?.model === "string" ? mainSession.model.trim() : "";
@@ -252,6 +266,9 @@ export const deriveHydrateAgentFleetResult = (
return {
agentId: agent.id,
name,
+ runtimeName,
+ identityName,
+ sessionDisplayName,
role: typeof agent.role === "string" && agent.role.trim() ? agent.role.trim() : null,
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
avatarSeed,
diff --git a/src/features/agents/operations/agentPermissionsOperation.ts b/src/features/agents/operations/agentPermissionsOperation.ts
index 7fb59a1..7d5fab4 100644
--- a/src/features/agents/operations/agentPermissionsOperation.ts
+++ b/src/features/agents/operations/agentPermissionsOperation.ts
@@ -1,5 +1,8 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
-import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
+import {
+ isWebchatSessionMutationBlockedError,
+ syncGatewaySessionSettings,
+} from "@/lib/gateway/GatewayClient";
import {
readGatewayAgentExecApprovals,
upsertGatewayAgentExecApprovals,
@@ -318,6 +321,32 @@ const upsertExecApprovalsPolicyForRole = async (params: {
});
};
+const syncExecutionRoleSessionSettings = async (params: {
+ client: GatewayClient;
+ sessionKey: string;
+ role: ExecutionRoleId;
+ sandboxMode?: string | null;
+}) => {
+ const execSettings = resolveSessionExecSettingsForRole({
+ role: params.role,
+ sandboxMode: params.sandboxMode ?? "",
+ });
+ try {
+ await syncGatewaySessionSettings({
+ client: params.client,
+ sessionKey: params.sessionKey,
+ execHost: execSettings.execHost,
+ execSecurity: execSettings.execSecurity,
+ execAsk: execSettings.execAsk,
+ });
+ } catch (error) {
+ if (isWebchatSessionMutationBlockedError(error)) {
+ return;
+ }
+ throw error;
+ }
+};
+
export async function updateAgentPermissionsViaStudio(params: {
client: GatewayClient;
agentId: string;
@@ -354,16 +383,11 @@ export async function updateAgentPermissionsViaStudio(params: {
overrides: toolOverrides,
});
- const execSettings = resolveSessionExecSettingsForRole({
- role,
- sandboxMode: runtimeConfigContext.sandboxMode,
- });
- await syncGatewaySessionSettings({
+ await syncExecutionRoleSessionSettings({
client: params.client,
sessionKey: params.sessionKey,
- execHost: execSettings.execHost,
- execSecurity: execSettings.execSecurity,
- execAsk: execSettings.execAsk,
+ role,
+ sandboxMode: runtimeConfigContext.sandboxMode,
});
if (params.loadAgents) {
@@ -403,16 +427,11 @@ export async function updateExecutionRoleViaStudio(params: {
overrides: toolOverrides,
});
- const execSettings = resolveSessionExecSettingsForRole({
- role: params.role,
- sandboxMode: runtimeConfigContext.sandboxMode,
- });
- await syncGatewaySessionSettings({
+ await syncExecutionRoleSessionSettings({
client: params.client,
sessionKey: params.sessionKey,
- execHost: execSettings.execHost,
- execSecurity: execSettings.execSecurity,
- execAsk: execSettings.execAsk,
+ role: params.role,
+ sandboxMode: runtimeConfigContext.sandboxMode,
});
await params.loadAgents();
diff --git a/src/features/agents/state/store.tsx b/src/features/agents/state/store.tsx
index 980c9dc..e998459 100644
--- a/src/features/agents/state/store.tsx
+++ b/src/features/agents/state/store.tsx
@@ -26,6 +26,9 @@ export type FocusFilter = "all" | "running" | "approvals";
export type AgentStoreSeed = {
agentId: string;
name: string;
+ runtimeName?: string | null;
+ identityName?: string | null;
+ sessionDisplayName?: string | null;
role?: string | null;
sessionKey: string;
avatarSeed?: string | null;
diff --git a/src/features/office/components/panels/SkillsMarketplacePanel.tsx b/src/features/office/components/panels/SkillsMarketplacePanel.tsx
index d1427e4..ea35fcd 100644
--- a/src/features/office/components/panels/SkillsMarketplacePanel.tsx
+++ b/src/features/office/components/panels/SkillsMarketplacePanel.tsx
@@ -502,7 +502,12 @@ export function SkillsMarketplacePanel({
diff --git a/src/features/office/hooks/useOfficeSkillsMarketplace.ts b/src/features/office/hooks/useOfficeSkillsMarketplace.ts
index 3a5544a..2ecac20 100644
--- a/src/features/office/hooks/useOfficeSkillsMarketplace.ts
+++ b/src/features/office/hooks/useOfficeSkillsMarketplace.ts
@@ -286,12 +286,14 @@ export const useOfficeSkillsMarketplace = ({
source: packagedSkill.installSource,
workspaceDir: report.workspaceDir,
managedSkillsDir: report.managedSkillsDir,
+ agentId: selectedAgent?.agentId ?? undefined,
+ agentName: selectedAgent?.name ?? undefined,
},
});
},
});
},
- [client, runSkillMutation]
+ [client, runSkillMutation, selectedAgent]
);
const handleInstallPackagedSkillAndEnable = useCallback(
@@ -340,6 +342,9 @@ export const useOfficeSkillsMarketplace = ({
source: packagedSkill.installSource,
workspaceDir: initialReport.workspaceDir,
managedSkillsDir: initialReport.managedSkillsDir,
+ agentId: targetAgentId,
+ agentName:
+ agents.find((agent) => agent.agentId === targetAgentId)?.name ?? undefined,
},
});
params.onProgress?.({
diff --git a/src/lib/agents/agentFiles.ts b/src/lib/agents/agentFiles.ts
index ad2e7e2..a2c3712 100644
--- a/src/lib/agents/agentFiles.ts
+++ b/src/lib/agents/agentFiles.ts
@@ -72,5 +72,5 @@ export const AGENT_FILE_PLACEHOLDERS: Record = {
export const createAgentFilesState = () =>
Object.fromEntries(
- AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }])
- ) as Record;
+ AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false, path: null, workspace: null }])
+ ) as Record;
diff --git a/src/lib/agents/personalityBuilder.ts b/src/lib/agents/personalityBuilder.ts
index 5cb5c8f..6745408 100644
--- a/src/lib/agents/personalityBuilder.ts
+++ b/src/lib/agents/personalityBuilder.ts
@@ -28,7 +28,10 @@ export type PersonalityBuilderDraft = {
memory: string;
};
-type AgentFilesInput = Record;
+type AgentFilesInput = Record<
+ AgentFileName,
+ { content: string; exists: boolean; path: string | null; workspace: string | null }
+>;
export const createEmptyPersonalityDraft = (): PersonalityBuilderDraft => ({
identity: {
diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts
index b585fdb..8cdbe5a 100644
--- a/src/lib/gateway/GatewayClient.ts
+++ b/src/lib/gateway/GatewayClient.ts
@@ -20,6 +20,7 @@ import type {
} from "@/lib/studio/coordinator";
import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
+import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import { GatewayResponseError } from "@/lib/gateway/errors";
const gatewayDebugEnabled = process.env.NODE_ENV !== "production";
@@ -117,10 +118,24 @@ const DEFAULT_UPSTREAM_GATEWAY_URL =
const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770";
const INITIAL_AUTO_CONNECT_DELAY_MS = 900;
const INITIAL_CONNECT_RETRY_DELAY_MS = 1_200;
+const OPENCLAW_CONTROL_UI_CLIENT_ID = "openclaw-control-ui";
+const OPENCLAW_WEBCHAT_UI_CLIENT_ID = "webchat-ui";
const isAutoManagedAdapter = (adapterType: StudioGatewayAdapterType) =>
adapterType !== "custom";
+export const resolveGatewayClientName = (
+ adapterType: StudioGatewayAdapterType,
+ gatewayUrl: string
+) => {
+ if (adapterType !== "openclaw") {
+ return OPENCLAW_CONTROL_UI_CLIENT_ID;
+ }
+ return isLocalGatewayUrl(gatewayUrl)
+ ? OPENCLAW_CONTROL_UI_CLIENT_ID
+ : OPENCLAW_WEBCHAT_UI_CLIENT_ID;
+};
+
export const resolveInitialGatewayAutoConnectDelayMs = (
adapterType: StudioGatewayAdapterType
): number => {
@@ -526,6 +541,15 @@ const doctorFixHint =
const protocolMismatchHint =
"This gateway looks too old for Claw3D's protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway` for a no-framework office demo.";
+const tailscaleGatewayHint =
+ "If this is a remote OpenClaw/Tailscale gateway, confirm the Studio host can reach the `wss://...` address and approve the first device pairing on the gateway host with `openclaw devices approve --latest`.";
+
+const pairingRequiredHint =
+ "This gateway is asking for first-time device approval. Run `openclaw devices approve --latest` on the gateway host, then restart Claw3D and reconnect from this browser.";
+
+const requiresDeviceIdentityHint =
+ "This gateway rejected the client as a control UI without device identity. For remote OpenClaw/Tailscale connections, update to the latest Claw3D build and approve the device pairing on the gateway host.";
+
const isGatewayProtocolMismatchError = (error: GatewayResponseError) => {
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
const message = error.message.trim();
@@ -541,6 +565,18 @@ const formatGatewayError = (error: unknown) => {
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
}
+ if (error.code === "studio.upstream_timeout") {
+ return `Gateway error (${error.code}): ${error.message} ${tailscaleGatewayHint}`;
+ }
+ if (error.code === "studio.upstream_rejected") {
+ const lower = error.message.toLowerCase();
+ if (lower.includes("pairing required")) {
+ return `Gateway error (${error.code}): ${error.message}. ${pairingRequiredHint}`;
+ }
+ if (lower.includes("device identity")) {
+ return `Gateway error (${error.code}): ${error.message}. ${requiresDeviceIdentityHint}`;
+ }
+ }
return `Gateway error (${error.code}): ${error.message}`;
}
if (error instanceof Error) {
@@ -608,6 +644,8 @@ const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([
"studio.gateway_url_invalid",
"studio.settings_load_failed",
"studio.upstream_error",
+ "studio.upstream_timeout",
+ "studio.upstream_rejected",
]);
const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
@@ -899,7 +937,7 @@ export const useGatewayConnection = (
gatewayUrl: resolveStudioProxyGatewayUrl(),
token,
authScopeKey: gatewayUrl,
- clientName: "openclaw-control-ui",
+ clientName: resolveGatewayClientName(selectedAdapterType, gatewayUrl),
disableDeviceAuth: selectedAdapterType !== "openclaw",
});
lastError = null;
diff --git a/src/lib/gateway/agentFiles.ts b/src/lib/gateway/agentFiles.ts
index c2de710..a718dac 100644
--- a/src/lib/gateway/agentFiles.ts
+++ b/src/lib/gateway/agentFiles.ts
@@ -2,7 +2,8 @@ import type { AgentFileName } from "@/lib/agents/agentFiles";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
type AgentsFilesGetResponse = {
- file?: { missing?: unknown; content?: unknown };
+ workspace?: unknown;
+ file?: { missing?: unknown; content?: unknown; path?: unknown };
};
const resolveAgentId = (value: string) => {
@@ -17,7 +18,7 @@ export const readGatewayAgentFile = async (params: {
client: GatewayClient;
agentId: string;
name: AgentFileName;
-}): Promise<{ exists: boolean; content: string }> => {
+}): Promise<{ exists: boolean; content: string; path: string | null; workspace: string | null }> => {
const agentId = resolveAgentId(params.agentId);
const response = await params.client.call("agents.files.get", {
agentId,
@@ -28,7 +29,11 @@ export const readGatewayAgentFile = async (params: {
const missing = fileRecord?.missing === true;
const content =
fileRecord && typeof fileRecord.content === "string" ? fileRecord.content : "";
- return { exists: !missing, content };
+ const path =
+ fileRecord && typeof fileRecord.path === "string" ? fileRecord.path : null;
+ const workspace =
+ typeof response?.workspace === "string" ? response.workspace : null;
+ return { exists: !missing, content, path, workspace };
};
export const writeGatewayAgentFile = async (params: {
diff --git a/src/lib/gateway/openclaw/GatewayBrowserClient.ts b/src/lib/gateway/openclaw/GatewayBrowserClient.ts
index 0e9ddf0..6c3c328 100644
--- a/src/lib/gateway/openclaw/GatewayBrowserClient.ts
+++ b/src/lib/gateway/openclaw/GatewayBrowserClient.ts
@@ -581,6 +581,15 @@ export class GatewayBrowserClient {
locale: navigator.language,
};
+ gatewayBrowserDebugLog("connect-params", {
+ clientId: params.client.id,
+ clientMode: params.client.mode,
+ disableDeviceAuth: this.opts.disableDeviceAuth,
+ isSecureContext,
+ hasToken: Boolean(authToken),
+ hasDeviceIdentity: Boolean(deviceIdentity),
+ });
+
void this.request("connect", params)
.then((hello) => {
gatewayBrowserDebugLog("hello-ok", {
diff --git a/src/lib/skills/install-gateway.ts b/src/lib/skills/install-gateway.ts
index df5d28f..031d8d8 100644
--- a/src/lib/skills/install-gateway.ts
+++ b/src/lib/skills/install-gateway.ts
@@ -5,7 +5,11 @@ import {
} from "@/lib/gateway/agentConfig";
import { getPackagedSkillById } from "@/lib/skills/catalog";
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
-import type { PackagedSkillInstallRequest, PackagedSkillInstallResult } from "@/lib/skills/types";
+import {
+ resolveWorkspaceFromAgentFiles,
+ type PackagedSkillInstallRequest,
+ type PackagedSkillInstallResult,
+} from "@/lib/skills/types";
const normalizeRequired = (value: string, field: string): string => {
const trimmed = value.trim();
@@ -15,6 +19,35 @@ const normalizeRequired = (value: string, field: string): string => {
return trimmed;
};
+const normalizeOptional = (value: string | undefined | null): string => value?.trim() ?? "";
+
+const getPathLeaf = (value: string): string => {
+ const normalized = value.replace(/[\\/]+$/, "");
+ const segments = normalized.split(/[\\/]/).filter(Boolean);
+ return segments[segments.length - 1] ?? "";
+};
+
+const isRootWorkspace = (workspaceDir: string) => {
+ const leaf = getPathLeaf(workspaceDir).toLowerCase();
+ return leaf === "workspace";
+};
+
+const validateWorkspaceInstallTarget = (params: {
+ workspaceDir: string;
+ agentId?: string;
+ agentName?: string;
+}) => {
+ if (isRootWorkspace(params.workspaceDir)) {
+ const targetLabel =
+ normalizeOptional(params.agentName) ||
+ normalizeOptional(params.agentId) ||
+ "the selected agent";
+ throw new Error(
+ `Cannot install a packaged skill because the workspace reported for ${targetLabel} resolves to the gateway root workspace (${params.workspaceDir}). Re-select the agent and refresh the marketplace before installing.`
+ );
+ }
+};
+
const escapeForJsonString = (value: string) => JSON.stringify(value);
const buildInstallerMessage = (params: {
@@ -70,7 +103,21 @@ export const installPackagedSkillViaGatewayAgent = async (params: {
throw new Error("Gateway-native packaged install currently supports workspace skills only.");
}
- const workspaceDir = normalizeRequired(params.request.workspaceDir, "workspaceDir");
+ let workspaceDir = normalizeRequired(params.request.workspaceDir, "workspaceDir");
+ if (isRootWorkspace(workspaceDir) && normalizeOptional(params.request.agentId)) {
+ const recoveredWorkspace = await resolveWorkspaceFromAgentFiles(
+ params.client,
+ normalizeOptional(params.request.agentId)
+ );
+ if (recoveredWorkspace) {
+ workspaceDir = recoveredWorkspace;
+ }
+ }
+ validateWorkspaceInstallTarget({
+ workspaceDir,
+ agentId: params.request.agentId,
+ agentName: params.request.agentName,
+ });
const files = readPackagedSkillFiles(packagedSkill.packageId);
const installerName = `Skill Installer ${Date.now()}`;
diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts
index 9f7d750..cd761c7 100644
--- a/src/lib/skills/types.ts
+++ b/src/lib/skills/types.ts
@@ -1,4 +1,5 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
+import { readGatewayAgentFile } from "@/lib/gateway/agentFiles";
export type SkillStatusConfigCheck = {
path: string;
@@ -95,6 +96,8 @@ export type PackagedSkillInstallRequest = {
source: RemovableSkillSource;
workspaceDir: string;
managedSkillsDir: string;
+ agentId?: string;
+ agentName?: string;
};
export type PackagedSkillInstallResult = {
@@ -120,13 +123,69 @@ const resolveRequiredValue = (value: string, message: string): string => {
return trimmed;
};
+const isLikelyRootWorkspace = (workspaceDir: string): boolean => {
+ const normalized = workspaceDir.trim().replace(/[\\/]+$/, "");
+ if (!normalized) return false;
+ return /[\\/]workspace$/i.test(normalized);
+};
+
+const resolveWorkspaceDirFromPath = (filePath: string | null | undefined): string | null => {
+ const normalized = filePath?.trim().replace(/[\\/]+$/, "") ?? "";
+ if (!normalized) return null;
+ const index = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
+ if (index <= 0) return null;
+ const candidate = normalized.slice(0, index).trim();
+ if (!candidate || isLikelyRootWorkspace(candidate)) {
+ return null;
+ }
+ return candidate;
+};
+
+export const resolveWorkspaceFromAgentFiles = async (
+ client: GatewayClient,
+ agentId: string
+): Promise => {
+ for (const name of ["IDENTITY.md", "SOUL.md", "AGENTS.md"] as const) {
+ try {
+ const file = await readGatewayAgentFile({ client, agentId, name });
+ const workspace = file.workspace?.trim() ?? "";
+ if (workspace && !isLikelyRootWorkspace(workspace)) {
+ return workspace;
+ }
+ const derivedFromPath = resolveWorkspaceDirFromPath(file.path);
+ if (derivedFromPath) {
+ return derivedFromPath;
+ }
+ } catch {
+ // Best-effort provenance recovery only.
+ }
+ }
+ return null;
+};
+
export const loadAgentSkillStatus = async (
client: GatewayClient,
agentId: string
): Promise => {
- return client.call("skills.status", {
- agentId: resolveAgentId(agentId),
+ const resolvedAgentId = resolveAgentId(agentId);
+ const report = await client.call("skills.status", {
+ agentId: resolvedAgentId,
});
+ const workspaceDir = report.workspaceDir?.trim() ?? "";
+ if (!workspaceDir || !isLikelyRootWorkspace(workspaceDir)) {
+ return report;
+ }
+ const recoveredWorkspace = await resolveWorkspaceFromAgentFiles(
+ client,
+ resolvedAgentId
+ );
+ if (!recoveredWorkspace) {
+ return report;
+ }
+ return {
+ ...report,
+ workspaceDir: recoveredWorkspace,
+ };
};
export const installSkill = async (
diff --git a/tests/unit/agentBrainPanel.test.ts b/tests/unit/agentBrainPanel.test.ts
index 7421ad9..846bbbb 100644
--- a/tests/unit/agentBrainPanel.test.ts
+++ b/tests/unit/agentBrainPanel.test.ts
@@ -67,10 +67,12 @@ const createMockClient = () => {
const agentId = typeof record.agentId === "string" ? record.agentId : "";
const name = typeof record.name === "string" ? record.name : "";
const content = filesByAgent[agentId]?.[name];
+ const workspace = `/workspace/${agentId}`;
+ const path = `${workspace}/${name}`;
if (typeof content !== "string") {
- return { file: { name, missing: true } };
+ return { workspace, file: { name, path, missing: true } };
}
- return { file: { name, missing: false, content } };
+ return { workspace, file: { name, path, missing: false, content } };
}
if (method === "agents.files.set") {
const record = params && typeof params === "object" ? (params as Record) : {};
@@ -117,6 +119,8 @@ describe("AgentBrainPanel", () => {
expect(screen.getByRole("heading", { name: "AGENTS.md" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "USER.md" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "IDENTITY.md" })).toBeInTheDocument();
+ expect(screen.getAllByText("Workspace:").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("/workspace/agent-1").length).toBeGreaterThan(0);
expect(screen.getByLabelText("AGENTS.md")).toHaveValue("alpha agents");
expect(screen.getByLabelText("SOUL.md")).toHaveValue(
"# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful."
@@ -217,4 +221,52 @@ describe("AgentBrainPanel", () => {
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
});
+
+ it("shows_missing_file_state_instead_of_generic_placeholder_content", async () => {
+ const { client } = createMockClient();
+ const agents = [createAgent("agent-2", "Beta", "session-2")];
+
+ render(
+ createElement(AgentBrainPanel, {
+ client,
+ agents,
+ selectedAgentId: "agent-2",
+ activeSection: "SOUL.md",
+ })
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("This agent does not have a custom SOUL.md yet. Saving here will create the real workspace file.")).toBeInTheDocument();
+ });
+
+ expect(screen.getByLabelText("SOUL.md")).toHaveValue("");
+ expect(screen.getByLabelText("SOUL.md")).toHaveAttribute("placeholder", "No SOUL.md yet.");
+ expect(screen.getByText("/workspace/agent-2/SOUL.md")).toBeInTheDocument();
+ });
+
+ it("can_initialize_missing_personality_files_for_an_agent", async () => {
+ const { client, filesByAgent } = createMockClient();
+ const agents = [createAgent("agent-2", "Beta", "session-2")];
+
+ render(
+ createElement(AgentBrainPanel, {
+ client,
+ agents,
+ selectedAgentId: "agent-2",
+ })
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Initialize missing files" })).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Initialize missing files" }));
+
+ await waitFor(() => {
+ expect(filesByAgent["agent-2"]["SOUL.md"]).toContain("# SOUL.md - Who You Are");
+ });
+
+ expect(filesByAgent["agent-2"]["IDENTITY.md"]).toContain("- Name: Beta");
+ expect(filesByAgent["agent-2"]["USER.md"]).toContain("# USER.md - About Your Human");
+ });
});
diff --git a/tests/unit/agentFleetHydration.test.ts b/tests/unit/agentFleetHydration.test.ts
index bbd8b84..00d701f 100644
--- a/tests/unit/agentFleetHydration.test.ts
+++ b/tests/unit/agentFleetHydration.test.ts
@@ -45,7 +45,7 @@ describe("hydrateAgentFleetFromGateway", () => {
{
id: "agent-1",
name: "One",
- identity: { avatarUrl: "https://example.com/one.png" },
+ identity: { name: "Main Persona", avatarUrl: "https://example.com/one.png" },
},
{
id: "agent-2",
@@ -55,6 +55,27 @@ describe("hydrateAgentFleetFromGateway", () => {
],
};
}
+ if (method === "agents.files.get") {
+ const record = params as Record;
+ if (record.agentId === "agent-2" && record.name === "IDENTITY.md") {
+ return {
+ workspace: "/tmp/workspace-agent-2",
+ file: {
+ missing: false,
+ content: "# IDENTITY.md - Who Am I?\n\n- Name: GLaDOS\n",
+ path: "/tmp/workspace-agent-2/IDENTITY.md",
+ },
+ };
+ }
+ return {
+ workspace: "/tmp/workspace-agent-1",
+ file: {
+ missing: false,
+ content: "# IDENTITY.md - Who Am I?\n\n- Name: Main Persona\n",
+ path: "/tmp/workspace-agent-1/IDENTITY.md",
+ },
+ };
+ }
if (method === "exec.approvals.get") {
return {
file: {
@@ -126,10 +147,11 @@ describe("hydrateAgentFleetFromGateway", () => {
expect(result.seeds[0]).toEqual(
expect.objectContaining({
agentId: "agent-1",
- name: "One",
+ name: "Main Persona",
+ runtimeName: "One",
+ identityName: "Main Persona",
+ sessionDisplayName: "Main",
sessionKey: "agent:agent-1:main",
- avatarSeed: "persisted-seed",
- avatarProfile: expect.objectContaining({ seed: "persisted-seed" }),
avatarUrl: "https://example.com/one.png",
model: "openai/gpt-4.1",
thinkingLevel: "medium",
@@ -141,6 +163,9 @@ describe("hydrateAgentFleetFromGateway", () => {
expect(result.seeds[1]).toEqual(
expect.objectContaining({
agentId: "agent-2",
+ name: "GLaDOS",
+ runtimeName: "Two",
+ identityName: "GLaDOS",
sessionExecHost: "gateway",
sessionExecSecurity: "full",
sessionExecAsk: "off",
diff --git a/tests/unit/agentPermissionsOperation.test.ts b/tests/unit/agentPermissionsOperation.test.ts
index c128e63..0148fe7 100644
--- a/tests/unit/agentPermissionsOperation.test.ts
+++ b/tests/unit/agentPermissionsOperation.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import {
isPermissionsCustom,
@@ -8,9 +8,64 @@ import {
resolveRoleForCommandMode,
resolveToolGroupOverrides,
resolveToolGroupStateFromConfigEntry,
+ updateAgentPermissionsViaStudio,
+ updateExecutionRoleViaStudio,
} from "@/features/agents/operations/agentPermissionsOperation";
+import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
+import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
+import {
+ readGatewayAgentExecApprovals,
+ upsertGatewayAgentExecApprovals,
+} from "@/lib/gateway/execApprovals";
+import { GatewayResponseError } from "@/lib/gateway/errors";
+
+vi.mock("@/lib/gateway/GatewayClient", async () => {
+ const actual = await vi.importActual(
+ "@/lib/gateway/GatewayClient"
+ );
+ return {
+ ...actual,
+ syncGatewaySessionSettings: vi.fn(),
+ };
+});
+
+vi.mock("@/lib/gateway/agentConfig", async () => {
+ const actual = await vi.importActual(
+ "@/lib/gateway/agentConfig"
+ );
+ return {
+ ...actual,
+ updateGatewayAgentOverrides: vi.fn(async () => undefined),
+ };
+});
+
+vi.mock("@/lib/gateway/execApprovals", () => ({
+ readGatewayAgentExecApprovals: vi.fn(async () => null),
+ upsertGatewayAgentExecApprovals: vi.fn(async () => undefined),
+}));
+
+const createWebchatBlockedPatchError = () =>
+ new GatewayResponseError({
+ code: "INVALID_REQUEST",
+ message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates",
+ });
describe("agentPermissionsOperation", () => {
+ const mockedSyncGatewaySessionSettings = vi.mocked(syncGatewaySessionSettings);
+ const mockedUpdateGatewayAgentOverrides = vi.mocked(updateGatewayAgentOverrides);
+ const mockedReadGatewayAgentExecApprovals = vi.mocked(readGatewayAgentExecApprovals);
+ const mockedUpsertGatewayAgentExecApprovals = vi.mocked(upsertGatewayAgentExecApprovals);
+
+ beforeEach(() => {
+ mockedSyncGatewaySessionSettings.mockReset();
+ mockedUpdateGatewayAgentOverrides.mockClear();
+ mockedReadGatewayAgentExecApprovals.mockReset();
+ mockedUpsertGatewayAgentExecApprovals.mockReset();
+ mockedReadGatewayAgentExecApprovals.mockResolvedValue(null);
+ mockedUpsertGatewayAgentExecApprovals.mockResolvedValue(undefined);
+ mockedUpdateGatewayAgentOverrides.mockResolvedValue(undefined);
+ });
+
it("maps command mode and preset role in both directions", () => {
expect(resolveRoleForCommandMode("off")).toBe("conservative");
expect(resolveRoleForCommandMode("ask")).toBe("collaborative");
@@ -111,4 +166,84 @@ describe("agentPermissionsOperation", () => {
})
).toBe(true);
});
+
+ it("does not fail permission updates when webchat blocks sessions.patch after config writes", async () => {
+ mockedSyncGatewaySessionSettings.mockRejectedValue(createWebchatBlockedPatchError());
+ const client = {
+ call: vi.fn(async (method: string) => {
+ if (method === "config.get") {
+ return {
+ config: {
+ agents: [
+ {
+ id: "agent-1",
+ sandbox: { mode: "workspace-write" },
+ tools: { allow: ["group:web"], deny: [] },
+ },
+ ],
+ },
+ };
+ }
+ throw new Error(`Unexpected method: ${method}`);
+ }),
+ } as never;
+ const loadAgents = vi.fn(async () => undefined);
+
+ await expect(
+ updateAgentPermissionsViaStudio({
+ client,
+ agentId: "agent-1",
+ sessionKey: "session-1",
+ draft: {
+ commandMode: "ask",
+ webAccess: true,
+ fileTools: false,
+ },
+ loadAgents,
+ })
+ ).resolves.toBeUndefined();
+
+ expect(mockedUpsertGatewayAgentExecApprovals).toHaveBeenCalledTimes(1);
+ expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1);
+ expect(mockedSyncGatewaySessionSettings).toHaveBeenCalledTimes(1);
+ expect(loadAgents).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not fail execution-role updates when webchat blocks sessions.patch after config writes", async () => {
+ mockedSyncGatewaySessionSettings.mockRejectedValue(createWebchatBlockedPatchError());
+ const client = {
+ call: vi.fn(async (method: string) => {
+ if (method === "config.get") {
+ return {
+ config: {
+ agents: [
+ {
+ id: "agent-1",
+ sandbox: { mode: "workspace-write" },
+ tools: { allow: ["group:web"], deny: [] },
+ },
+ ],
+ },
+ };
+ }
+ throw new Error(`Unexpected method: ${method}`);
+ }),
+ } as never;
+ const loadAgents = vi.fn(async () => undefined);
+
+ await expect(
+ updateExecutionRoleViaStudio({
+ client,
+ agentId: "agent-1",
+ sessionKey: "session-1",
+ role: "autonomous",
+ loadAgents,
+ })
+ ).resolves.toBeUndefined();
+
+ expect(mockedUpsertGatewayAgentExecApprovals).toHaveBeenCalledTimes(1);
+ expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1);
+ expect(mockedSyncGatewaySessionSettings).toHaveBeenCalledTimes(1);
+ expect(loadAgents).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/tests/unit/gatewayConnectRetryPolicy.test.ts b/tests/unit/gatewayConnectRetryPolicy.test.ts
index 148a79a..b5327dd 100644
--- a/tests/unit/gatewayConnectRetryPolicy.test.ts
+++ b/tests/unit/gatewayConnectRetryPolicy.test.ts
@@ -33,5 +33,37 @@ describe("resolveGatewayAutoRetryDelayMs", () => {
expect(delay).toBeNull();
});
+
+ it("does not retry when the upstream websocket handshake times out", () => {
+ const delay = resolveGatewayAutoRetryDelayMs({
+ status: "disconnected",
+ didAutoConnect: true,
+ hasConnectedOnce: true,
+ wasManualDisconnect: false,
+ gatewayUrl: "wss://remote.example",
+ errorMessage:
+ "Gateway error (studio.upstream_timeout): Timed out connecting Studio to the upstream gateway WebSocket.",
+ connectErrorCode: "studio.upstream_timeout",
+ attempt: 0,
+ });
+
+ expect(delay).toBeNull();
+ });
+
+ it("does not retry when the upstream gateway explicitly rejects pairing", () => {
+ const delay = resolveGatewayAutoRetryDelayMs({
+ status: "disconnected",
+ didAutoConnect: true,
+ hasConnectedOnce: true,
+ wasManualDisconnect: false,
+ gatewayUrl: "wss://remote.example",
+ errorMessage:
+ "Gateway error (studio.upstream_rejected): Upstream gateway rejected connect (1008): pairing required.",
+ connectErrorCode: "studio.upstream_rejected",
+ attempt: 0,
+ });
+
+ expect(delay).toBeNull();
+ });
});
diff --git a/tests/unit/gatewayProxy.test.ts b/tests/unit/gatewayProxy.test.ts
index 8cac297..1ff5153 100644
--- a/tests/unit/gatewayProxy.test.ts
+++ b/tests/unit/gatewayProxy.test.ts
@@ -643,4 +643,73 @@ describe("createGatewayProxy", () => {
]);
}
});
+
+ it("surfaces upstream pairing rejection before browser close", async () => {
+ const upstream = new WebSocketServer({ port: 0 });
+ const address = upstream.address();
+ if (!address || typeof address === "string") {
+ throw new Error("expected upstream server to have a port");
+ }
+ const upstreamUrl = `ws://127.0.0.1:${address.port}`;
+
+ upstream.on("connection", (ws) => {
+ ws.on("message", (raw) => {
+ const parsed = JSON.parse(String(raw));
+ if (parsed?.method === "connect") {
+ ws.close(1008, "pairing required");
+ }
+ });
+ });
+
+ const { createGatewayProxy } = await import("../../server/gateway-proxy");
+
+ const proxyHttp = await import("node:http").then((m) => m.createServer());
+ const proxy = createGatewayProxy({
+ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "host-token-456" }),
+ allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
+ logError: () => {},
+ });
+ proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
+
+ await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
+ const proxyAddr = proxyHttp.address();
+ if (!proxyAddr || typeof proxyAddr === "string") {
+ throw new Error("expected proxy server to have a port");
+ }
+
+ const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
+ try {
+ await waitForEvent(browser, "open");
+ browser.send(
+ JSON.stringify({
+ type: "req",
+ id: "connect-pairing-required",
+ method: "connect",
+ params: { auth: {} },
+ })
+ );
+
+ const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
+ const response = JSON.parse(String(rawMessage ?? ""));
+ expect(response).toMatchObject({
+ type: "res",
+ id: "connect-pairing-required",
+ ok: false,
+ error: {
+ code: "studio.upstream_rejected",
+ message: "Upstream gateway rejected connect (1008): pairing required",
+ },
+ });
+ } finally {
+ for (const client of upstream.clients) {
+ client.close();
+ }
+ await Promise.all([
+ closeWebSocket(browser),
+ closeWebSocketServer(upstream),
+ closeHttpServer(proxyHttp),
+ ]);
+ }
+ });
+
});
diff --git a/tests/unit/personalityBuilder.test.ts b/tests/unit/personalityBuilder.test.ts
index 284eb3c..dfb3ed8 100644
--- a/tests/unit/personalityBuilder.test.ts
+++ b/tests/unit/personalityBuilder.test.ts
@@ -14,7 +14,10 @@ describe("personalityBuilder", () => {
const files = createFiles();
files["IDENTITY.md"] = {
exists: true,
- content: `# IDENTITY.md - Who Am I?\n\n- **Name:** Nova\n- **Creature:** fox spirit\n- **Vibe:** calm + direct\n- **Emoji:** 🦊\n- **Avatar:** avatars/nova.png\n`,
+ content:
+ "# IDENTITY.md - Who Am I?\n\n- **Name:** Nova\n- **Creature:** fox spirit\n- **Vibe:** calm + direct\n- **Emoji:** fox\n- **Avatar:** avatars/nova.png\n",
+ path: null,
+ workspace: null,
};
const draft = parsePersonalityFiles(files);
@@ -23,7 +26,7 @@ describe("personalityBuilder", () => {
name: "Nova",
creature: "fox spirit",
vibe: "calm + direct",
- emoji: "🦊",
+ emoji: "fox",
avatar: "avatars/nova.png",
});
});
@@ -32,7 +35,10 @@ describe("personalityBuilder", () => {
const files = createFiles();
files["USER.md"] = {
exists: true,
- content: `# USER.md - About Your Human\n\n- **Name:** George\n- **What to call them:** GP\n- **Pronouns:** he/him\n- **Timezone:** America/Chicago\n- **Notes:** Building Claw3D.\n\n## Context\n\nWants concise technical answers.\nPrefers implementation over discussion.\n`,
+ content:
+ "# USER.md - About Your Human\n\n- **Name:** George\n- **What to call them:** GP\n- **Pronouns:** he/him\n- **Timezone:** America/Chicago\n- **Notes:** Building Claw3D.\n\n## Context\n\nWants concise technical answers.\nPrefers implementation over discussion.\n",
+ path: null,
+ workspace: null,
};
const draft = parsePersonalityFiles(files);
@@ -51,7 +57,10 @@ describe("personalityBuilder", () => {
const files = createFiles();
files["SOUL.md"] = {
exists: true,
- content: `# SOUL.md - Who You Are\n\n## Core Truths\n\nBe direct.\nAvoid filler.\n\n## Boundaries\n\n- Keep user data private.\n\n## Vibe\n\nPragmatic and calm.\n\n## Continuity\n\nUpdate files when behavior changes.\n`,
+ content:
+ "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe direct.\nAvoid filler.\n\n## Boundaries\n\n- Keep user data private.\n\n## Vibe\n\nPragmatic and calm.\n\n## Continuity\n\nUpdate files when behavior changes.\n",
+ path: null,
+ workspace: null,
};
const draft = parsePersonalityFiles(files);
@@ -69,12 +78,16 @@ describe("personalityBuilder", () => {
files["IDENTITY.md"] = {
exists: true,
content:
- "# IDENTITY.md - Who Am I?\n\n- **Name:** _(pick something you like)_\n- **Creature:** _(AI? robot? familiar? ghost in the machine? something weirder?)_\n- **Vibe:** _(how do you come across? sharp? warm? chaotic? calm?)_\n- **Emoji:** _(your signature — pick one that feels right)_\n- **Avatar:** _(workspace-relative path, http(s) URL, or data URI)_\n",
+ "# IDENTITY.md - Who Am I?\n\n- **Name:** _(pick something you like)_\n- **Creature:** _(AI? robot? familiar? ghost in the machine? something weirder?)_\n- **Vibe:** _(how do you come across? sharp? warm? chaotic? calm?)_\n- **Emoji:** _(your signature - pick one that feels right)_\n- **Avatar:** _(workspace-relative path, http(s) URL, or data URI)_\n",
+ path: null,
+ workspace: null,
};
files["USER.md"] = {
exists: true,
content:
"# USER.md - About Your Human\n\n- **Name:**\n- **What to call them:**\n- **Pronouns:** _(optional)_\n- **Timezone:**\n- **Notes:**\n\n## Context\n\n_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_\n",
+ path: null,
+ workspace: null,
};
const draft = parsePersonalityFiles(files);
@@ -102,7 +115,7 @@ describe("personalityBuilder", () => {
name: "Nova",
creature: "fox spirit",
vibe: "calm + direct",
- emoji: "🦊",
+ emoji: "fox",
avatar: "avatars/nova.png",
},
user: {
@@ -134,7 +147,7 @@ describe("personalityBuilder", () => {
"- Name: Nova",
"- Creature: fox spirit",
"- Vibe: calm + direct",
- "- Emoji: 🦊",
+ "- Emoji: fox",
"- Avatar: avatars/nova.png",
"",
].join("\n")
diff --git a/tests/unit/skillsGatewayClient.test.ts b/tests/unit/skillsGatewayClient.test.ts
index 86ccb57..152eab3 100644
--- a/tests/unit/skillsGatewayClient.test.ts
+++ b/tests/unit/skillsGatewayClient.test.ts
@@ -20,6 +20,73 @@ describe("skills gateway client", () => {
expect(result).toBe(report);
});
+ it("repairs root workspace reports using agent file provenance", async () => {
+ const client = {
+ call: vi.fn(async (method: string, params?: Record) => {
+ if (method === "skills.status") {
+ return {
+ workspaceDir: "/home/pi/.openclaw/workspace",
+ managedSkillsDir: "/home/pi/.openclaw/skills",
+ skills: [],
+ };
+ }
+ if (method === "agents.files.get") {
+ expect(params).toEqual({
+ agentId: "main",
+ name: "IDENTITY.md",
+ });
+ return {
+ workspace: "/home/pi/.openclaw/workspace-main",
+ file: {
+ missing: false,
+ content: "# IDENTITY",
+ path: "/home/pi/.openclaw/workspace-main/IDENTITY.md",
+ },
+ };
+ }
+ throw new Error(`Unexpected method: ${method}`);
+ }),
+ } as unknown as GatewayClient;
+
+ const result = await loadAgentSkillStatus(client, "main");
+
+ expect(result.workspaceDir).toBe("/home/pi/.openclaw/workspace-main");
+ expect(client.call).toHaveBeenNthCalledWith(1, "skills.status", { agentId: "main" });
+ expect(client.call).toHaveBeenNthCalledWith(2, "agents.files.get", {
+ agentId: "main",
+ name: "IDENTITY.md",
+ });
+ });
+
+ it("derives workspace from file path when agents.files.get reports the root workspace", async () => {
+ const client = {
+ call: vi.fn(async (method: string) => {
+ if (method === "skills.status") {
+ return {
+ workspaceDir: "/home/pi/.openclaw/workspace",
+ managedSkillsDir: "/home/pi/.openclaw/skills",
+ skills: [],
+ };
+ }
+ if (method === "agents.files.get") {
+ return {
+ workspace: "/home/pi/.openclaw/workspace",
+ file: {
+ missing: false,
+ content: "# IDENTITY",
+ path: "/home/pi/.openclaw/workspace-main/IDENTITY.md",
+ },
+ };
+ }
+ throw new Error(`Unexpected method: ${method}`);
+ }),
+ } as unknown as GatewayClient;
+
+ const result = await loadAgentSkillStatus(client, "main");
+
+ expect(result.workspaceDir).toBe("/home/pi/.openclaw/workspace-main");
+ });
+
it("fails fast when agent id is empty", async () => {
const client = {
call: vi.fn(),
diff --git a/tests/unit/skillsInstallGateway.test.ts b/tests/unit/skillsInstallGateway.test.ts
index 3bda332..455db2a 100644
--- a/tests/unit/skillsInstallGateway.test.ts
+++ b/tests/unit/skillsInstallGateway.test.ts
@@ -124,4 +124,92 @@ describe("skills install gateway", () => {
})
);
});
+
+ it("rejects installs when the gateway reports the global root workspace", async () => {
+ const call = vi.fn();
+
+ await expect(
+ installPackagedSkillViaGatewayAgent({
+ client: { call } as unknown as GatewayClient,
+ request: {
+ packageId: "todo-board",
+ source: "openclaw-workspace",
+ workspaceDir: "/home/pi/.openclaw/workspace",
+ managedSkillsDir: "/home/pi/.openclaw/skills",
+ agentId: "soundclaw",
+ agentName: "soundclaw",
+ },
+ })
+ ).rejects.toThrow(/gateway root workspace/i);
+
+ expect(call).toHaveBeenCalledTimes(3);
+ expect(call).toHaveBeenNthCalledWith(1, "agents.files.get", {
+ agentId: "soundclaw",
+ name: "IDENTITY.md",
+ });
+ });
+
+ it("repairs the workspace from agent file provenance before creating the installer agent", async () => {
+ const call = vi.fn(async (method: string, params?: Record) => {
+ if (method === "agents.files.get") {
+ expect(params).toEqual({ agentId: "main", name: "IDENTITY.md" });
+ return {
+ workspace: "/home/pi/.openclaw/workspace",
+ file: {
+ missing: false,
+ content: "# IDENTITY",
+ path: "/home/pi/.openclaw/workspace-main/IDENTITY.md",
+ },
+ };
+ }
+ if (method === "agents.create") {
+ return { agentId: "installer-3" };
+ }
+ if (method === "config.get") {
+ return {
+ exists: true,
+ hash: "hash-3",
+ config: {
+ agents: {
+ list: [{ id: "installer-3", tools: {} }],
+ },
+ },
+ };
+ }
+ if (method === "config.set") {
+ return { ok: true };
+ }
+ if (method === "config.patch") {
+ return { ok: true };
+ }
+ if (method === "agents.list") {
+ return { mainKey: "main" };
+ }
+ if (method === "chat.send") {
+ return { runId: "run-3", status: "started" };
+ }
+ if (method === "agent.wait") {
+ return { ok: true };
+ }
+ throw new Error(`Unexpected method: ${method}`);
+ });
+
+ const result = await installPackagedSkillViaGatewayAgent({
+ client: { call } as unknown as GatewayClient,
+ request: {
+ packageId: "todo-board",
+ source: "openclaw-workspace",
+ workspaceDir: "/home/pi/.openclaw/workspace",
+ managedSkillsDir: "/home/pi/.openclaw/skills",
+ agentId: "main",
+ agentName: "main",
+ },
+ });
+
+ expect(result.installedPath).toBe("/home/pi/.openclaw/workspace-main/skills/todo-board");
+ expect(call).toHaveBeenCalledWith("agents.create", {
+ name: expect.stringContaining("Skill Installer"),
+ workspace: "/home/pi/.openclaw/workspace-main",
+ });
+ });
});
diff --git a/tests/unit/useGatewayConnection.test.ts b/tests/unit/useGatewayConnection.test.ts
index 5d57f49..354db7b 100644
--- a/tests/unit/useGatewayConnection.test.ts
+++ b/tests/unit/useGatewayConnection.test.ts
@@ -15,10 +15,16 @@ const setupAndImportHook = async (gatewayUrl: string | null) => {
vi.resetModules();
vi.spyOn(console, "info").mockImplementation(() => {});
- const captured: { url: string | null; token: unknown; authScopeKey: unknown } = {
+ const captured: {
+ url: string | null;
+ token: unknown;
+ authScopeKey: unknown;
+ clientName: unknown;
+ } = {
url: null,
token: null,
authScopeKey: null,
+ clientName: null,
};
vi.doMock("../../src/lib/gateway/openclaw/GatewayBrowserClient", () => {
@@ -35,6 +41,7 @@ const setupAndImportHook = async (gatewayUrl: string | null) => {
captured.url = typeof opts.url === "string" ? opts.url : null;
captured.token = "token" in opts ? opts.token : null;
captured.authScopeKey = "authScopeKey" in opts ? opts.authScopeKey : null;
+ captured.clientName = "clientName" in opts ? opts.clientName : null;
this.opts = {
onHello: typeof opts.onHello === "function" ? (opts.onHello as (hello: unknown) => void) : undefined,
onEvent: typeof opts.onEvent === "function" ? (opts.onEvent as (event: unknown) => void) : undefined,
@@ -186,6 +193,99 @@ describe("useGatewayConnection", () => {
});
expect(captured.token).toBe("");
expect(captured.authScopeKey).toBe("wss://remote.example");
+ expect(captured.clientName).toBe("openclaw-control-ui");
+ });
+
+ it("uses_webchat_identity_for_remote_openclaw_connections", async () => {
+ const { useGatewayConnection, captured } = await setupAndImportHook(null);
+ const coordinator = {
+ loadSettings: async () => null,
+ loadSettingsEnvelope: async () => ({
+ settings: {
+ version: 1,
+ gateway: {
+ url: "wss://pi5.myth-coho.ts.net",
+ token: "shared-token",
+ adapterType: "openclaw",
+ lastKnownGood: {
+ url: "wss://pi5.myth-coho.ts.net",
+ token: "shared-token",
+ adapterType: "openclaw",
+ },
+ },
+ focused: {},
+ avatars: {},
+ analytics: {},
+ voiceReplies: {},
+ office: {},
+ deskAssignments: {},
+ standup: {},
+ taskBoard: {},
+ },
+ localGatewayDefaults: null,
+ }),
+ schedulePatch: () => {},
+ flushPending: async () => {},
+ };
+
+ const Probe = () => {
+ useGatewayConnection(coordinator);
+ return createElement("div", null, "ok");
+ };
+
+ render(createElement(Probe));
+
+ await waitFor(() => {
+ expect(captured.url).toBe("ws://localhost:3000/api/gateway/ws");
+ });
+ expect(captured.authScopeKey).toBe("wss://pi5.myth-coho.ts.net");
+ expect(captured.clientName).toBe("webchat-ui");
+ });
+
+ it("keeps_control_ui_identity_for_local_openclaw_connections", async () => {
+ const { useGatewayConnection, captured } = await setupAndImportHook(null);
+ const coordinator = {
+ loadSettings: async () => null,
+ loadSettingsEnvelope: async () => ({
+ settings: {
+ version: 1,
+ gateway: {
+ url: "ws://localhost:18789",
+ token: "shared-token",
+ adapterType: "openclaw",
+ lastKnownGood: {
+ url: "ws://localhost:18789",
+ token: "shared-token",
+ adapterType: "openclaw",
+ },
+ },
+ focused: {},
+ avatars: {},
+ analytics: {},
+ voiceReplies: {},
+ office: {},
+ deskAssignments: {},
+ standup: {},
+ taskBoard: {},
+ },
+ localGatewayDefaults: null,
+ }),
+ schedulePatch: () => {},
+ flushPending: async () => {},
+ };
+
+ const Probe = () => {
+ useGatewayConnection(coordinator);
+ return createElement("div", null, "ok");
+ };
+
+ render(createElement(Probe));
+
+ await waitFor(() => {
+ expect(captured.url).toBe("ws://localhost:3000/api/gateway/ws");
+ });
+ expect(captured.authScopeKey).toBe("ws://localhost:18789");
+ expect(captured.clientName).toBe("openclaw-control-ui");
});
it("does_not_auto_connect_without_a_last_known_good_state", async () => {
@@ -257,6 +357,19 @@ describe("useGatewayConnection", () => {
expect(mod.resolveInitialGatewayConnectAttemptCount("openclaw", true)).toBe(1);
});
+ it("uses_webchat_client_id_only_for_remote_openclaw", async () => {
+ const mod = await import("@/lib/gateway/GatewayClient");
+ expect(mod.resolveGatewayClientName("openclaw", "wss://pi5.myth-coho.ts.net")).toBe(
+ "webchat-ui"
+ );
+ expect(mod.resolveGatewayClientName("openclaw", "ws://localhost:18789")).toBe(
+ "openclaw-control-ui"
+ );
+ expect(mod.resolveGatewayClientName("hermes", "ws://localhost:18789")).toBe(
+ "openclaw-control-ui"
+ );
+ });
+
it("auto_applies_runtime_local_defaults_when_no_saved_gateway_and_build_time_empty", async () => {
// Simulates #57: NEXT_PUBLIC_GATEWAY_URL was never rebuilt, but
// CLAW3D_GATEWAY_URL is set on the server so localGatewayDefaults