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
This commit is contained in:
gsknnft
2026-04-03 18:57:36 -04:00
committed by GitHub
parent 4be98d7080
commit a18c8c630c
28 changed files with 1174 additions and 76 deletions
+54 -2
View File
@@ -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<string, unknown>) : {};
@@ -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");
});
});
+29 -4
View File
@@ -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<string, unknown>;
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",
+136 -1
View File
@@ -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<typeof import("@/lib/gateway/GatewayClient")>(
"@/lib/gateway/GatewayClient"
);
return {
...actual,
syncGatewaySessionSettings: vi.fn(),
};
});
vi.mock("@/lib/gateway/agentConfig", async () => {
const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>(
"@/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);
});
});
@@ -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();
});
});
+69
View File
@@ -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<void>((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),
]);
}
});
});
+20 -7
View File
@@ -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")
+67
View File
@@ -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<string, unknown>) => {
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(),
+88
View File
@@ -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<string, unknown>) => {
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",
});
});
});
+114 -1
View File
@@ -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