Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/app/(app)/claw/hooks/useGatewayUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions services/kiloclaw/controller/src/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion services/kiloclaw/controller/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
16 changes: 11 additions & 5 deletions services/kiloclaw/src/routes/access-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`;
}

Expand Down
Loading