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}
-