diff --git a/packages/server/src/server/bootstrap-web-ui.test.ts b/packages/server/src/server/bootstrap-web-ui.test.ts index 81afc3c166..5669e23b6d 100644 --- a/packages/server/src/server/bootstrap-web-ui.test.ts +++ b/packages/server/src/server/bootstrap-web-ui.test.ts @@ -84,6 +84,23 @@ describe("daemon web UI bootstrap", () => { } }); + test("returns 404 for missing metadata while serving web UI deep links", async () => { + const distDir = await createWebUiDist(); + daemonHandle = await createTestPaseoDaemon({ + mcpEnabled: true, + webUi: { enabled: true, distDir }, + }); + const origin = `http://127.0.0.1:${daemonHandle.port}`; + const metadata = await fetch(`${origin}/.well-known/oauth-protected-resource/mcp/agents`); + expect(metadata.status).toBe(404); + expect(metadata.headers.get("content-type")).toBe("application/json; charset=utf-8"); + expect(await metadata.json()).toEqual({ error: "Not found" }); + + const deepLink = await fetch(`${origin}/h/example/agent/123`); + expect(deepLink.status).toBe(200); + expect(await deepLink.text()).toContain("app"); + }); + test("advertises ComputerName as the hostname over HTTP and WebSocket", async () => { daemonHandle = await createTestPaseoDaemon({ mcpEnabled: false }); const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); diff --git a/packages/server/src/server/web-ui.test.ts b/packages/server/src/server/web-ui.test.ts index 2e1f6ba663..b1aa04a8d2 100644 --- a/packages/server/src/server/web-ui.test.ts +++ b/packages/server/src/server/web-ui.test.ts @@ -187,6 +187,33 @@ describe("daemon web UI route module", () => { expect(res.body).toContain("app"); }); + test.each([ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp/agents", + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", + ])("returns a JSON 404 instead of the SPA for missing %s", async (requestPath) => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", requestPath); + + expect(res.status).toBe(404); + expect(res.headers["content-type"]).toBe("application/json; charset=utf-8"); + expect(JSON.parse(res.body)).toEqual({ error: "Not found" }); + }); + + test("serves well-known files that exist in the dist directory", async () => { + await mkdir(path.join(distDir, ".well-known"), { recursive: true }); + await writeFile(path.join(distDir, ".well-known", "security.json"), '{"ok":true}'); + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/.well-known/security.json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("application/json; charset=utf-8"); + expect(res.body).toBe('{"ok":true}'); + }); + test("serves static assets", async () => { const app = createApp({ enabled: true, distDir, publicDir }); diff --git a/packages/server/src/server/web-ui.ts b/packages/server/src/server/web-ui.ts index b59896c159..c00611a4e9 100644 --- a/packages/server/src/server/web-ui.ts +++ b/packages/server/src/server/web-ui.ts @@ -15,6 +15,13 @@ function isExcludedPath(requestPath: string): boolean { return EXCLUDED_PATHS.has(requestPath); } +// RFC 8615 well-known URIs are machine-read metadata. Clients such as MCP OAuth +// discovery (RFC 9728, RFC 8414) need a 404 for documents the daemon does not +// serve; the SPA's index.html with a 200 reads as malformed metadata. +function isWellKnownPath(requestPath: string): boolean { + return requestPath === "/.well-known" || requestPath.startsWith("/.well-known/"); +} + const CONTENT_TYPES: Record = { ".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8", @@ -71,7 +78,14 @@ interface ResolvedTarget { isIndexHtml: boolean; } -function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget | null { +interface ResolveTargetFileOptions { + distDir: string; + requestPath: string; + spaFallback: boolean; +} + +function resolveTargetFile(options: ResolveTargetFileOptions): ResolvedTarget | null { + const { distDir, requestPath, spaFallback } = options; const safePath = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, ""); let filePath = path.join(distDir, safePath); @@ -82,6 +96,9 @@ function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget const finalStat = safeStat(filePath); if (!finalStat?.isFile()) { + if (!spaFallback) { + return null; + } filePath = path.join(distDir, "index.html"); const fallbackStat = safeStat(filePath); if (!fallbackStat?.isFile()) { @@ -190,8 +207,13 @@ interface ServeWebUiFileOptions { function serveWebUiFile(options: ServeWebUiFileOptions): void { const { distDir, requestPath, label, req, res } = options; - const target = resolveTargetFile(distDir, requestPath); + const wellKnown = isWellKnownPath(requestPath); + const target = resolveTargetFile({ distDir, requestPath, spaFallback: !wellKnown }); if (!target) { + if (wellKnown) { + res.status(404).json({ error: "Not found" }); + return; + } res.status(404).end(); return; }