Skip to content

Commit 4f42c03

Browse files
authored
gateway: fix global Control UI 404s for symlinked wrappers and bundled package roots (#40385)
Merged via squash. Prepared head SHA: 567b3ed Co-authored-by: velvet-shark <[email protected]> Co-authored-by: velvet-shark <[email protected]> Reviewed-by: @velvet-shark
1 parent 13bd3db commit 4f42c03

File tree

9 files changed

+404
-41
lines changed

9 files changed

+404
-41
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
4646
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
4747
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
4848
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
49+
- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @velvet-shark.
4950

5051
## 2026.3.7
5152

src/cli/daemon-cli/lifecycle.test.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,16 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
3636
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
3737
const resolveGatewayPort = vi.fn(() => 18789);
3838
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
39-
const probeGateway =
40-
vi.fn<
41-
(opts: {
42-
url: string;
43-
auth?: { token?: string; password?: string };
44-
timeoutMs: number;
45-
}) => Promise<{
46-
ok: boolean;
47-
configSnapshot: unknown;
48-
}>
49-
>();
39+
const probeGateway = vi.fn<
40+
(opts: {
41+
url: string;
42+
auth?: { token?: string; password?: string };
43+
timeoutMs: number;
44+
}) => Promise<{
45+
ok: boolean;
46+
configSnapshot: unknown;
47+
}>
48+
>();
5049
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
5150
const loadConfig = vi.fn(() => ({}));
5251

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import fs from "node:fs/promises";
2+
import type { IncomingMessage } from "node:http";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
7+
const { resolveControlUiRootSyncMock, isPackageProvenControlUiRootSyncMock } = vi.hoisted(() => ({
8+
resolveControlUiRootSyncMock: vi.fn(),
9+
isPackageProvenControlUiRootSyncMock: vi.fn().mockReturnValue(true),
10+
}));
11+
12+
vi.mock("../infra/control-ui-assets.js", async (importOriginal) => {
13+
const actual = await importOriginal<typeof import("../infra/control-ui-assets.js")>();
14+
return {
15+
...actual,
16+
resolveControlUiRootSync: resolveControlUiRootSyncMock,
17+
isPackageProvenControlUiRootSync: isPackageProvenControlUiRootSyncMock,
18+
};
19+
});
20+
21+
const { handleControlUiHttpRequest } = await import("./control-ui.js");
22+
const { makeMockHttpResponse } = await import("./test-http-response.js");
23+
24+
async function withControlUiRoot<T>(fn: (tmp: string) => Promise<T>) {
25+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-auto-root-"));
26+
try {
27+
await fs.writeFile(path.join(tmp, "index.html"), "<html>fallback</html>\n");
28+
return await fn(tmp);
29+
} finally {
30+
await fs.rm(tmp, { recursive: true, force: true });
31+
}
32+
}
33+
34+
afterEach(() => {
35+
resolveControlUiRootSyncMock.mockReset();
36+
isPackageProvenControlUiRootSyncMock.mockReset();
37+
isPackageProvenControlUiRootSyncMock.mockReturnValue(true);
38+
});
39+
40+
describe("handleControlUiHttpRequest auto-detected root", () => {
41+
it("serves hardlinked asset files for bundled auto-detected roots", async () => {
42+
await withControlUiRoot(async (tmp) => {
43+
const assetsDir = path.join(tmp, "assets");
44+
await fs.mkdir(assetsDir, { recursive: true });
45+
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
46+
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
47+
resolveControlUiRootSyncMock.mockReturnValue(tmp);
48+
49+
const { res, end } = makeMockHttpResponse();
50+
const handled = handleControlUiHttpRequest(
51+
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
52+
res,
53+
);
54+
55+
expect(handled).toBe(true);
56+
expect(res.statusCode).toBe(200);
57+
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("console.log('hi');");
58+
});
59+
});
60+
61+
it("serves hardlinked SPA fallback index.html for bundled auto-detected roots", async () => {
62+
await withControlUiRoot(async (tmp) => {
63+
const sourceIndex = path.join(tmp, "index.source.html");
64+
const indexPath = path.join(tmp, "index.html");
65+
await fs.writeFile(sourceIndex, "<html>fallback-hardlink</html>\n");
66+
await fs.rm(indexPath);
67+
await fs.link(sourceIndex, indexPath);
68+
resolveControlUiRootSyncMock.mockReturnValue(tmp);
69+
70+
const { res, end } = makeMockHttpResponse();
71+
const handled = handleControlUiHttpRequest(
72+
{ url: "/dashboard", method: "GET" } as IncomingMessage,
73+
res,
74+
);
75+
76+
expect(handled).toBe(true);
77+
expect(res.statusCode).toBe(200);
78+
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("<html>fallback-hardlink</html>\n");
79+
});
80+
});
81+
82+
it("rejects hardlinked assets for non-package-proven auto-detected roots", async () => {
83+
isPackageProvenControlUiRootSyncMock.mockReturnValue(false);
84+
await withControlUiRoot(async (tmp) => {
85+
const assetsDir = path.join(tmp, "assets");
86+
await fs.mkdir(assetsDir, { recursive: true });
87+
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
88+
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
89+
resolveControlUiRootSyncMock.mockReturnValue(tmp);
90+
91+
const { res } = makeMockHttpResponse();
92+
const handled = handleControlUiHttpRequest(
93+
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
94+
res,
95+
);
96+
97+
expect(handled).toBe(true);
98+
expect(res.statusCode).toBe(404);
99+
});
100+
});
101+
});

src/gateway/control-ui.http.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,15 @@ describe("handleControlUiHttpRequest", () => {
4545
method: "GET" | "HEAD" | "POST";
4646
rootPath: string;
4747
basePath?: string;
48+
rootKind?: "resolved" | "bundled";
4849
}) {
4950
const { res, end } = makeMockHttpResponse();
5051
const handled = handleControlUiHttpRequest(
5152
{ url: params.url, method: params.method } as IncomingMessage,
5253
res,
5354
{
5455
...(params.basePath ? { basePath: params.basePath } : {}),
55-
root: { kind: "resolved", path: params.rootPath },
56+
root: { kind: params.rootKind ?? "resolved", path: params.rootPath },
5657
},
5758
);
5859
return { res, end, handled };
@@ -326,6 +327,72 @@ describe("handleControlUiHttpRequest", () => {
326327
});
327328
});
328329

