Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./
COPY --from=builder /app/tsconfig.json ./

# Install Chromium headless shell + system deps for Playwright.
# Must run after node_modules is copied so bunx can resolve playwright.
# --only-shell skips the full Chromium binary (saves ~75 MiB); the custom
# phantom_preview_page tool uses chromium.launch() which picks the shell
# automatically for headless=true. The @playwright/mcp embed path uses a
# contextGetter so it never needs the full chrome channel binary.
ENV PLAYWRIGHT_BROWSERS_PATH=/home/phantom/.cache/ms-playwright
RUN mkdir -p "$PLAYWRIGHT_BROWSERS_PATH" && \
bunx playwright install --with-deps --only-shell chromium && \
chown -R phantom:phantom /home/phantom/.cache && \
rm -rf /var/lib/apt/lists/*

# Copy default phantom-config (constitution.md, persona.md, etc.)
# These get backed up so they survive the empty volume mount on first run.
COPY --from=builder /app/phantom-config ./phantom-config
Expand Down
14 changes: 14 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.77",
"@modelcontextprotocol/sdk": "^1.28.0",
"@playwright/mcp": "0.0.70",
"@slack/bolt": "^4.6.0",
"croner": "^10.0.1",
"imapflow": "^1.2.18",
"nodemailer": "^8.0.4",
"playwright": "1.59.1",
"resend": "^6.9.4",
"telegraf": "^4.16.3",
"yaml": "^2.6.0",
Expand Down
25 changes: 25 additions & 0 deletions src/agent/prompt-assembler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,31 @@ function buildEnvironment(config: PhantomConfig): string {
lines.push(`- Pages are at ${publicUrl}/ui/<filename>`);
}
lines.push("");
lines.push("SELF-VALIDATE EVERY UI PAGE YOU CREATE.");
lines.push("After phantom_create_page succeeds, always call phantom_preview_page with");
lines.push("the same path. Review the screenshot, the HTTP status, the page title,");
lines.push("and especially the console messages and failed network requests list.");
lines.push("If there are console errors, failed CDN loads, or the screenshot looks");
lines.push("wrong, fix the HTML and re-run phantom_preview_page until clean. Only");
lines.push("report the page to the user after validation passes.");
lines.push("The tool returns one image block plus a JSON metadata block. The image");
lines.push("is for visual review, the JSON tells you what failed to load or error.");
lines.push("");
lines.push("GENERAL BROWSER CAPABILITY.");
lines.push("You have access to the full Playwright MCP tool surface via the");
lines.push("phantom-browser server. These tools share one Chromium instance with");
lines.push("phantom_preview_page. Use browser_navigate to open any URL (localhost");
lines.push("or external), browser_snapshot for structured accessibility text,");
lines.push("browser_take_screenshot for pixel captures, browser_click/browser_type/");
lines.push("browser_fill_form for interaction, browser_console_messages and");
lines.push("browser_network_requests for debugging, browser_tabs for multi-page work.");
lines.push("For single-shot self-validation of your own /ui/<path> pages, always");
lines.push("prefer phantom_preview_page: one call returns image plus JSON.");
lines.push("For multi-step browsing, research tasks, or external sites, use the");
lines.push("browser_* tools directly.");
lines.push("Do NOT use browser_run_code against external pages unless the user");
lines.push("explicitly asked you to execute code in a foreign origin.");
lines.push("");
lines.push("When you build something that others should access, you have two options:");
lines.push("1. Create an HTTP API on a local port. Give the user the internal URL and auth token.");
lines.push(
Expand Down
10 changes: 7 additions & 3 deletions src/agent/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class AgentRuntime {
private roleTemplate: RoleTemplate | null = null;
private onboardingPrompt: string | null = null;
private lastTrackedFiles: string[] = [];
private mcpServerFactories: Record<string, () => McpServerConfig> | null = null;
private mcpServerFactories: Record<string, () => McpServerConfig | Promise<McpServerConfig>> | null = null;

constructor(config: PhantomConfig, db: Database) {
this.config = config;
Expand All @@ -55,7 +55,7 @@ export class AgentRuntime {
this.onboardingPrompt = prompt;
}

setMcpServerFactories(factories: Record<string, () => McpServerConfig>): void {
setMcpServerFactories(factories: Record<string, () => McpServerConfig | Promise<McpServerConfig>>): void {
this.mcpServerFactories = factories;
}

Expand Down Expand Up @@ -208,7 +208,11 @@ export class AgentRuntime {
...(useResume && session.sdk_session_id ? { resume: session.sdk_session_id } : {}),
...(this.mcpServerFactories
? {
mcpServers: Object.fromEntries(Object.entries(this.mcpServerFactories).map(([k, f]) => [k, f()])),
mcpServers: Object.fromEntries(
await Promise.all(
Object.entries(this.mcpServerFactories).map(async ([k, f]) => [k, await f()] as const),
),
),
}
: {}),
},
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import { Scheduler } from "./scheduler/service.ts";
import { createSchedulerToolServer } from "./scheduler/tool.ts";
import { getSecretRequest } from "./secrets/store.ts";
import { createSecretToolServer } from "./secrets/tools.ts";
import { createBrowserToolServer } from "./ui/browser-mcp.ts";
import { closePreviewResources, createPreviewToolServer, getOrCreatePreviewContext } from "./ui/preview.ts";
import { setPublicDir, setSecretSavedCallback, setSecretsDb } from "./ui/serve.ts";
import { createWebUiToolServer } from "./ui/tools.ts";

Expand Down Expand Up @@ -191,6 +193,8 @@ async function main(): Promise<void> {
"phantom-scheduler": () => createSchedulerToolServer(scheduler as Scheduler),
"phantom-web-ui": () => createWebUiToolServer(config.public_url),
"phantom-secrets": () => createSecretToolServer({ db, baseUrl: secretsBaseUrl }),
"phantom-preview": () => createPreviewToolServer(config.port),
"phantom-browser": () => createBrowserToolServer(() => getOrCreatePreviewContext()),
...(process.env.RESEND_API_KEY
? {
"phantom-email": () =>
Expand All @@ -204,7 +208,7 @@ async function main(): Promise<void> {
});
const emailStatus = process.env.RESEND_API_KEY ? " + email" : "";
console.log(
`[mcp] MCP server initialized (dynamic tools + scheduler + web UI + secrets${emailStatus} wired to agent)`,
`[mcp] MCP server initialized (dynamic tools + scheduler + web UI + secrets + preview + browser${emailStatus} wired to agent)`,
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
Expand Down Expand Up @@ -580,6 +584,9 @@ async function main(): Promise<void> {
onShutdown("Scheduler", async () => {
if (scheduler) scheduler.stop();
});
onShutdown("Preview browser", async () => {
await closePreviewResources();
});
onShutdown("Peer health monitor", async () => {
if (peerHealthMonitor) peerHealthMonitor.stop();
});
Expand Down
33 changes: 33 additions & 0 deletions src/ui/__tests__/browser-mcp.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Integration tests for createBrowserToolServer. These exercise the real
// @playwright/mcp embed with a real BrowserContext. Opt-in:
//
// PHANTOM_INTEGRATION=1 bun test src/ui/__tests__/browser-mcp.integration.test.ts
//
// Skipped by default so `bun test` stays hermetic.

import { afterAll, describe, expect, test } from "bun:test";
import { createBrowserToolServer } from "../browser-mcp.ts";
import { closePreviewResources, getOrCreatePreviewContext } from "../preview.ts";
import { revokeAllSessions } from "../session.ts";

const ENABLED = process.env.PHANTOM_INTEGRATION === "1";
const suite = ENABLED ? describe : describe.skip;

suite("createBrowserToolServer (integration)", () => {
afterAll(async () => {
await closePreviewResources();
revokeAllSessions();
});

test("builds an embed server wired to a shared BrowserContext", async () => {
const embed = await createBrowserToolServer(() => getOrCreatePreviewContext());
expect(embed.type).toBe("sdk");
expect(embed.name).toBe("phantom-browser");
const inst = embed.instance as unknown as {
connect: unknown;
close: () => Promise<void>;
};
expect(typeof inst.connect).toBe("function");
await inst.close();
});
});
37 changes: 37 additions & 0 deletions src/ui/__tests__/browser-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import type { BrowserContext } from "playwright";
import { createBrowserToolServer } from "../browser-mcp.ts";

// The real @playwright/mcp createConnection is lazy: it wires a backend
// factory that will call the contextGetter only when a client actually
// requests a tool. Constructing the embed does not require a live
// BrowserContext, so these tests never touch Chromium.
function fakeContextGetter(): Promise<BrowserContext> {
return Promise.reject(new Error("contextGetter should not run in unit tests"));
}

describe("createBrowserToolServer", () => {
test("returns an SDK MCP server config with the phantom-browser name", async () => {
const config = await createBrowserToolServer(fakeContextGetter);
expect(config.type).toBe("sdk");
expect(config.name).toBe("phantom-browser");
expect(config.instance).toBeDefined();
});

test("instance exposes the MCP connect() contract used by the Agent SDK", async () => {
const config = await createBrowserToolServer(fakeContextGetter);
const inst = config.instance as unknown as { connect: unknown; close: unknown };
expect(typeof inst.connect).toBe("function");
expect(typeof inst.close).toBe("function");
});

test("each call returns a distinct underlying Server instance", async () => {
const a = await createBrowserToolServer(fakeContextGetter);
const b = await createBrowserToolServer(fakeContextGetter);
// Factory pattern: the phantom-browser wrapper must be fresh per query.
// If the same instance leaks across calls the SDK will throw "Already
// connected to a transport" on the second run. See src/index.ts for
// the cardinal rule citation.
expect(a.instance).not.toBe(b.instance);
});
});
63 changes: 63 additions & 0 deletions src/ui/__tests__/preview.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Integration tests for phantom_preview_page. These launch a real Chromium
// (headless shell) and talk to a local Bun.serve, so they are opt-in. Run:
//
// PHANTOM_INTEGRATION=1 bun test src/ui/__tests__/preview.integration.test.ts
//
// They are skipped by default so `bun test` stays fast and hermetic.

import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { closePreviewResources, getOrCreatePreviewContext } from "../preview.ts";
import { revokeAllSessions } from "../session.ts";

const ENABLED = process.env.PHANTOM_INTEGRATION === "1";
const suite = ENABLED ? describe : describe.skip;

suite("phantom_preview_page (integration)", () => {
let server: ReturnType<typeof Bun.serve> | null = null;
let port = 0;

beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/ui/test.html") {
return new Response(
"<!DOCTYPE html><html><head><title>Preview Integration</title></head>" +
"<body><script>console.log('hi from preview test')</script>" +
"<h1>Hello</h1></body></html>",
{ headers: { "content-type": "text/html" } },
);
}
return new Response("not found", { status: 404 });
},
});
port = server.port ?? 0;
});

afterAll(async () => {
await closePreviewResources();
revokeAllSessions();
server?.stop(true);
});

test("navigates and screenshots a live page", async () => {
const ctx = await getOrCreatePreviewContext();
const page = await ctx.newPage();
try {
const response = await page.goto(`http://localhost:${port}/ui/test.html`);
expect(response?.status()).toBe(200);
expect(await page.title()).toBe("Preview Integration");
const shot = await page.screenshot({ type: "png" });
expect(shot.length).toBeGreaterThan(100);
} finally {
await page.close();
}
});

test("two preview calls share the same BrowserContext", async () => {
const a = await getOrCreatePreviewContext();
const b = await getOrCreatePreviewContext();
expect(a).toBe(b);
});
});
58 changes: 58 additions & 0 deletions src/ui/__tests__/preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createPreviewToolServer } from "../preview.ts";
import { createPreviewSession, isValidSession, revokeAllSessions } from "../session.ts";

afterEach(() => {
revokeAllSessions();
});

describe("createPreviewSession", () => {
test("returns a token accepted by isValidSession", () => {
const { sessionToken } = createPreviewSession();
expect(typeof sessionToken).toBe("string");
expect(sessionToken.length).toBeGreaterThan(20);
expect(isValidSession(sessionToken)).toBe(true);
});

test("tokens are unique per call", () => {
const a = createPreviewSession().sessionToken;
const b = createPreviewSession().sessionToken;
expect(a).not.toBe(b);
});

test("token expires after the 10 minute TTL", () => {
const { sessionToken } = createPreviewSession();
expect(isValidSession(sessionToken)).toBe(true);

const originalNow = Date.now;
try {
// Eleven minutes later, the session should have expired.
Date.now = () => originalNow() + 11 * 60 * 1000;
expect(isValidSession(sessionToken)).toBe(false);
} finally {
Date.now = originalNow;
}
});
});

describe("createPreviewToolServer", () => {
test("returns an SDK MCP server config with a name", () => {
const server = createPreviewToolServer(3100);
expect(server).toBeDefined();
expect(server.type).toBe("sdk");
expect(server.name).toBe("phantom-preview");
expect(server.instance).toBeDefined();
});

test("each call returns a distinct instance (factory pattern)", () => {
const a = createPreviewToolServer(3100);
const b = createPreviewToolServer(3100);
expect(a.instance).not.toBe(b.instance);
});

test("instance exposes the MCP connect() contract", () => {
const server = createPreviewToolServer(3100);
const inst = server.instance as unknown as { connect: unknown };
expect(typeof inst.connect).toBe("function");
});
});
Loading
Loading