diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 81d129b4a..90cb6e713 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -1,10 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { - AgentMessage, - AgentToolResult, -} from "@mariozechner/pi-agent-core"; +import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import { normalizeThinkLevel, diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 9fab11a0a..ad4eafd57 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -853,6 +853,7 @@ export async function compactEmbeddedPiSession(params: { sessionKey: params.sessionKey ?? params.sessionId, agentDir, config: params.config, + serveBaseUrl: params.serveBaseUrl, }); const machineName = await getMachineDisplayName(); const runtimeInfo = { @@ -1015,6 +1016,7 @@ export async function runEmbeddedPiAgent(params: { extraSystemPrompt?: string; ownerNumbers?: string[]; enforceFinalTag?: boolean; + serveBaseUrl?: string; }): Promise { const sessionLane = resolveSessionLane( params.sessionKey?.trim() || params.sessionId, @@ -1050,7 +1052,7 @@ export async function runEmbeddedPiAgent(params: { provider, preferredProfile: explicitProfileId, }); - if (explicitProfileId && !profileOrder.includes(explicitProfileId)) { +if (explicitProfileId && !profileOrder.includes(explicitProfileId)) { throw new Error( `Auth profile "${explicitProfileId}" is not configured for ${provider}.`, ); @@ -1166,6 +1168,7 @@ export async function runEmbeddedPiAgent(params: { sessionKey: params.sessionKey ?? params.sessionId, agentDir, config: params.config, + serveBaseUrl: params.serveBaseUrl, }); const machineName = await getMachineDisplayName(); const runtimeInfo = { diff --git a/src/agents/tools/serve-tool.ts b/src/agents/tools/serve-tool.ts new file mode 100644 index 000000000..05bec1d7f --- /dev/null +++ b/src/agents/tools/serve-tool.ts @@ -0,0 +1,97 @@ +import { Type } from "@sinclair/typebox"; + +import { getTailnetHostname } from "../../infra/tailscale.js"; +import { + serveCreate, + serveDelete, + serveList, +} from "../../gateway/serve.js"; +import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; + +async function resolveServeBaseUrl(providedUrl?: string): Promise { + if (providedUrl) return providedUrl; + try { + const tailnetHost = await getTailnetHostname(); + return `https://${tailnetHost}`; + } catch { + return "http://localhost:18789"; + } +} + +export function createServeTool(opts?: { baseUrl?: string }): AnyAgentTool { + return { + label: "Serve File", + name: "serve", + description: + "Create a publicly accessible URL for a file. Returns a URL that can be shared. " + + "Supports optional title, description, and OG image for rich link previews. " + + "TTL can be specified as a duration (e.g., '1h', '7d') or 'forever'.", + parameters: Type.Object({ + path: Type.String({ description: "Absolute path to the file to serve" }), + slug: Type.Optional( + Type.String({ description: "Custom URL slug (auto-generated if omitted)" }), + ), + title: Type.Optional( + Type.String({ description: "Title for link preview" }), + ), + description: Type.Optional( + Type.String({ description: "Description for link preview" }), + ), + ttl: Type.Optional( + Type.String({ + description: "Time to live: '1h', '7d', 'forever' (default: '24h')", + }), + ), + ogImage: Type.Optional( + Type.String({ description: "URL or path to Open Graph preview image" }), + ), + }), + execute: async (_toolCallId: string, args: unknown) => { + const params = (args ?? {}) as Record; + const filePath = readStringParam(params, "path", { required: true }); + const slug = readStringParam(params, "slug"); + const title = readStringParam(params, "title"); + const description = readStringParam(params, "description"); + const ttl = readStringParam(params, "ttl"); + const ogImage = readStringParam(params, "ogImage"); + + const baseUrl = await resolveServeBaseUrl(opts?.baseUrl); + const result = serveCreate( + { path: filePath, slug, title, description, ttl, ogImage }, + baseUrl, + ); + return jsonResult(result); + }, + }; +} + +export function createServeListTool(opts?: { baseUrl?: string }): AnyAgentTool { + return { + label: "List Served Files", + name: "serve_list", + description: "List all currently served files with their URLs and metadata.", + parameters: Type.Object({}), + execute: async () => { + const baseUrl = await resolveServeBaseUrl(opts?.baseUrl); + const items = serveList(baseUrl); + return jsonResult({ count: items.length, items }); + }, + }; +} + +export function createServeDeleteTool(): AnyAgentTool { + return { + label: "Delete Served File", + name: "serve_delete", + description: "Remove a served file by its slug.", + parameters: Type.Object({ + slug: Type.String({ description: "The slug of the served file to delete" }), + }), + execute: async (_toolCallId: string, args: unknown) => { + const params = (args ?? {}) as Record; + const slug = readStringParam(params, "slug", { required: true }); + const deleted = serveDelete(slug); + return jsonResult({ deleted, slug }); + }, + }; +} diff --git a/src/gateway/serve.ts b/src/gateway/serve.ts new file mode 100644 index 000000000..c791737d9 --- /dev/null +++ b/src/gateway/serve.ts @@ -0,0 +1,366 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + unlinkSync, + writeFileSync, + copyFileSync, + statSync, +} from "node:fs"; +import { extname, join, basename } from "node:path"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import MarkdownIt from "markdown-it"; +import { CONFIG_DIR } from "../utils.js"; + +const md = new MarkdownIt({ html: true, linkify: true, typographer: true }); + +export type ServeMetadata = { + slug: string; + contentPath: string; + contentHash: string; + contentType: string; + size: number; + title: string; + description: string; + ogImage: string | null; + createdAt: string; + expiresAt: string | null; + ttl: string; +}; + +export type ServeCreateParams = { + path: string; + slug: string; + title: string; + description: string; + ttl?: string; + ogImage?: string; +}; + +const SERVE_DIR = join(CONFIG_DIR, "serve"); + +function ensureServeDir() { + if (!existsSync(SERVE_DIR)) { + mkdirSync(SERVE_DIR, { recursive: true }); + } +} + +export function parseTtl(ttl: string): number | null { + if (ttl === "forever") return null; + const match = ttl.match(/^(\d+)(m|h|d)$/); + if (!match) return 24 * 60 * 60 * 1000; // default 24h + const value = parseInt(match[1], 10); + const unit = match[2]; + switch (unit) { + case "m": + return value * 60 * 1000; + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + default: + return 24 * 60 * 60 * 1000; + } +} + +function hashContent(content: Buffer): string { + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +function getMimeType(ext: string): string { + const mimes: Record = { + ".md": "text/markdown", + ".html": "text/html", + ".htm": "text/html", + ".txt": "text/plain", + ".json": "application/json", + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".css": "text/css", + ".js": "application/javascript", + }; + return mimes[ext.toLowerCase()] ?? "application/octet-stream"; +} + +function findUniqueSlug(baseSlug: string): string { + ensureServeDir(); + let slug = baseSlug; + let counter = 1; + while (existsSync(join(SERVE_DIR, `${slug}.json`))) { + const existing = loadMetadata(slug); + if (!existing) break; + slug = `${baseSlug}-${counter}`; + counter++; + } + return slug; +} + +function loadMetadata(slug: string): ServeMetadata | null { + const metaPath = join(SERVE_DIR, `${slug}.json`); + if (!existsSync(metaPath)) return null; + try { + return JSON.parse(readFileSync(metaPath, "utf-8")) as ServeMetadata; + } catch { + return null; + } +} + +function saveMetadata(meta: ServeMetadata) { + ensureServeDir(); + const metaPath = join(SERVE_DIR, `${meta.slug}.json`); + writeFileSync(metaPath, JSON.stringify(meta, null, 2)); +} + +function isExpired(meta: ServeMetadata): boolean { + if (!meta.expiresAt) return false; + return new Date(meta.expiresAt).getTime() < Date.now(); +} + +function deleteServedContent(slug: string) { + const meta = loadMetadata(slug); + if (!meta) return false; + const metaPath = join(SERVE_DIR, `${slug}.json`); + const contentPath = join(SERVE_DIR, meta.contentPath); + try { + if (existsSync(contentPath)) unlinkSync(contentPath); + if (existsSync(metaPath)) unlinkSync(metaPath); + return true; + } catch { + return false; + } +} + +export function serveCreate( + params: ServeCreateParams, + baseUrl: string, +): { url: string; slug: string } { + ensureServeDir(); + + if (!existsSync(params.path)) { + throw new Error(`File not found: ${params.path}`); + } + + const content = readFileSync(params.path); + const hash = hashContent(content); + const ext = extname(params.path); + const contentType = getMimeType(ext); + + // Check for existing with same slug + const existing = loadMetadata(params.slug); + let slug: string; + + if (existing && existing.contentHash === hash) { + // Same content, update metadata + slug = params.slug; + } else if (existing) { + // Different content, find unique slug + slug = findUniqueSlug(params.slug); + } else { + slug = params.slug; + } + + const contentFilename = `${slug}${ext}`; + const contentPath = join(SERVE_DIR, contentFilename); + copyFileSync(params.path, contentPath); + + const ttl = params.ttl ?? "24h"; + const ttlMs = parseTtl(ttl); + const now = new Date(); + const expiresAt = ttlMs ? new Date(now.getTime() + ttlMs).toISOString() : null; + + const meta: ServeMetadata = { + slug, + contentPath: contentFilename, + contentHash: hash, + contentType, + size: content.length, + title: params.title, + description: params.description, + ogImage: params.ogImage ?? null, + createdAt: now.toISOString(), + expiresAt, + ttl, + }; + + saveMetadata(meta); + + return { url: `${baseUrl}/s/${slug}`, slug }; +} + +export function serveList(baseUrl: string): ServeMetadata[] { + ensureServeDir(); + const files = readdirSync(SERVE_DIR).filter((f) => f.endsWith(".json")); + const items: ServeMetadata[] = []; + + for (const file of files) { + const slug = file.replace(/\.json$/, ""); + const meta = loadMetadata(slug); + if (meta && !isExpired(meta)) { + items.push(meta); + } + } + + return items; +} + +export function serveDelete(slug: string): boolean { + return deleteServedContent(slug); +} + +// CSS for rendered pages +const CSS = ` +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 700px; + margin: 0 auto; + padding: 20px; + background: #fff; +} +h1, h2, h3, h4, h5, h6 { margin-top: 1.5em; margin-bottom: 0.5em; } +h1 { font-size: 2em; } +h2 { font-size: 1.5em; } +h3 { font-size: 1.25em; } +p { margin: 1em 0; } +a { color: #0066cc; } +img { max-width: 100%; height: auto; } +pre { background: #f5f5f5; padding: 1em; overflow-x: auto; border-radius: 4px; } +code { background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; } +pre code { background: none; padding: 0; } +blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding-left: 1em; color: #666; } +table { border-collapse: collapse; width: 100%; margin: 1em 0; } +th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } +th { background: #f5f5f5; } +`; + +const HIGHLIGHT_CSS = ` +.hljs{background:#f5f5f5;padding:0} +.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-section,.hljs-link{color:#a626a4} +.hljs-string,.hljs-title,.hljs-name,.hljs-type,.hljs-attribute,.hljs-symbol,.hljs-bullet,.hljs-addition,.hljs-variable,.hljs-template-tag,.hljs-template-variable{color:#50a14f} +.hljs-comment,.hljs-quote,.hljs-deletion,.hljs-meta{color:#a0a1a7} +.hljs-number,.hljs-regexp,.hljs-literal,.hljs-bullet,.hljs-link{color:#986801} +.hljs-emphasis{font-style:italic} +.hljs-strong{font-weight:bold} +`; + +function renderHtmlPage(meta: ServeMetadata, bodyHtml: string, baseUrl: string): string { + const ogImageTag = meta.ogImage + ? `` + : ""; + + return ` + + + + + + + ${ogImageTag} + + ${escapeHtml(meta.title)} + + + + +
+ ${bodyHtml} +
+ + + +`; +} + +function render404Page(): string { + return ` + + + + + Not Found - Clawdis + + + +
+

Content Not Found

+

This content may have expired or been removed.

+
+ +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function handleServeRequest( + req: IncomingMessage, + res: ServerResponse, + opts: { baseUrl: string }, +): boolean { + const url = new URL(req.url ?? "/", "http://localhost"); + if (!url.pathname.startsWith("/s/")) return false; + + const slug = url.pathname.slice(3); // Remove "/s/" + if (!slug || slug.includes("/")) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(render404Page()); + return true; + } + + const meta = loadMetadata(slug); + if (!meta || isExpired(meta)) { + if (meta && isExpired(meta)) { + deleteServedContent(slug); + } + res.statusCode = 404; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(render404Page()); + return true; + } + + const contentPath = join(SERVE_DIR, meta.contentPath); + if (!existsSync(contentPath)) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(render404Page()); + return true; + } + + const content = readFileSync(contentPath); + + // Render markdown/text as HTML + if (meta.contentType === "text/markdown" || meta.contentType === "text/plain") { + const text = content.toString("utf-8"); + const bodyHtml = meta.contentType === "text/markdown" ? md.render(text) : `
${escapeHtml(text)}
`; + const html = renderHtmlPage(meta, bodyHtml, opts.baseUrl); + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(html); + return true; + } + + // Serve other files directly + res.statusCode = 200; + res.setHeader("Content-Type", meta.contentType); + res.setHeader("Content-Length", content.length); + res.end(content); + return true; +} diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 6dc106ac9..6abf3cdcf 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -22,6 +22,7 @@ import { resolveHookProvider, } from "./hooks.js"; import { applyHookMappings } from "./hooks-mapping.js"; +import { handleServeRequest } from "./serve.js"; type SubsystemLogger = ReturnType; @@ -206,12 +207,14 @@ export function createGatewayHttpServer(opts: { controlUiEnabled: boolean; controlUiBasePath: string; handleHooksRequest: HooksRequestHandler; + serveBaseUrl?: string; }): HttpServer { const { canvasHost, controlUiEnabled, controlUiBasePath, handleHooksRequest, + serveBaseUrl, } = opts; const httpServer: HttpServer = createHttpServer((req, res) => { // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. @@ -219,6 +222,7 @@ export function createGatewayHttpServer(opts: { void (async () => { if (await handleHooksRequest(req, res)) return; + if (serveBaseUrl && handleServeRequest(req, res, { baseUrl: serveBaseUrl })) return; if (canvasHost) { if (await handleA2uiHttpRequest(req, res)) return; if (await canvasHost.handleHttpRequest(req, res)) return; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 9dd92c846..10bb97d57 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -593,11 +593,23 @@ export async function startGatewayServer( dispatchWakeHook, }); + // Try to use Tailscale funnel URL for serve feature (public URLs) + let serveBaseUrl: string; + try { + const tailnetHost = await getTailnetHostname(); + serveBaseUrl = `https://${tailnetHost}`; + log.info(`Serve base URL (Tailscale): ${serveBaseUrl}`); + } catch { + const serveHost = bindHost === "0.0.0.0" ? "localhost" : bindHost; + serveBaseUrl = `http://${serveHost}:${port}`; + log.info(`Serve base URL (local): ${serveBaseUrl}`); + } const httpServer: HttpServer = createGatewayHttpServer({ canvasHost, controlUiEnabled, controlUiBasePath, handleHooksRequest, + serveBaseUrl, }); let bonjourStop: (() => Promise) | null = null; let bridge: Awaited> | null = null;