diff --git a/src/core/__tests__/server.test.ts b/src/core/__tests__/server.test.ts
index fdd1178..d235e08 100644
--- a/src/core/__tests__/server.test.ts
+++ b/src/core/__tests__/server.test.ts
@@ -1,8 +1,11 @@
-import { afterAll, beforeAll, describe, expect, test } from "bun:test";
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
import YAML from "yaml";
import { hashTokenSync } from "../../mcp/config.ts";
import type { McpConfig } from "../../mcp/types.ts";
+import { handleUiRequest, setPublicDir } from "../../ui/serve.ts";
import { startServer } from "../server.ts";
/**
@@ -92,4 +95,100 @@ describe("server routing", () => {
expect(body.agent).toBe("phantom");
});
});
+
+ // /public/* is the agent publishing surface: files under public/public/*
+ // on disk are served without auth so Googlebot and unauthenticated
+ // visitors can fetch them. These tests redirect publicDir at a tmp dir
+ // so they never mutate the repo's own public/ tree.
+ describe("GET /public/*", () => {
+ const realPublic = resolve(import.meta.dir, "../../../public");
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "phantom-public-"));
+ setPublicDir(tmpDir);
+ });
+
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ setPublicDir(realPublic);
+ });
+
+ function write(rel: string, content: string): void {
+ const full = join(tmpDir, rel);
+ mkdirSync(full.substring(0, full.lastIndexOf("/")), { recursive: true });
+ writeFileSync(full, content, "utf-8");
+ }
+
+ test("GET /public/ serves public/public/index.html when present, no redirect to /ui/login", async () => {
+ write("public/index.html", "
Blog");
+ const res = await fetch(`${baseUrl}/public/`, { redirect: "manual" });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Location")).toBeNull();
+ const body = await res.text();
+ expect(body).toContain("Blog");
+ });
+
+ test("GET /public/ returns 404 when index.html is missing, never 302", async () => {
+ const res = await fetch(`${baseUrl}/public/`, { redirect: "manual" });
+ expect(res.status).toBe(404);
+ expect(res.headers.get("Location")).toBeNull();
+ });
+
+ test("GET /public/blog/foo.html serves without cookie", async () => {
+ write("public/blog/foo.html", "Post");
+ const res = await fetch(`${baseUrl}/public/blog/foo.html`);
+ expect(res.status).toBe(200);
+ const body = await res.text();
+ expect(body).toContain("Post");
+ });
+
+ test("GET /public/blog/ falls back to index.html inside the directory", async () => {
+ write("public/blog/index.html", "Blog Index");
+ const res = await fetch(`${baseUrl}/public/blog/`);
+ expect(res.status).toBe(200);
+ const body = await res.text();
+ expect(body).toContain("Blog Index");
+ });
+
+ test("traversal attempt /public/..%2Fsecret.html returns 403", async () => {
+ write("secret.html", "secret");
+ const res = await fetch(`${baseUrl}/public/..%2Fsecret.html`);
+ expect(res.status).toBe(403);
+ const body = await res.text();
+ expect(body).not.toContain("secret");
+ });
+
+ test("traversal to dashboard.js via /public/../dashboard/dashboard.js returns 403", async () => {
+ write("dashboard/dashboard.js", "console.log('priv');");
+ const res = await fetch(`${baseUrl}/public/..%2Fdashboard%2Fdashboard.js`);
+ expect(res.status).toBe(403);
+ const body = await res.text();
+ expect(body).not.toContain("console.log");
+ });
+
+ test("Cache-Control on /public/* responses is public, max-age=300", async () => {
+ write("public/post.html", "Post");
+ const res = await fetch(`${baseUrl}/public/post.html`);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Cache-Control")).toBe("public, max-age=300");
+ });
+
+ test("regression: GET /ui/foo.html without cookie still redirects to /ui/login", async () => {
+ write("foo.html", "Private");
+ const res = await handleUiRequest(
+ new Request("http://localhost/ui/foo.html", { headers: { Accept: "text/html" } }),
+ );
+ expect(res.status).toBe(302);
+ expect(res.headers.get("Location")).toBe("/ui/login");
+ });
+
+ test("regression: GET /ui/dashboard/dashboard.js without cookie still returns 401", async () => {
+ write("dashboard/dashboard.js", "console.log('priv');");
+ const res = await handleUiRequest(
+ new Request("http://localhost/ui/dashboard/dashboard.js", { headers: { Accept: "application/javascript" } }),
+ );
+ expect(res.status).toBe(401);
+ });
+ });
});
diff --git a/src/core/server.ts b/src/core/server.ts
index bf813b8..8479052 100644
--- a/src/core/server.ts
+++ b/src/core/server.ts
@@ -1,3 +1,4 @@
+import { resolve as pathResolve } from "node:path";
import type { AgentRuntime } from "../agent/runtime.ts";
import type { SlackChannel } from "../channels/slack.ts";
import { handleEmailLogin } from "../chat/email-login.ts";
@@ -8,7 +9,7 @@ import type { PhantomMcpServer } from "../mcp/server.ts";
import type { MemoryHealth } from "../memory/types.ts";
import type { SchedulerHealthSummary } from "../scheduler/health.ts";
import { avatarUrlIfPresent, handleAvatarGet } from "../ui/api/identity.ts";
-import { handleUiRequest } from "../ui/serve.ts";
+import { getPublicDir, handleUiRequest } from "../ui/serve.ts";
import { type HealthPayload, renderHealthHtml } from "./health-page.ts";
const VERSION = "0.20.1";
@@ -195,6 +196,14 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
if (response) return response;
}
+ // Public publishing surface. Agents drop HTML, XML, or assets
+ // under public/public/*. Served without auth so Googlebot,
+ // OpenGraph scrapers, and the open web can read them.
+ // Traversal-defended via path.resolve + containment check.
+ if (url.pathname === "/public" || url.pathname === "/public/" || url.pathname.startsWith("/public/")) {
+ return handlePublicRequest(url);
+ }
+
if (url.pathname.startsWith("/ui")) {
return handleUiRequest(req);
}
@@ -211,6 +220,44 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
return server;
}
+async function handlePublicRequest(url: URL): Promise {
+ const publicRoot = pathResolve(getPublicDir(), "public");
+ const isRoot = url.pathname === "/public" || url.pathname === "/public/";
+ const rawRel = isRoot ? "index.html" : url.pathname.slice("/public/".length);
+ // Decode percent-escapes so traversal sequences like ..%2F become visible
+ // to the containment check below. A malformed escape is rejected outright.
+ let rel: string;
+ try {
+ rel = decodeURIComponent(rawRel);
+ } catch {
+ return new Response("Forbidden", { status: 403 });
+ }
+ if (rel.includes("\0")) {
+ return new Response("Forbidden", { status: 403 });
+ }
+ const candidate = pathResolve(publicRoot, rel);
+ if (candidate !== publicRoot && !candidate.startsWith(`${publicRoot}/`)) {
+ return new Response("Forbidden", { status: 403 });
+ }
+ const file = Bun.file(candidate);
+ if (await file.exists()) {
+ return new Response(file, {
+ headers: { "Cache-Control": "public, max-age=300" },
+ });
+ }
+ // Directory-style index.html fallback (e.g. /public/blog/ -> public/public/blog/index.html)
+ const indexCandidate = pathResolve(candidate, "index.html");
+ if (indexCandidate !== candidate && indexCandidate.startsWith(`${publicRoot}/`)) {
+ const indexFile = Bun.file(indexCandidate);
+ if (await indexFile.exists()) {
+ return new Response(indexFile, {
+ headers: { "Cache-Control": "public, max-age=300" },
+ });
+ }
+ }
+ return new Response("Not found", { status: 404 });
+}
+
async function handleTrigger(req: Request): Promise {
if (!triggerAuth) {
return Response.json({ status: "error", message: "Auth not initialized" }, { status: 503 });
diff --git a/src/ui/api/__tests__/pages-api.test.ts b/src/ui/api/__tests__/pages-api.test.ts
index dbf4eae..db15cbf 100644
--- a/src/ui/api/__tests__/pages-api.test.ts
+++ b/src/ui/api/__tests__/pages-api.test.ts
@@ -113,6 +113,19 @@ describe("GET /ui/api/pages", () => {
expect(body.pages[0].path).toBe("/ui/keep.html");
});
+ test("excludes public/ directory so agent-published pages never surface in recent-pages", async () => {
+ writePage("public/blog/post-1.html", "Post 1");
+ writePage("public/index.html", "Public Root");
+ writePage("keep.html", "Keep");
+ const res = await handleUiRequest(req());
+ const body = (await res.json()) as PagesResponse;
+ const paths = body.pages.map((p) => p.path);
+ expect(paths).not.toContain("/ui/public/blog/post-1.html");
+ expect(paths).not.toContain("/ui/public/index.html");
+ expect(paths).toContain("/ui/keep.html");
+ expect(body.pages.length).toBe(1);
+ });
+
test("walks up to depth 3", async () => {
writePage("a/b/c/deep.html", "Deep");
writePage("a/b/c/d/too-deep.html", "Too Deep");
diff --git a/src/ui/api/pages.ts b/src/ui/api/pages.ts
index 835b757..684ed97 100644
--- a/src/ui/api/pages.ts
+++ b/src/ui/api/pages.ts
@@ -32,7 +32,7 @@ const EXCLUDED_FILENAMES = new Set([
"robots.txt",
]);
-const EXCLUDED_ROOT_DIRS = new Set(["dashboard", "_examples", "chat"]);
+const EXCLUDED_ROOT_DIRS = new Set(["dashboard", "_examples", "chat", "public"]);
const TITLE_REGEX = /]*>([^<]*)<\/title>/i;
const MAX_TITLE_LEN = 120;