diff --git a/apps/web/src/app/(app)/claw/hooks/useGatewayUrl.ts b/apps/web/src/app/(app)/claw/hooks/useGatewayUrl.ts index c192e97d5c..17f5a6bccc 100644 --- a/apps/web/src/app/(app)/claw/hooks/useGatewayUrl.ts +++ b/apps/web/src/app/(app)/claw/hooks/useGatewayUrl.ts @@ -7,7 +7,7 @@ export function useGatewayUrl(status: KiloClawDashboardStatus | undefined) { if (!status?.userId) return baseUrl; const params = new URLSearchParams({ userId: status.userId }); // Instance-keyed instances need the instanceId so the access gateway - // can resolve the correct sandboxId and redirect to /i/{instanceId}/. + // can resolve the correct sandboxId and set the active-instance cookie. if (status.instanceId) { params.set('instanceId', status.instanceId); } diff --git a/services/kiloclaw/controller/src/proxy.test.ts b/services/kiloclaw/controller/src/proxy.test.ts index c94fb2f892..e3e22d5b9b 100644 --- a/services/kiloclaw/controller/src/proxy.test.ts +++ b/services/kiloclaw/controller/src/proxy.test.ts @@ -212,7 +212,9 @@ describe('WebSocket proxy', () => { const req = createIncomingMessage({ 'x-kiloclaw-proxy-token': 'token-1', host: 'acct-xxxx.fly.dev', + forwarded: 'for=1.2.3.4;proto=https', 'x-forwarded-for': '1.2.3.4', + 'x-forwarded-proto': 'https', 'x-real-ip': '1.2.3.4', 'x-forwarded-host': 'claw.kilo.ai', }); @@ -265,7 +267,9 @@ describe('WebSocket proxy', () => { expect(forwarded?.['host']).toBe('127.0.0.1:3001'); // Upstream proxy headers must be stripped so the gateway's // isLocalDirectRequest check doesn't treat the request as proxied/remote. + expect(forwarded?.['forwarded']).toBeUndefined(); expect(forwarded?.['x-forwarded-for']).toBeUndefined(); + expect(forwarded?.['x-forwarded-proto']).toBeUndefined(); expect(forwarded?.['x-real-ip']).toBeUndefined(); expect(forwarded?.['x-forwarded-host']).toBeUndefined(); expect((clientSocket as unknown as FakeSocket).pipe).toHaveBeenCalledWith(backendSocket); diff --git a/services/kiloclaw/controller/src/proxy.ts b/services/kiloclaw/controller/src/proxy.ts index b9acb6897e..85e904720e 100644 --- a/services/kiloclaw/controller/src/proxy.ts +++ b/services/kiloclaw/controller/src/proxy.ts @@ -152,10 +152,12 @@ export function handleWebSocketUpgrade( const forwardedHeaders = { ...req.headers }; delete forwardedHeaders['x-kiloclaw-proxy-token']; // Rewrite Host so the gateway sees a loopback origin (matching the HTTP proxy path). - // Strip forwarded-* headers injected by upstream proxies (Fly, CF) so the gateway's + // Strip proxy/forwarding headers injected by upstream proxies (Fly, CF) so the gateway's // isLocalDirectRequest check doesn't conclude the request came from a remote client. forwardedHeaders['host'] = `${backendHost}:${backendPort}`; + delete forwardedHeaders['forwarded']; delete forwardedHeaders['x-forwarded-for']; + delete forwardedHeaders['x-forwarded-proto']; delete forwardedHeaders['x-real-ip']; delete forwardedHeaders['x-forwarded-host']; diff --git a/services/kiloclaw/src/routes/access-gateway.ts b/services/kiloclaw/src/routes/access-gateway.ts index 7507fd15b4..6c39b48a16 100644 --- a/services/kiloclaw/src/routes/access-gateway.ts +++ b/services/kiloclaw/src/routes/access-gateway.ts @@ -117,10 +117,11 @@ async function resolveSandboxId( /** * Build the redirect URL after successful auth. * - * For instance-keyed instances: /i/{instanceId}/#token={token} - * → subsequent requests go through the /i/:instanceId/* proxy route - * For legacy: /#token={token} - * → subsequent requests go through the catch-all proxy route + * Always redirects to /#token={token} — the catch-all proxy uses the + * kiloclaw-active-instance cookie (set before this redirect) to route + * requests to the correct instance. The /i/{instanceId} prefix must + * never appear in the redirect URL because the OpenClaw SPA would use + * it as the WebSocket base path, bypassing cookie-based routing. */ async function buildRedirectUrl( userId: string, @@ -130,7 +131,12 @@ async function buildRedirectUrl( if (!env.GATEWAY_TOKEN_SECRET) return '/'; const sandboxId = await resolveSandboxId(userId, env, instanceId); const token = await deriveGatewayToken(sandboxId, env.GATEWAY_TOKEN_SECRET); - const basePath = instanceId && isValidInstanceId(instanceId) ? `/i/${instanceId}/` : '/'; + // Always redirect to '/' — never include the /i/{instanceId} prefix. + // The kiloclaw-active-instance cookie (set before this redirect) tells + // the catch-all proxy which instance to route to. Exposing the prefix + // in the URL would leak it to the OpenClaw SPA, which would then use + // it as the WebSocket target — bypassing cookie-based routing entirely. + const basePath = '/'; return `${basePath}#token=${token}`; }