330+
it("rejects hardlinked index.html for non-package control-ui roots", async () => {
331+
await withControlUiRoot({
332+
fn: async (tmp) => {
333+
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-index-hardlink-"));
334+
try {
335+
const outsideIndex = path.join(outsideDir, "index.html");
336+
await fs.writeFile(outsideIndex, "<html>outside-hardlink</html>\n");
337+
await fs.rm(path.join(tmp, "index.html"));
338+
await fs.link(outsideIndex, path.join(tmp, "index.html"));
339+
340+
const { res, end, handled } = runControlUiRequest({
341+
url: "/",
342+
method: "GET",
343+
rootPath: tmp,
344+
});
345+
expectNotFoundResponse({ handled, res, end });
346+
} finally {
347+
await fs.rm(outsideDir, { recursive: true, force: true });
348+
}
349+
},
350+
});
351+
});
352+
353+
it("rejects hardlinked asset files for custom/resolved roots (security boundary)", async () => {
354+
await withControlUiRoot({
355+
fn: async (tmp) => {
356+
const assetsDir = path.join(tmp, "assets");
357+
await fs.mkdir(assetsDir, { recursive: true });
358+
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
359+
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
360+
361+
const { res, end, handled } = runControlUiRequest({
362+
url: "/assets/app.hl.js",
363+
method: "GET",
364+
rootPath: tmp,
365+
});
366+
367+
expect(handled).toBe(true);
368+
expect(res.statusCode).toBe(404);
369+
expect(end).toHaveBeenCalledWith("Not Found");
370+
},
371+
});
372+
});
373+
374+
it("serves hardlinked asset files for bundled roots (pnpm global install)", async () => {
375+
await withControlUiRoot({
376+
fn: async (tmp) => {
377+
const assetsDir = path.join(tmp, "assets");
378+
await fs.mkdir(assetsDir, { recursive: true });
379+
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
380+
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
381+
382+
const { res, end, handled } = runControlUiRequest({
383+
url: "/assets/app.hl.js",
384+
method: "GET",
385+
rootPath: tmp,
386+
rootKind: "bundled",
387+
});
388+
389+
expect(handled).toBe(true);
390+
expect(res.statusCode).toBe(200);
391+
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("console.log('hi');");
392+
},
393+
});
394+
});
395+
329396
it("does not handle POST to root-mounted paths (plugin webhook passthrough)", async () => {
330397
await withControlUiRoot({
331398
fn: async (tmp) => {

src/gateway/control-ui.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
33
import path from "node:path";
44
import type { OpenClawConfig } from "../config/config.js";
55
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
6-
import { resolveControlUiRootSync } from "../infra/control-ui-assets.js";
6+
import {
7+
isPackageProvenControlUiRootSync,
8+
resolveControlUiRootSync,
9+
} from "../infra/control-ui-assets.js";
710
import { isWithinDir } from "../infra/path-safety.js";
811
import { openVerifiedFileSync } from "../infra/safe-open-sync.js";
912
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
@@ -39,6 +42,7 @@ export type ControlUiRequestOptions = {
3942
};
4043

4144
export type ControlUiRootState =
45+
| { kind: "bundled"; path: string }
4246
| { kind: "resolved"; path: string }
4347
| { kind: "invalid"; path: string }
4448
| { kind: "missing" };
@@ -256,13 +260,15 @@ function resolveSafeAvatarFile(filePath: string): { path: string; fd: number } |
256260
function resolveSafeControlUiFile(
257261
rootReal: string,
258262
filePath: string,
263+
rejectHardlinks: boolean,
259264
): { path: string; fd: number } | null {
260265
const opened = openBoundaryFileSync({
261266
absolutePath: filePath,
262267
rootPath: rootReal,
263268
rootRealPath: rootReal,
264269
boundaryLabel: "control ui root",
265270
skipLexicalRootCheck: true,
271+
rejectHardlinks,
266272
});
267273
if (!opened.ok) {
268274
if (opened.reason === "io") {
@@ -367,7 +373,7 @@ export function handleControlUiHttpRequest(
367373
}
368374

369375
const root =
370-
rootState?.kind === "resolved"
376+
rootState?.kind === "resolved" || rootState?.kind === "bundled"
371377
? rootState.path
372378
: resolveControlUiRootSync({
373379
moduleUrl: import.meta.url,
@@ -419,7 +425,16 @@ export function handleControlUiHttpRequest(
419425
return true;
420426
}
421427

422-
const safeFile = resolveSafeControlUiFile(rootReal, filePath);
428+
const isBundledRoot =
429+
rootState?.kind === "bundled" ||
430+
(rootState === undefined &&
431+
isPackageProvenControlUiRootSync(root, {
432+
moduleUrl: import.meta.url,
433+
argv1: process.argv[1],
434+
cwd: process.cwd(),
435+
}));
436+
const rejectHardlinks = !isBundledRoot;
437+
const safeFile = resolveSafeControlUiFile(rootReal, filePath, rejectHardlinks);
423438
if (safeFile) {
424439
try {
425440
if (respondHeadForFile(req, res, safeFile.path)) {
@@ -448,7 +463,7 @@ export function handleControlUiHttpRequest(
448463

449464
// SPA fallback (client-side router): serve index.html for unknown paths.
450465
const indexPath = path.join(root, "index.html");
451-
const safeIndex = resolveSafeControlUiFile(rootReal, indexPath);
466+
const safeIndex = resolveSafeControlUiFile(rootReal, indexPath, rejectHardlinks);
452467
if (safeIndex) {
453468
try {
454469
if (respondHeadForFile(req, res, safeIndex.path)) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { describe, expect, test } from "vitest";
5+
import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js";
6+
7+
installGatewayTestHooks({ scope: "suite" });
8+
9+
async function withGlobalControlUiHardlinkFixture<T>(run: (rootPath: string) => Promise<T>) {
10+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-ui-hardlink-"));
11+
try {
12+
const packageRoot = path.join(tmp, "pnpm-global", "5", "node_modules", "openclaw");
13+
const controlUiRoot = path.join(packageRoot, "dist", "control-ui");
14+
await fs.mkdir(controlUiRoot, { recursive: true });
15+
await fs.writeFile(
16+
path.join(packageRoot, "package.json"),
17+
JSON.stringify({ name: "openclaw" }),
18+
);
19+
20+
const storeDir = path.join(tmp, "pnpm-store", "files");
21+
await fs.mkdir(storeDir, { recursive: true });
22+
const storeIndex = path.join(storeDir, "index.html");
23+
await fs.writeFile(storeIndex, "<html><body>pnpm-hardlink-ui</body></html>\n");
24+
await fs.link(storeIndex, path.join(controlUiRoot, "index.html"));
25+
26+
return await run(controlUiRoot);
27+
} finally {
28+
await fs.rm(tmp, { recursive: true, force: true });
29+
}
30+
}
31+
32+
describe("gateway.controlUi.root", () => {
33+
test("rejects hardlinked index.html when configured root points at global OpenClaw package control-ui", async () => {
34+
await withGlobalControlUiHardlinkFixture(async (rootPath) => {
35+
testState.gatewayControlUi = { root: rootPath };
36+
await withGatewayServer(
37+
async ({ port }) => {
38+
const res = await fetch(`http://127.0.0.1:${port}/`);
39+
expect(res.status).toBe(404);
40+
expect(await res.text()).toBe("Not Found");
41+
},
42+
{ serverOptions: { controlUiEnabled: true } },
43+
);
44+
});
45+
});
46+
});

src/gateway/server.impl.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { resolveMainSessionKey } from "../config/sessions.js";
2424
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
2525
import {
2626
ensureControlUiAssetsBuilt,
27+
isPackageProvenControlUiRootSync,
2728
resolveControlUiRootOverrideSync,
2829
resolveControlUiRootSync,
2930
} from "../infra/control-ui-assets.js";
@@ -545,7 +546,16 @@ export async function startGatewayServer(
545546
});
546547
}
547548
controlUiRootState = resolvedRoot
548-
? { kind: "resolved", path: resolvedRoot }
549+
? {
550+
kind: isPackageProvenControlUiRootSync(resolvedRoot, {
551+
moduleUrl: import.meta.url,
552+
argv1: process.argv[1],
553+
cwd: process.cwd(),
554+
})
555+
? "bundled"
556+
: "resolved",
557+
path: resolvedRoot,
558+
}
549559
: { kind: "missing" };
550560
}
551561

0 commit comments

Comments
 (0)