diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 7f6e32ae0..613bdbed8 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -188,6 +188,10 @@ describe("ElectronProtocol", () => { response.headers.get("content-security-policy") ?? "", "font-src 'self' pathway-dev: data:", ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "frame-src 'self' http: https:", + ); }), ); @@ -499,5 +503,8 @@ describe("ElectronProtocol", () => { "https:", ]); assert.deepEqual(directives["font-src"], ["'self'", "pathway:", "data:"]); + assert.deepEqual(directives["worker-src"], ["'self'", "blob:"]); + assert.deepEqual(directives["form-action"], ["'self'"]); + assert.deepEqual(directives["frame-src"], ["'self'", "http:", "https:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 000ba3237..469515650 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -137,6 +137,10 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat // origins are not known when this response policy is created, so restrict // connections by the network schemes the client supports instead of by host. const connectSources = ["'self'", "http:", "https:", "ws:", "wss:"]; + // Agent HTML renders load in iframes from the owning environment's asset + // origin, which is just as unknown here. The iframe sandbox, not this + // policy, is what isolates those pages. Cloudflare's challenge frame is https. + const frameSources = ["'self'", "http:", "https:"]; return [ "default-src 'self'", @@ -146,7 +150,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat "style-src 'self' 'unsafe-inline'", `font-src 'self' ${input.scheme}: data:`, "worker-src 'self' blob:", - "frame-src 'self' https://challenges.cloudflare.com", + `frame-src ${frameSources.join(" ")}`, "form-action 'self'", ].join("; "); } diff --git a/apps/pathway-ios/Pathway/shared/datalayer/PathwayHTMLRender.swift b/apps/pathway-ios/Pathway/shared/datalayer/PathwayHTMLRender.swift new file mode 100644 index 000000000..ff831cdea --- /dev/null +++ b/apps/pathway-ios/Pathway/shared/datalayer/PathwayHTMLRender.swift @@ -0,0 +1,220 @@ +import Foundation +import UIKit + +/// A page an agent published into its thread with `html_render`, shown inline above its reply. +/// The server stores the page as a thread attachment and normalizes the tool item, so this reads +/// only `output.htmlRender` of a completed `dynamic_tool` item. Mirrors `@pathway/shared/htmlRender`. +struct PathwayHTMLRender: Equatable, Sendable { + /// A content height the server measured the page at for one frame width. + struct Measured: Equatable, Sendable { + let width: Double + let height: Double + } + + static let minHeight = 80.0 + static let maxHeight = 2_000.0 + /// The reply column's width on desktop. Agents size pages for it, so it decides whether the + /// agent's height asked for a scrolling frame. + static let columnWidth = 728.0 + private static let maxTitleLength = 200 + private static let maxMeasurements = 24 + + let attachmentID: String + let title: String + /// The agent's frame height in CSS pixels. + let height: Double + /// Ascending by width; empty when the environment could not measure the page. + let heights: [Measured] + + init?(_ item: PathwayTimelineItem) { + guard item.type == "dynamic_tool", item.status == "completed", + Self.htmlTool(item.fields["toolName"]?.stringValue) == "html_render", + let output = item.fields["output"]?.objectValue, output["isError"]?.boolValue != true else { return nil } + self.init(reference: output["htmlRender"]) + } + + init?(reference: JSONValue?) { + guard let reference = reference?.objectValue, + let attachmentID = reference["attachmentId"]?.stringValue, (1...256).contains(attachmentID.utf16.count), + let title = reference["title"]?.stringValue, + case let .number(height)? = reference["height"], height.isFinite else { return nil } + let trimmed = Self.prefix(title.trimmingCharacters(in: Self.whitespace), length: Self.maxTitleLength) + self.attachmentID = attachmentID + self.title = trimmed.isEmpty ? "HTML" : trimmed + self.height = Self.clamp(height) + heights = Self.measured(reference["heights"]) ?? [] + } + + /// The Pathway HTML tool (`html_render` or `html_preview`) a tool name refers to, in the + /// spellings providers have used. New items always carry `pathway.html_render`. + static func htmlTool(_ toolName: String?) -> String? { + guard let toolName else { return nil } + let label = toolName.replacingOccurrences(of: #"\s+(?:complete|completed)\s*$"#, with: "", options: [.regularExpression, .caseInsensitive]) + .trimmingCharacters(in: whitespace) + let pattern = #"^(?:mcp__(?i:pathway|pathway_code)__|(?i:pathway|pathway_code)[.:/_])?(html_render|html_preview)$"# + guard label.range(of: pattern, options: .regularExpression) != nil else { return nil } + return label.hasSuffix("html_render") ? "html_render" : "html_preview" + } + + /// The work-row label for a running or failed HTML tool call. + static func toolLabel(_ toolName: String?) -> String? { + switch htmlTool(toolName) { + case "html_render": "Render an HTML page" + case "html_preview": "Preview an HTML page" + default: nil + } + } + + /// The frame height at a frame width: the page's own reported content height once it has one, + /// else the server's measurement for that width. The agent's height caps it only when it is + /// below the page's height at the column width (a scrolling frame) or the page is unmeasured. + func frameHeight(width: Double, contentHeight: Double? = nil) -> Double { + guard !heights.isEmpty else { return Self.clamp(min(height, contentHeight ?? height)) } + let cap = measuredHeight(at: Self.columnWidth) > height ? height : Self.maxHeight + return Self.clamp(min(cap, contentHeight ?? measuredHeight(at: width))) + } + + /// A readable download name: the title without characters file systems reject. + var fileName: String { + let name = title + .replacingOccurrences(of: #"[\\/:*?"<>|\p{Cc}]+"#, with: " ", options: .regularExpression) + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: Self.whitespace) + let clipped = Self.prefix(name, length: 120).trimmingCharacters(in: Self.whitespace) + return "\(clipped.isEmpty ? "Page" : clipped).html" + } + + /// The `assets.createUrl` resource that serves the stored page inline. + var assetResource: JSONValue { + .object([ + "_tag": .string("attachment"), "attachmentId": .string(attachmentID), "fileName": .string(fileName), + "mimeType": .string("text/html"), "disposition": .string("inline") + ]) + } + + // The taller of the heights measured at the nearest widths on each side. A breakpoint + // between two measured widths can make the page as tall as either. + private func measuredHeight(at width: Double) -> Double { + let high = heights.firstIndex { $0.width >= width } ?? heights.count - 1 + let low = heights[high].width == width ? high : max(0, high - 1) + return max(heights[low].height, heights[high].height) + } + + private static func measured(_ value: JSONValue?) -> [Measured]? { + guard let entries = value?.arrayValue, (1...maxMeasurements).contains(entries.count) else { return nil } + var measured: [Measured] = [] + for entry in entries { + guard let pair = entry.arrayValue, pair.count == 2, + case let .number(width) = pair[0], width.rounded() == width, (1...10_000).contains(width), + case let .number(height) = pair[1], height.isFinite, height > 0 else { return nil } + measured.append(Measured(width: width, height: clamp(height))) + } + measured.sort { $0.width < $1.width } + return zip(measured, measured.dropFirst()).allSatisfy { $0.width < $1.width } ? measured : nil + } + + private static func clamp(_ height: Double) -> Double { + min(maxHeight, max(minHeight, height.rounded())) + } + + // JavaScript trim/slice operate on ECMAScript whitespace and UTF-16 code units. + private static let whitespace = CharacterSet(charactersIn: "\u{0009}\u{000A}\u{000B}\u{000C}\u{000D}\u{0020}\u{00A0}\u{1680}\u{2000}\u{2001}\u{2002}\u{2003}\u{2004}\u{2005}\u{2006}\u{2007}\u{2008}\u{2009}\u{200A}\u{2028}\u{2029}\u{202F}\u{205F}\u{3000}\u{FEFF}") + private static func prefix(_ value: String, length: Int) -> String { + String(decoding: value.utf16.prefix(length), as: UTF16.self) + } +} + +/// The theme a page styles against: CSS custom properties named as on web, filled from the +/// iOS system palette, which is what the conversation around the page uses. +struct PathwayHTMLRenderTheme: Equatable, Sendable { + let appearance: String + let variables: [String: String] + + static let fonts = ( + sans: #"-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif"#, + mono: #""SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace"# + ) + // A categorical series after the accent, the same as web's. + private static let chart = ( + light: ["#0d9488", "#d97706", "#9333ea", "#e11d48", "#65a30d"], + dark: ["#2dd4bf", "#fbbf24", "#c084fc", "#fb7185", "#a3e635"] + ) + private static let uriComponentCharacters = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()") + + static func current(_ traits: UITraitCollection) -> Self { + let dark = traits.userInterfaceStyle == .dark + func css(_ color: UIColor, opacity: CGFloat = 1) -> String { + let resolved = color.resolvedColor(with: traits) + return Self.css(resolved.withAlphaComponent(resolved.cgColor.alpha * opacity)) + } + let tint = UIColor.tintColor + var variables = [ + "--background": css(.systemBackground), + "--foreground": css(.label), + "--muted": css(.secondarySystemBackground), + "--muted-foreground": css(.secondaryLabel), + "--card": css(.secondarySystemBackground), + "--card-foreground": css(.label), + "--popover": css(.tertiarySystemBackground), + "--popover-foreground": css(.label), + "--secondary": css(.secondarySystemFill), + "--secondary-foreground": css(.label), + "--border": css(.separator), + "--input": css(.separator), + "--ring": css(tint), + "--primary": css(tint), + "--primary-foreground": css(.white), + "--accent": css(tint), + "--accent-foreground": css(.white), + "--accent-surface": css(tint, opacity: 0.12), + "--accent-surface-foreground": css(tint), + "--destructive": css(.systemRed), + "--destructive-foreground": css(.systemRed), + "--destructive-surface": css(.systemRed, opacity: 0.12), + "--warning": css(.systemOrange), + "--warning-foreground": css(.systemOrange), + "--warning-surface": css(.systemOrange, opacity: 0.12), + "--success": css(.systemGreen), + "--success-foreground": css(.systemGreen), + "--info": css(.systemBlue), + "--info-foreground": css(.systemBlue), + "--code-background": css(.secondarySystemBackground), + "--code-foreground": css(.label), + "--chart-1": css(tint), + "--radius": "0.625rem", + "--font-sans": fonts.sans, + "--font-mono": fonts.mono + ] + for (index, color) in (dark ? chart.dark : chart.light).enumerated() { variables["--chart-\(index + 2)"] = color } + return Self(appearance: dark ? "dark" : "light", variables: variables) + } + + /// `#pathway-theme=`: the page's bootstrap reads it before first paint, then drops it. + var fragment: String { + let json = Self.json(.object(["appearance": .string(appearance), "variables": .object(variables.mapValues(JSONValue.string))])) + return "#pathway-theme=" + (json.addingPercentEncoding(withAllowedCharacters: Self.uriComponentCharacters) ?? "") + } + + /// Posts the MCP Apps `host-context-changed` notification a loaded page restyles on. + var hostContextChangedScript: String { + let message = JSONValue.object([ + "jsonrpc": .string("2.0"), "method": .string("ui/notifications/host-context-changed"), + "params": .object(["theme": .string(appearance), "styles": .object(["variables": .object(variables.mapValues(JSONValue.string))])]) + ]) + return "window.postMessage(\(Self.json(message)), \"*\");" + } + + static func css(_ color: UIColor) -> String { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let channels = [red, green, blue].map { Int((min(1, max(0, $0)) * 255).rounded()) } + if alpha >= 0.999 { return String(format: "#%02x%02x%02x", channels[0], channels[1], channels[2]) } + return "rgba(\(channels[0]), \(channels[1]), \(channels[2]), \(String(format: "%.3g", Double(max(0, alpha)))))" + } + + private static func json(_ value: JSONValue) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)).map { String(decoding: $0, as: UTF8.self) } ?? "{}" + } +} diff --git a/apps/pathway-ios/Pathway/shared/datalayer/PathwayThreadConversationSupport.swift b/apps/pathway-ios/Pathway/shared/datalayer/PathwayThreadConversationSupport.swift index 6e0b27124..95bfb7af2 100644 --- a/apps/pathway-ios/Pathway/shared/datalayer/PathwayThreadConversationSupport.swift +++ b/apps/pathway-ios/Pathway/shared/datalayer/PathwayThreadConversationSupport.swift @@ -301,6 +301,13 @@ extension PathwayAgentThreadModel { if saved.isFileURL { PathwayAttachmentImageLocations.store(saved, directory: storageDirectory, image: key) } return saved } + /// A signed URL that serves a published page inline. Each mount asks for a fresh one, so a + /// page that scrolls back into view never loads with an expired token. + func htmlRenderURL(_ render: PathwayHTMLRender) async throws -> URL { + guard let connect else { throw PathwayThreadConversationError.message("Connect to the environment to view this page.") } + let signed = try await request("assets.createUrl", payload: .object(["resource": render.assetResource]), reportsErrors: false) + return try PathwayEnvironmentHTTP.signedAssetURL(signed, base: try await connect.prepare(environment: environment).httpBaseURL) + } private func resolveAssetURL(_ relative: String) async throws -> URL { guard let connect else { throw PathwayThreadConversationError.message("Connect to the environment to access files.") } let base = try await connect.prepare(environment: environment).httpBaseURL diff --git a/apps/pathway-ios/Pathway/shared/views/AgentHTMLRender.swift b/apps/pathway-ios/Pathway/shared/views/AgentHTMLRender.swift new file mode 100644 index 000000000..e8f337b0e --- /dev/null +++ b/apps/pathway-ios/Pathway/shared/views/AgentHTMLRender.swift @@ -0,0 +1,424 @@ +import SwiftUI +import UIKit +import WebKit + +/// A published HTML page in the feed, at the height that fits it at this width. Its web view +/// exists only while the row is on screen, so a long thread never holds many live pages. +struct AgentTranscriptHTMLRender: View { + let render: PathwayHTMLRender + let model: PathwayAgentThreadModel + @State private var width = 0.0 + @State private var contentHeight: Double? + @State private var visible = false + @State private var showsFullScreen = false + + var body: some View { + Group { + if visible { + AgentHTMLRenderDocument(render: render, model: model, nested: true, + onContentHeight: { if contentHeight != $0 { contentHeight = $0 } }, + onExpand: { showsFullScreen = true }) + } else { + Color.clear + } + } + .frame(maxWidth: .infinity) + .frame(height: render.frameHeight(width: width, contentHeight: contentHeight)) + .onGeometryChange(for: Double.self) { Double($0.size.width) } action: { width = $0 } + .onScrollVisibilityChange(threshold: 0.01) { visible = $0 } + .onDisappear { visible = false } + .fullScreenCover(isPresented: $showsFullScreen) { AgentHTMLRenderFullScreen(render: render, model: model) } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("thread-html-render-\(render.attachmentID)") + } +} + +/// The page with a Done button and a 16 pt gutter in the page's background color, since pages +/// carry no horizontal padding of their own. +struct AgentHTMLRenderFullScreen: View { + let render: PathwayHTMLRender + let model: PathwayAgentThreadModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + AgentHTMLRenderDocument(render: render, model: model, nested: false) + .padding(.horizontal, 16) + .background(Color(uiColor: .systemBackground)) + .ignoresSafeArea(edges: .bottom) + .navigationTitle(render.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + } + } +} + +/// Signs the page's URL and shows it, holding loading and failure in the same box. A failed load +/// retries once with a fresh URL, since the old one may have expired. +private struct AgentHTMLRenderDocument: View { + let render: PathwayHTMLRender + let model: PathwayAgentThreadModel + let nested: Bool + var onContentHeight: (Double) -> Void = { _ in } + var onExpand: (() -> Void)? + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var contrast + @State private var url: URL? + @State private var loaded = false + @State private var failed = false + @State private var retried = false + @State private var attempt = 0 + + var body: some View { + ZStack { + if let url, !failed { + PathwayHTMLRenderWebView(url: url, title: render.title, theme: theme, nested: nested, + onLoad: { loaded = true }, onContentHeight: onContentHeight, onFailure: loadFailed) + .id(url) + if !loaded { ProgressView() } + } else if failed { + Button(action: reload) { + VStack(spacing: 4) { + Label("Page unavailable", systemImage: "exclamationmark.triangle") + Text("Tap to reload").font(.caption) + } + .font(.subheadline).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity).contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityLabel("Page unavailable. Reload \(render.title)") + } else if model.isSubscriptionReady { + ProgressView() + } else { + Label("Page available when reconnected", systemImage: "network.slash") + .font(.subheadline).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topTrailing) { + if loaded, let onExpand { + Button(action: onExpand) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption.weight(.semibold)).foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .background(Color(uiColor: .systemBackground).opacity(0.85), in: .circle) + .overlay { Circle().stroke(Color(uiColor: .separator)) } + .frame(width: 44, height: 44).contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityLabel("Open \(render.title) full screen") + .accessibilityIdentifier("thread-html-render-expand-\(render.attachmentID)") + } + } + .task(id: "\(attempt):\(model.isSubscriptionReady)") { + guard url == nil, !failed else { return } + do { + let signed = try await model.htmlRenderURL(render) + guard !Task.isCancelled else { return } + url = signed + } catch { + guard !Task.isCancelled else { return } + // Disconnected: wait for the subscription instead of showing a failure. + failed = model.isSubscriptionReady + } + } + } + + private var theme: PathwayHTMLRenderTheme { + .current(UITraitCollection { traits in + traits.userInterfaceStyle = colorScheme == .dark ? .dark : .light + traits.accessibilityContrast = contrast == .increased ? .high : .normal + }) + } + + private func loadFailed(retryWithFreshURL: Bool) { + loaded = false + if retried || !retryWithFreshURL { failed = true; return } + retried = true + url = nil + attempt += 1 + } + + private func reload() { + loaded = false + failed = false + retried = false + url = nil + attempt += 1 + } +} + +/// One published page. The page is sandboxed by its response's CSP; this view keeps it in place: +/// no navigation away from its signed URL, no script-opened windows, and links the reader taps +/// open in the browser. Inside the feed it reports its content height and hands vertical drags it +/// cannot use to the feed. +struct PathwayHTMLRenderWebView: UIViewRepresentable { + let url: URL + let title: String + let theme: PathwayHTMLRenderTheme + let nested: Bool + let onLoad: () -> Void + let onContentHeight: (Double) -> Void + let onFailure: (_ retryWithFreshURL: Bool) -> Void + @Environment(\.openURL) private var openURL + + static let messageHandler = "pathwayHtmlRender" + // Runs in a content world page scripts cannot reach, so only this script can post to the + // handler. Measures as the server does. A top-level page has no parent, so its MCP Apps + // `ui/open-link` requests arrive as messages to itself. + static let bridgeScript = """ + (function(){var p=window.webkit&&window.webkit.messageHandlers.\(messageHandler),h,lastOverflow;if(!p)return;\ + function z(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);\ + var b=document.body,overflow=Math.max(r.scrollHeight,b?b.scrollHeight:0)>window.innerHeight+1||Math.max(r.scrollWidth,b?b.scrollWidth:0)>window.innerWidth+1;\ + if(overflow!==lastOverflow){lastOverflow=overflow;p.postMessage({overflow:overflow});}\ + if(v===h||!Number.isFinite(v)||!(v>0))return;h=v;p.postMessage({height:v});}\ + if(window.ResizeObserver){var o=new ResizeObserver(z);o.observe(document.documentElement);if(document.body)o.observe(document.body);}\ + window.addEventListener("load",z);window.addEventListener("resize",z);z();\ + window.addEventListener("message",function(e){var d=e.data,q=d&&d.params;if(e.source!==window||!d||d.jsonrpc!=="2.0"||d.method!=="ui/open-link")return;\ + if((typeof d.id==="string"||(typeof d.id==="number"&&Number.isFinite(d.id)))&&q&&typeof q.url==="string")p.postMessage({openLink:q.url,id:d.id});});})(); + """ + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIView(context: Context) -> WKWebView { + let view = Self.makeWebView(coordinator: context.coordinator) + context.coordinator.load(view) + return view + } + + /// The configured web view, before its page loads. + static func makeWebView(coordinator: Coordinator, configuration: WKWebViewConfiguration = .init()) -> WKWebView { + let nested = coordinator.parent.nested + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = .all + configuration.userContentController.addUserScript(WKUserScript(source: Self.bridgeScript, injectionTime: .atDocumentEnd, + forMainFrameOnly: true, in: .defaultClient)) + configuration.userContentController.add(WeakMessageHandler(coordinator), contentWorld: .defaultClient, name: Self.messageHandler) + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = coordinator + view.uiDelegate = coordinator + view.allowsLinkPreview = false + view.isOpaque = false + view.backgroundColor = .clear + view.scrollView.backgroundColor = .clear + view.accessibilityLabel = coordinator.parent.title + let tap = UITapGestureRecognizer(target: coordinator, action: #selector(Coordinator.tapped)) + tap.cancelsTouchesInView = false + tap.delaysTouchesEnded = false + tap.delegate = coordinator + view.addGestureRecognizer(tap) + if nested { + view.scrollView.isScrollEnabled = false + view.scrollView.bounces = false + view.scrollView.showsVerticalScrollIndicator = false + view.scrollView.showsHorizontalScrollIndicator = false + view.scrollView.contentInsetAdjustmentBehavior = .never + let handoff = UIPanGestureRecognizer(target: nil, action: nil) + handoff.cancelsTouchesInView = false + handoff.delegate = coordinator + view.addGestureRecognizer(handoff) + view.scrollView.panGestureRecognizer.require(toFail: handoff) + coordinator.handoff = handoff + } + return view + } + + @MainActor private final class WeakMessageHandler: NSObject, WKScriptMessageHandler { + weak var coordinator: Coordinator? + init(_ coordinator: Coordinator) { self.coordinator = coordinator } + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + coordinator?.userContentController(controller, didReceive: message) + } + } + + func updateUIView(_ view: WKWebView, context: Context) { + context.coordinator.parent = self + context.coordinator.openURL = openURL + context.coordinator.showTheme(in: view) + } + + static func dismantleUIView(_ view: WKWebView, coordinator: Coordinator) { + view.stopLoading() + view.configuration.userContentController.removeAllScriptMessageHandlers() + view.navigationDelegate = nil + view.uiDelegate = nil + } + + @MainActor final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIGestureRecognizerDelegate { + var parent: PathwayHTMLRenderWebView + var openURL: OpenURLAction? + weak var handoff: UIPanGestureRecognizer? + /// The theme the loaded document shows; nil until it loads. + private var shownTheme: PathwayHTMLRenderTheme? + private var loadedTheme: PathwayHTMLRenderTheme? + private var finished = false + private var failed = false + private var crashes = 0 + private var lastTap: TimeInterval? + + init(_ parent: PathwayHTMLRenderWebView) { self.parent = parent } + + func load(_ view: WKWebView) { + let theme = parent.theme + loadedTheme = theme + shownTheme = nil + finished = false + view.load(URLRequest(url: URL(string: parent.url.absoluteString + theme.fragment) ?? parent.url)) + } + + func showTheme(in view: WKWebView) { + guard let shown = shownTheme, shown != parent.theme else { return } + shownTheme = parent.theme + view.evaluateJavaScript(parent.theme.hostContextChangedScript, in: nil, in: .page, completionHandler: nil) + } + + @objc func tapped() { lastTap = ProcessInfo.processInfo.systemUptime } + + /// Opens an http(s) URL in the browser, once per tap on the page; a page cannot open one by itself. + func openExternally(_ url: URL?) -> Bool { + guard let url, Self.externalURL(url.absoluteString) != nil, let openURL, + let lastTap, ProcessInfo.processInfo.systemUptime - lastTap < 2 else { return false } + self.lastTap = nil + openURL(url) + return true + } + + private func fail(retryWithFreshURL: Bool = true) { + guard !failed else { return } + failed = true + parent.onFailure(retryWithFreshURL) + } + + private static func withoutFragment(_ url: URL?) -> String? { url?.absoluteString.components(separatedBy: "#").first } + + // MARK: Messages from the bridge script + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == PathwayHTMLRenderWebView.messageHandler, message.frameInfo.isMainFrame, + let body = message.body as? [String: Any] else { return } + if let overflow = body["overflow"] as? Bool { + message.webView?.scrollView.isScrollEnabled = !parent.nested || overflow + } else if let height = Self.number(body["height"]), height > 0 { + parent.onContentHeight(height) + } else if let link = body["openLink"] as? String, let id = body["id"], id is String || Self.number(id) != nil, + openExternally(Self.externalURL(link)) { + message.webView?.callAsyncJavaScript("window.postMessage(m, \"*\")", + arguments: ["m": ["jsonrpc": "2.0", "id": id, "result": [String: Any]()]], + in: nil, in: .page, completionHandler: nil) + } + } + + static func number(_ value: Any?) -> Double? { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), number.doubleValue.isFinite else { return nil } + return number.doubleValue + } + + static func externalURL(_ value: String) -> URL? { + guard value.range(of: #"^https?://"#, options: [.regularExpression, .caseInsensitive]) != nil, + let url = URL(string: value), let host = url.host, !host.isEmpty else { return nil } + return url + } + + /// A sandbox directive must actually restrict the page to the allowed capabilities. + /// A word in another directive, or allow-same-origin/top-navigation, does not isolate it. + static func acceptsResponse(_ response: HTTPURLResponse, for url: URL) -> Bool { + guard withoutFragment(response.url) == withoutFragment(url), (200..<300).contains(response.statusCode), + response.mimeType == "text/html", let csp = response.value(forHTTPHeaderField: "Content-Security-Policy") else { return false } + let allowed = Set(["allow-scripts", "allow-forms", "allow-popups"]) + return csp.split(separator: ",").contains { policy in + for directive in policy.split(separator: ";") { + let tokens = directive.lowercased().split(whereSeparator: { " \t\n\r\u{000C}".contains($0) }).map(String.init) + if tokens.first == "sandbox" { return tokens.dropFirst().allSatisfy { allowed.contains($0) } } + } + return false + } + } + + // MARK: Navigation + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { + let url = navigationAction.request.url + guard let target = navigationAction.targetFrame else { + // A new window: `target=_blank` on a tapped link. + if navigationAction.navigationType == .linkActivated { _ = openExternally(url) } + decisionHandler(.cancel) + return + } + if target.isMainFrame { + if Self.withoutFragment(url) == Self.withoutFragment(parent.url) { decisionHandler(.allow); return } + if navigationAction.navigationType == .linkActivated { _ = openExternally(url) } + decisionHandler(.cancel) + } else { + decisionHandler(["http", "https", "about", "data"].contains(url?.scheme?.lowercased() ?? "") ? .allow : .cancel) + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void) { + guard navigationResponse.isForMainFrame else { decisionHandler(.allow); return } + // Only a page the environment served inline and sandboxed; an expired token is a 403. + let response = navigationResponse.response as? HTTPURLResponse + if let response, Self.acceptsResponse(response, for: parent.url) { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + fail() + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + finished = true + shownTheme = loadedTheme + showTheme(in: webView) + parent.onLoad() + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { + loadFailed(error) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + loadFailed(error) + } + + private func loadFailed(_ error: any Error) { + let error = error as NSError + guard !finished, !(error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled) else { return } + fail() + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + // Reload once; a page that keeps crashing its process is not reloaded forever. + crashes += 1 + if crashes > 1 { fail(retryWithFreshURL: false) } else { load(webView) } + } + + // MARK: Windows + + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + if navigationAction.navigationType == .linkActivated { _ = openExternally(navigationAction.request.url) } + return nil + } + + // MARK: Gestures + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { true } + + /// The handoff pan begins, failing the page's own scrolling, for a vertical drag the page + /// cannot follow: down at its top or up at its bottom. The feed then scrolls instead. + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === handoff, let handoff, let scrollView = (handoff.view as? WKWebView)?.scrollView else { return true } + guard scrollView.isScrollEnabled else { return false } + let velocity = handoff.velocity(in: handoff.view) + guard abs(velocity.y) > abs(velocity.x) else { return false } + let top = -scrollView.adjustedContentInset.top + let bottom = max(top, scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom) + return velocity.y > 0 ? scrollView.contentOffset.y <= top + 1 : scrollView.contentOffset.y >= bottom - 1 + } + } +} diff --git a/apps/pathway-ios/Pathway/shared/views/AgentThreadTranscript.swift b/apps/pathway-ios/Pathway/shared/views/AgentThreadTranscript.swift index 77324d9dd..e0b7d4da2 100644 --- a/apps/pathway-ios/Pathway/shared/views/AgentThreadTranscript.swift +++ b/apps/pathway-ios/Pathway/shared/views/AgentThreadTranscript.swift @@ -74,6 +74,9 @@ struct AgentThreadTranscript: View { } } else if let notice = PathwayComputerNotice(item) { AgentTranscriptComputerNotice(item: item, notice: notice, model: model) + } else if let render = PathwayHTMLRender(item) { + AgentTranscriptHTMLRender(render: render, model: model) + .id(render.attachmentID) } else { AgentTranscriptEventRow(item: item, model: model, onOpenChild: onOpenChild) } @@ -537,7 +540,9 @@ struct AgentTranscriptEventRow: View { case "web_search": return "Searched \(item.fields["patterns"]?.arrayValue?.compactMap(\.stringValue).joined(separator: ", ") ?? "the web")" case "subagent": return "Agent work" case "reasoning": return "Thinking" - case "dynamic_tool": return item.fields["toolName"]?.stringValue ?? "Tool call" + case "dynamic_tool": + let toolName = item.fields["toolName"]?.stringValue + return PathwayHTMLRender.toolLabel(toolName) ?? toolName ?? "Tool call" case "checkpoint": return "Saved checkpoint" default: return item.type.replacingOccurrences(of: "_", with: " ").capitalized } diff --git a/apps/pathway-ios/Pathway/shared/views/AgentThreadTranscriptLayout.swift b/apps/pathway-ios/Pathway/shared/views/AgentThreadTranscriptLayout.swift index fe14b5af8..7e61b7611 100644 --- a/apps/pathway-ios/Pathway/shared/views/AgentThreadTranscriptLayout.swift +++ b/apps/pathway-ios/Pathway/shared/views/AgentThreadTranscriptLayout.swift @@ -14,10 +14,12 @@ enum AgentThreadTranscriptLayout { private static let persistent = Set(["subagent", "fork", "thread_created", "proposed_plan"]) private static let attention = Set(["approval_request", "user_input_request", "error", "run_interrupt_request", "run_interrupt_result"]) - /// Rows that never fold: durable links, anything asking for attention, and Computer notices. + /// Rows that never fold: durable links, anything asking for attention, Computer notices, and + /// published HTML pages, which stay above the reply they belong to. private static func isPinned(_ item: PathwayTimelineItem) -> Bool { persistent.contains(item.type) || attention.contains(item.type) || item.fields["workspacePreparation"]?.objectValue != nil || PathwayComputerNotice(item) != nil + || PathwayHTMLRender(item) != nil } static func rows(_ items: [PathwayTimelineItem], activeRunID: String?) -> [AgentTranscriptRow] { @@ -101,6 +103,8 @@ final class AgentThreadTranscriptLayoutCache { let status: String let streaming: Bool let workspacePreparation: Bool + /// Output can land after the status turns completed; a page then pins its row. + let htmlRender: Bool let startedAt: String? let completedAt: String? let updatedAt: String? @@ -118,6 +122,7 @@ final class AgentThreadTranscriptLayoutCache { let settled = item.runID != activeRunID && !item.streaming && !["running", "pending", "waiting"].contains(item.status) return ItemKey(id: item.id, type: item.type, runID: item.runID, status: item.status, streaming: item.streaming, workspacePreparation: item.fields["workspacePreparation"]?.objectValue != nil, + htmlRender: PathwayHTMLRender(item) != nil, startedAt: settled ? item.fields["startedAt"]?.stringValue : nil, completedAt: settled ? item.fields["completedAt"]?.stringValue : nil, updatedAt: settled ? item.fields["updatedAt"]?.stringValue : nil) diff --git a/apps/pathway-ios/PathwayTests/AgentThreadTranscriptLayoutTests.swift b/apps/pathway-ios/PathwayTests/AgentThreadTranscriptLayoutTests.swift index 8852c5c52..5109007ff 100644 --- a/apps/pathway-ios/PathwayTests/AgentThreadTranscriptLayoutTests.swift +++ b/apps/pathway-ios/PathwayTests/AgentThreadTranscriptLayoutTests.swift @@ -86,6 +86,70 @@ struct AgentThreadTranscriptLayoutTests { #expect(cache.rebuildCount == 2) } + @Test func publishedPageStaysAboveTheAnswerWhenItsRunFolds() throws { + let page = try render("page", ordinal: 2) + let rows = AgentThreadTranscriptLayout.rows([ + try item("user", type: "user_message", ordinal: 0), + try item("command", type: "command_execution", ordinal: 1), + page, + try item("later", type: "command_execution", ordinal: 3), + try item("answer", type: "assistant_message", ordinal: 4) + ], activeRunID: nil) + #expect(rows.map(\.id) == ["user", "work:run", "page", "answer"]) + guard case let .work(_, folded, _) = rows[1].content else { Issue.record("Expected settled work fold"); return } + #expect(folded.map(\.id) == ["command", "later"]) + + // While the run is active the page ends the activity group around it. + let active = AgentThreadTranscriptLayout.rows([ + try item("search", type: "file_search", ordinal: 0), try item("command", type: "command_execution", ordinal: 1), + page, try item("later", type: "command_execution", ordinal: 3) + ], activeRunID: "run") + #expect(active.map(\.id) == ["activity:search", "page", "later"]) + } + + @Test func unpublishedPagesFoldLikeOtherToolCalls() throws { + let rows = AgentThreadTranscriptLayout.rows([ + try item("user", type: "user_message", ordinal: 0), + try render("failed", ordinal: 1, status: "failed"), + try item("answer", type: "assistant_message", ordinal: 2) + ], activeRunID: nil) + #expect(rows.map(\.id) == ["user", "work:run", "answer"]) + } + + @Test func pageOutputArrivingAfterCompletionRebuildsTheLayout() throws { + let cache = AgentThreadTranscriptLayoutCache() + let pending = try render("page", ordinal: 0, output: .null) + #expect(cache.rows([pending], activeRunID: nil).map(\.id) == ["work:run"]) + #expect(cache.rows([try render("page", ordinal: 0)], activeRunID: nil).map(\.id) == ["page"]) + #expect(cache.rebuildCount == 2) + } + + @Test func pageMetadataUsesCurrentItemsAndInvalidOutputUnpinsTheRow() throws { + let cache = AgentThreadTranscriptLayoutCache() + _ = cache.rows([try render("page", ordinal: 0)], activeRunID: nil) + let changed: JSONValue = .object(["htmlRender": .object([ + "attachmentId": .string("thread-replacement-html"), "title": .string("Updated"), "height": .number(600) + ])]) + let rows = cache.rows([try render("page", ordinal: 0, output: changed)], activeRunID: nil) + guard case let .item(item) = rows.first?.content else { Issue.record("Expected page row"); return } + #expect(PathwayHTMLRender(item)?.attachmentID == "thread-replacement-html") + #expect(PathwayHTMLRender(item)?.height == 600) + #expect(cache.rebuildCount == 1) + let invalid: JSONValue = .object(["htmlRender": changed.objectValue!["htmlRender"]!, "isError": .bool(true)]) + #expect(cache.rows([try render("page", ordinal: 0, output: invalid)], activeRunID: nil).map(\.id) == ["work:run"]) + #expect(cache.rebuildCount == 2) + } + + private func render(_ id: String, ordinal: Int, status: String = "completed", output: JSONValue? = nil) throws -> PathwayTimelineItem { + let reference: JSONValue = .object(["attachmentId": .string("thread-\(id)-html"), "title": .string("Chart"), "height": .number(420)]) + return try #require(PathwayTimelineItem(json: .object([ + "id": .string(id), "type": .string("dynamic_tool"), "ordinal": .number(Double(ordinal)), "runId": .string("run"), + "status": .string(status), "toolName": .string("pathway.html_render"), "startedAt": .string("2026-09-06T00:00:01Z"), + "completedAt": .string("2026-09-06T00:00:02Z"), "updatedAt": .string("2026-09-06T00:00:02Z"), + "output": output ?? .object(["htmlRender": reference, "message": .string("Rendered above your reply.")]) + ]))) + } + private func item(_ id: String, type: String, ordinal: Int, run: String = "run", status: String = "completed", streaming: Bool = false, start: String = "2026-09-06T00:00:01Z", end: String = "2026-09-06T00:00:02Z") throws -> PathwayTimelineItem { try #require(PathwayTimelineItem(json: .object([ diff --git a/apps/pathway-ios/PathwayTests/PathwayHTMLRenderTests.swift b/apps/pathway-ios/PathwayTests/PathwayHTMLRenderTests.swift new file mode 100644 index 000000000..4b86c4ae6 --- /dev/null +++ b/apps/pathway-ios/PathwayTests/PathwayHTMLRenderTests.swift @@ -0,0 +1,181 @@ +import Foundation +@testable import Pathway +import Testing +import UIKit + +@MainActor +struct PathwayHTMLRenderTests { + private let reference: JSONValue = .object(["attachmentId": .string("thread-abc-123-html"), "title": .string("Chart"), "height": .number(420)]) + + @Test func readsCompletedRendersInEveryToolSpelling() throws { + for toolName in ["pathway.html_render", "mcp__pathway__html_render", "html_render", "pathway_html_render", + "mcp__pathway_code__html_render", "pathway_code.html_render", "pathway_code/html_render", "pathway:html_render", + "pathway_code_html_render", "mcp__PATHWAY__html_render", "PATHWAY/html_render", " pathway.html_render completed "] { + let render = try #require(PathwayHTMLRender(try item(toolName: toolName)), "\(toolName)") + #expect(render.attachmentID == "thread-abc-123-html") + #expect(render.title == "Chart") + #expect(render.height == 420) + #expect(render.heights.isEmpty) + } + } + + @Test func leavesRunningFailedErroredAndOtherToolsAsWorkRows() throws { + let output: JSONValue = .object(["htmlRender": reference]) + #expect(PathwayHTMLRender(try item(status: "running")) == nil) + #expect(PathwayHTMLRender(try item(status: "failed")) == nil) + #expect(PathwayHTMLRender(try item(status: "cancelled")) == nil) + #expect(PathwayHTMLRender(try item(output: .object(["htmlRender": reference, "isError": .bool(true)]))) == nil) + #expect(PathwayHTMLRender(try item(toolName: "pathway.html_preview", output: output)) == nil) + #expect(PathwayHTMLRender(try item(toolName: "mcp__other__html_render", output: output)) == nil) + #expect(PathwayHTMLRender(try item(toolName: "other.html_render", output: output)) == nil) + #expect(PathwayHTMLRender(try item(toolName: "pathway.HTML_RENDER", output: output)) == nil) + #expect(PathwayHTMLRender(try item(toolName: "MCP__pathway__html_render", output: output)) == nil) + #expect(PathwayHTMLRender(try item(type: "command_execution", output: output)) == nil) + #expect(PathwayHTMLRender(try item(output: .array([output]))) == nil) + // A provider envelope is the server's to normalize, not a page. + #expect(PathwayHTMLRender(try item(output: .object(["structuredContent": output]))) == nil) + #expect(PathwayHTMLRender(try item(output: .string("{\"htmlRender\":{}}"))) == nil) + } + + @Test func validatesTheReferenceLikeTheSharedReader() { + func read(_ changes: [String: JSONValue]) -> PathwayHTMLRender? { + guard case var .object(fields) = reference else { return nil } + fields.merge(changes) { $1 } + return PathwayHTMLRender(reference: .object(fields)) + } + #expect(read(["height": .number(99_999)])?.height == 2_000) + #expect(read(["height": .number(12)])?.height == 80) + #expect(read(["height": .number(420.5)])?.height == 421) + #expect(read(["title": .string(" ")])?.title == "HTML") + #expect(read(["title": .string(" Revenue \n")])?.title == "Revenue") + #expect(read(["title": .string(String(repeating: "a", count: 300))])?.title.count == 200) + #expect(read(["title": .string(String(repeating: "😀", count: 150))])?.title.utf16.count == 200) + #expect(read(["title": .string("\u{FEFF} Chart \u{FEFF}")])?.title == "Chart") + #expect(read(["attachmentId": .number(4)]) == nil) + #expect(read(["attachmentId": .string("")]) == nil) + #expect(read(["attachmentId": .string(String(repeating: "a", count: 257))]) == nil) + #expect(read(["title": .null]) == nil) + #expect(read(["height": .string("420")]) == nil) + #expect(read(["height": .number(.nan)]) == nil) + #expect(read(["height": .number(.infinity)]) == nil) + + let sorted = read(["heights": heights([[728, 1403], [390, 1290], [1000, 1660]])]) + #expect(sorted?.heights.map(\.width) == [390, 728, 1000]) + #expect(read(["heights": heights([[728, 99_999]])])?.heights.first?.height == 2_000) + // A malformed table is dropped whole; the page still shows at the agent's height. + for malformed: JSONValue in [ + .array([.array([.number(728), .string("x")])]), heights([[728.5, 400]]), heights([[0, 400]]), heights([[10_001, 400]]), + heights([[728, 0]]), heights([[728, -1]]), heights([[728, .infinity]]), heights([[728, 400], [728, 500]]), heights([]), heights(Array(repeating: [320, 400], count: 25)), + .array([.array([.number(728)])]), .string("[[728,400]]") + ] { + let render = read(["heights": malformed]) + #expect(render != nil) + #expect(render?.heights.isEmpty == true) + } + } + + /// The `htmlRenderFrameHeight` cases from `packages/shared/src/htmlRender.test.ts` (#16283), so the two stay in step. + @Test func frameHeightMatchesTheSharedRule() throws { + let fixture = """ + [ + {"reference": {"height": 1500, "heights": [[728, 1403], [390, 1290], [1000, 1660]]}, + "cases": [[728, null, 1403], [559, null, 1403], [320, null, 1290]]}, + {"reference": {"height": 2000, "heights": [[520, 900], [640, 450]]}, + "cases": [[590, null, 900], [640, null, 450]]}, + {"reference": {"height": 1403, "heights": [[728, 1403], [390, 1290], [1000, 1660]]}, + "cases": [[728, 1415, 1415], [1400, null, 1660], [728, 5000, 2000]]}, + {"reference": {"height": 600, "heights": [[728, 1403], [390, 1290], [1000, 1660]]}, + "cases": [[728, 1415, 600], [1400, null, 600]]}, + {"reference": {"height": 420}, + "cases": [[728, null, 420], [728, 900, 420], [728, 300, 300], [728, 20, 80]]} + ] + """ + let groups = try #require(try JSONDecoder().decode(JSONValue.self, from: Data(fixture.utf8)).arrayValue) + for group in groups { + guard case var .object(fields) = group.objectValue?["reference"], case let .object(base) = reference else { Issue.record("Bad fixture"); return } + fields.merge(base) { current, _ in current } + let render = try #require(PathwayHTMLRender(reference: .object(fields))) + for value in group.objectValue?["cases"]?.arrayValue ?? [] { + let row = try #require(value.arrayValue) + guard case let .number(width) = row[0], case let .number(expected) = row[2] else { Issue.record("Bad case"); continue } + let content: Double? = if case let .number(height) = row[1] { height } else { nil } + #expect(render.frameHeight(width: width, contentHeight: content) == expected, "\(fields) at \(width), content \(String(describing: content))") + } + } + } + + @Test func requestsTheStoredPageInline() throws { + let render = try #require(PathwayHTMLRender(reference: .object([ + "attachmentId": .string("thread-abc-123-html"), "title": .string("Q3: revenue / costs?"), "height": .number(420) + ]))) + #expect(render.assetResource == .object([ + "_tag": .string("attachment"), "attachmentId": .string("thread-abc-123-html"), "fileName": .string("Q3 revenue costs.html"), + "mimeType": .string("text/html"), "disposition": .string("inline") + ])) + #expect(try #require(PathwayHTMLRender(reference: .object(["attachmentId": .string("a"), "title": .string("<>|"), "height": .number(80)]))).fileName == "Page.html") + #expect(try #require(PathwayHTMLRender(reference: .object([ + "attachmentId": .string("a"), "title": .string(String(repeating: "😀", count: 100)), "height": .number(80) + ]))).fileName.utf16.count == 125) + } + + @Test func labelsRunningHtmlToolCalls() { + #expect(PathwayHTMLRender.toolLabel("pathway.html_render") == "Render an HTML page") + #expect(PathwayHTMLRender.toolLabel("mcp__pathway__html_preview") == "Preview an HTML page") + #expect(PathwayHTMLRender.toolLabel("pathway.delegate_task") == nil) + #expect(PathwayHTMLRender.toolLabel(nil) == nil) + } + + @Test func themeHasTheSharedVariablesAndTheBridgeShapes() throws { + // The variable names `htmlRenderTheme` produces in `packages/shared/src/htmlRender.ts`. + let names: Set = [ + "--background", "--foreground", "--muted", "--muted-foreground", "--card", "--card-foreground", "--popover", + "--popover-foreground", "--secondary", "--secondary-foreground", "--border", "--input", "--ring", "--primary", + "--primary-foreground", "--accent", "--accent-foreground", "--accent-surface", "--accent-surface-foreground", + "--destructive", "--destructive-foreground", "--destructive-surface", "--warning", "--warning-foreground", + "--warning-surface", "--success", "--success-foreground", "--info", "--info-foreground", "--code-background", + "--code-foreground", "--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5", "--chart-6", "--radius", + "--font-sans", "--font-mono" + ] + let dark = PathwayHTMLRenderTheme.current(UITraitCollection(userInterfaceStyle: .dark)) + let light = PathwayHTMLRenderTheme.current(UITraitCollection(userInterfaceStyle: .light)) + #expect(Set(dark.variables.keys) == names) + #expect(dark.appearance == "dark" && light.appearance == "light") + #expect(dark.variables["--background"] == "#000000") + #expect(light.variables["--background"] == "#ffffff") + #expect(dark.variables["--chart-2"] == "#2dd4bf") + // The page's bootstrap drops any value with these characters. + #expect(dark.variables.values.allSatisfy { $0.rangeOfCharacter(from: CharacterSet(charactersIn: ";{}<>")) == nil }) + + let fragment = dark.fragment + #expect(fragment.hasPrefix("#pathway-theme=")) + #expect(!fragment.contains("&") && !fragment.contains(" ")) + let encoded = try #require(fragment.dropFirst("#pathway-theme=".count).removingPercentEncoding) + let decoded = try JSONDecoder().decode(JSONValue.self, from: Data(encoded.utf8)) + #expect(decoded == .object(["appearance": .string("dark"), "variables": .object(dark.variables.mapValues(JSONValue.string))])) + #expect(URL(string: "https://environment.example/api/assets/token/Chart.html" + fragment) != nil) + + let script = light.hostContextChangedScript + #expect(script.hasPrefix("window.postMessage(") && script.hasSuffix(", \"*\");")) + let message = String(script.dropFirst("window.postMessage(".count).dropLast(", \"*\");".count)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data(message.utf8)) == .object([ + "jsonrpc": .string("2.0"), "method": .string("ui/notifications/host-context-changed"), + "params": .object(["theme": .string("light"), "styles": .object(["variables": .object(light.variables.mapValues(JSONValue.string))])]) + ])) + } + + @Test func writesCssColors() { + #expect(PathwayHTMLRenderTheme.css(UIColor(red: 1, green: 0.5, blue: 0, alpha: 1)) == "#ff8000") + #expect(PathwayHTMLRenderTheme.css(UIColor(red: 0, green: 0, blue: 1, alpha: 0.12)) == "rgba(0, 0, 255, 0.12)") + } + + private func heights(_ rows: [[Double]]) -> JSONValue { .array(rows.map { .array($0.map(JSONValue.number)) }) } + + private func item(type: String = "dynamic_tool", toolName: String = "pathway.html_render", status: String = "completed", + output: JSONValue? = nil) throws -> PathwayTimelineItem { + try #require(PathwayTimelineItem(json: .object([ + "id": .string("render"), "type": .string(type), "status": .string(status), "toolName": .string(toolName), + "input": .object(["title": .string("Chart"), "height": .number(420), "htmlBytes": .number(2400)]), + "output": output ?? .object(["htmlRender": reference, "message": .string("Rendered above your reply.")]) + ]))) + } +} diff --git a/apps/pathway-ios/PathwayTests/PathwayHTMLRenderWebViewTests.swift b/apps/pathway-ios/PathwayTests/PathwayHTMLRenderWebViewTests.swift new file mode 100644 index 000000000..686d36a67 --- /dev/null +++ b/apps/pathway-ios/PathwayTests/PathwayHTMLRenderWebViewTests.swift @@ -0,0 +1,285 @@ +import Foundation +import Network +import SwiftUI +@testable import Pathway +import Testing +import UIKit +import WebKit + +/// Real WebKit, off screen, loading pages over loopback HTTP with the environment's sandbox +/// headers, so these cover what unit tests of the Swift alone cannot. +@MainActor +struct PathwayHTMLRenderWebViewTests { + private static let sandboxed = ["Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups"] + private static let tallPage = "
" + + @Test(.timeLimit(.minutes(1))) func reportsTheContentHeightOfASandboxedPage() async throws { + let server = try await PageServer.start(pages: ["/api/assets/token/Chart.html": (Self.tallPage, Self.sandboxed)]) + defer { server.stop() } + #expect(await load("/api/assets/token/Chart.html", server: server) == ["load", "height 500"]) + } + + @Test(.timeLimit(.minutes(1))) func fittedPagesLeaveScrollingToTheFeed() async throws { + let server = try await PageServer.start(pages: ["/api/assets/token/Chart.html": (Self.tallPage, Self.sandboxed)]) + defer { server.stop() } + #expect(await load("/api/assets/token/Chart.html", server: server, frameHeight: 500, expectedScrollEnabled: false) == ["load", "height 500"]) + } + + @Test(.timeLimit(.minutes(1))) func refusesAPageServedWithoutTheSandbox() async throws { + let server = try await PageServer.start(pages: [ + "/api/assets/token/Plain.html": (Self.tallPage, ["Content-Type": "text/html"]), + "/api/assets/token/Download.html": (Self.tallPage, ["Content-Type": "application/octet-stream", "Content-Security-Policy": "default-src 'none'; sandbox"]), + "/api/assets/token/FalseSandbox.html": (Self.tallPage, ["Content-Type": "text/html", "Content-Security-Policy": "default-src https://sandbox.example"]), + "/api/assets/token/SameOrigin.html": (Self.tallPage, ["Content-Type": "text/html", "Content-Security-Policy": "sandbox allow-scripts allow-same-origin"]) + ]) + defer { server.stop() } + #expect(await load("/api/assets/token/Plain.html", server: server) == ["failure"]) + #expect(await load("/api/assets/token/Download.html", server: server) == ["failure"]) + #expect(await load("/api/assets/token/FalseSandbox.html", server: server) == ["failure"]) + #expect(await load("/api/assets/token/SameOrigin.html", server: server) == ["failure"]) + } + + @Test(.timeLimit(.minutes(1))) func bridgeForwardsLinkRequestsAndThePageReceivesTheTheme() async throws { + // The page reports its origin and whether it can see the bridge's handler, then answers a + // theme notification with a link request, the way the published bootstrap checks its source. + let page = """ +

Page

+ """ + let server = try await PageServer.start(pages: ["/api/assets/token/Bridge.html": (page, Self.sandboxed)]) + defer { server.stop() } + let (events, continuation) = AsyncStream.makeStream(of: String.self) + let recorder = Recorder(continuation: continuation) + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.userContentController.addUserScript(WKUserScript(source: PathwayHTMLRenderWebView.bridgeScript, injectionTime: .atDocumentEnd, + forMainFrameOnly: true, in: .defaultClient)) + configuration.userContentController.add(recorder, contentWorld: .defaultClient, name: PathwayHTMLRenderWebView.messageHandler) + let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 320, height: 200), configuration: configuration) + defer { view.stopLoading(); configuration.userContentController.removeAllScriptMessageHandlers() } + view.load(URLRequest(url: server.url("/api/assets/token/Bridge.html"))) + var iterator = events.makeAsyncIterator() + func nextLink() async -> String? { + while let event = await iterator.next(isolation: #isolation) { if event.hasPrefix("link") { return event } } + return nil + } + // Sandboxed: an opaque origin, and the page's own scripts cannot post to the handler. + #expect(await nextLink() == "link https://example.com/null/undefined 7") + view.evaluateJavaScript(PathwayHTMLRenderTheme.current(UITraitCollection(userInterfaceStyle: .dark)).hostContextChangedScript, + in: nil, in: .page, completionHandler: nil) + #expect(await nextLink() == "link https://example.com/dark/2dd4bf theme") + withExtendedLifetime((view, recorder)) {} + } + + @Test func responseRequiresTheSignedPageAndARestrictiveSandboxDirective() throws { + let url = try #require(URL(string: "https://environment.example/api/assets/token/Chart.html")) + func accepts(_ policy: String, status: Int = 200, mime: String = "text/html", responseURL: URL? = nil) -> Bool { + let response = HTTPURLResponse(url: responseURL ?? url, statusCode: status, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": mime, "Content-Security-Policy": policy])! + return PathwayHTMLRenderWebView.Coordinator.acceptsResponse(response, for: url) + } + #expect(accepts("sandbox allow-scripts allow-forms allow-popups")) + #expect(accepts("default-src 'none'; SANDBOX")) + #expect(!accepts("default-src https://sandbox.example")) + #expect(!accepts("sandbox\u{00A0}allow-scripts")) + #expect(!accepts("sandbox allow-scripts allow-same-origin")) + #expect(!accepts("sandbox allow-top-navigation")) + #expect(!accepts("sandbox allow-popups-to-escape-sandbox")) + #expect(!accepts("sandbox allow-same-origin; sandbox")) + #expect(!accepts("sandbox", status: 403)) + #expect(!accepts("sandbox", mime: "application/octet-stream")) + #expect(!accepts("sandbox", responseURL: URL(string: "https://other.example/Chart.html"))) + } + + @Test func nativeBridgeRejectsBooleanNonfiniteNumbersAndNonHttpLinks() { + for value: Any in [true, false, Double.nan, Double.infinity, "412"] { + #expect(PathwayHTMLRenderWebView.Coordinator.number(value) == nil) + } + #expect(PathwayHTMLRenderWebView.Coordinator.number(412) == 412) + for link in ["file:///etc/passwd", "javascript:alert(1)", "https://", "about:blank", "https:example.com"] { + #expect(PathwayHTMLRenderWebView.Coordinator.externalURL(link) == nil) + } + #expect(PathwayHTMLRenderWebView.Coordinator.externalURL("https://example.com/a") != nil) + } + + @Test func externalLinksRequireATapAndConsumeItOnlyOnce() { + let coordinator = PathwayHTMLRenderWebView.Coordinator(parent()) + var opened: [URL] = [] + coordinator.openURL = OpenURLAction { url in opened.append(url); return .handled } + let url = URL(string: "https://example.com/a")! + #expect(!coordinator.openExternally(url)) + coordinator.tapped() + #expect(!coordinator.openExternally(URL(string: "file:///etc/passwd"))) + #expect(coordinator.openExternally(url)) + #expect(!coordinator.openExternally(url)) + #expect(opened == [url]) + } + + @Test func webViewDoesNotRetainItsCoordinatorAndUsesEphemeralStorage() throws { + var coordinator: PathwayHTMLRenderWebView.Coordinator? = .init(parent()) + weak var weakCoordinator = coordinator + let view = PathwayHTMLRenderWebView.makeWebView(coordinator: try #require(coordinator)) + #expect(!view.configuration.websiteDataStore.isPersistent) + #expect(!view.configuration.preferences.javaScriptCanOpenWindowsAutomatically) + #expect(!view.scrollView.isScrollEnabled) + coordinator = nil + #expect(weakCoordinator == nil) + withExtendedLifetime(view) {} + } + + @Test func secondProcessCrashFailsWithoutAURLRetry() { + var retries: [Bool] = [] + let coordinator = PathwayHTMLRenderWebView.Coordinator(parent(onFailure: { retries.append($0) })) + let view = PathwayHTMLRenderWebView.makeWebView(coordinator: coordinator) + defer { PathwayHTMLRenderWebView.dismantleUIView(view, coordinator: coordinator) } + coordinator.webViewWebContentProcessDidTerminate(view) + #expect(retries.isEmpty) + coordinator.webViewWebContentProcessDidTerminate(view) + coordinator.webViewWebContentProcessDidTerminate(view) + #expect(retries == [false]) + } + + @Test func handoffUsesOnlyOutwardVerticalDragsAtThePageEdges() throws { + let coordinator = PathwayHTMLRenderWebView.Coordinator(parent()) + let view = PathwayHTMLRenderWebView.makeWebView(coordinator: coordinator) + defer { PathwayHTMLRenderWebView.dismantleUIView(view, coordinator: coordinator) } + view.frame = CGRect(x: 0, y: 0, width: 320, height: 200) + view.scrollView.frame = view.bounds + view.scrollView.contentSize = CGSize(width: 320, height: 500) + let pan = TestPan() + view.addGestureRecognizer(pan) + coordinator.handoff = pan + pan.direction = CGPoint(x: 0, y: 100) + #expect(!coordinator.gestureRecognizerShouldBegin(pan)) + view.scrollView.isScrollEnabled = true + #expect(coordinator.gestureRecognizerShouldBegin(pan)) + pan.direction.y = -100 + #expect(!coordinator.gestureRecognizerShouldBegin(pan)) + view.scrollView.contentOffset.y = 100 + #expect(!coordinator.gestureRecognizerShouldBegin(pan)) + view.scrollView.contentOffset.y = 300 + #expect(coordinator.gestureRecognizerShouldBegin(pan)) + pan.direction.y = 100 + #expect(!coordinator.gestureRecognizerShouldBegin(pan)) + pan.direction.x = 200 + #expect(!coordinator.gestureRecognizerShouldBegin(pan)) + #expect(coordinator.gestureRecognizer(pan, shouldRecognizeSimultaneouslyWith: UIPanGestureRecognizer())) + } + + private func parent(onFailure: @escaping (Bool) -> Void = { _ in }) -> PathwayHTMLRenderWebView { + .init(url: URL(string: "http://127.0.0.1:9/api/assets/token/Chart.html")!, title: "Chart", + theme: .current(UITraitCollection(userInterfaceStyle: .dark)), nested: true, + onLoad: {}, onContentHeight: { _ in }, onFailure: onFailure) + } + + private func load(_ path: String, server: PageServer, frameHeight: CGFloat = 200, expectedScrollEnabled: Bool = true) async -> Set { + let (events, continuation) = AsyncStream.makeStream(of: String.self) + let parent = PathwayHTMLRenderWebView( + url: server.url(path), title: "Chart", + theme: .current(UITraitCollection(userInterfaceStyle: .dark)), nested: true, + onLoad: { continuation.yield("load") }, onContentHeight: { continuation.yield("height \(Int($0))") }, + onFailure: { _ in continuation.yield("failure") }) + let coordinator = PathwayHTMLRenderWebView.Coordinator(parent) + let view = PathwayHTMLRenderWebView.makeWebView(coordinator: coordinator) + defer { PathwayHTMLRenderWebView.dismantleUIView(view, coordinator: coordinator) } + view.frame = CGRect(x: 0, y: 0, width: 320, height: frameHeight) + coordinator.load(view) + var seen = Set() + for await event in events { + seen.insert(event) + if event == "failure" || seen.isSuperset(of: ["load", "height 500"]) { break } + } + if !seen.contains("failure") { #expect(view.scrollView.isScrollEnabled == expectedScrollEnabled) } + withExtendedLifetime((view, coordinator)) {} + return seen + } +} + +@MainActor private final class TestPan: UIPanGestureRecognizer { + var direction = CGPoint.zero + override func velocity(in view: UIView?) -> CGPoint { direction } +} + +/// Serves pages over loopback HTTP the way the environment's asset route does, headers included. +private final class PageServer: @unchecked Sendable { + typealias Page = (html: String, headers: [String: String]) + private let listener: NWListener + private let port: UInt16 + + private init(listener: NWListener, port: UInt16) { + self.listener = listener + self.port = port + } + + func url(_ path: String) -> URL { URL(string: "http://127.0.0.1:\(port)\(path)")! } + + static func start(pages: [String: Page]) async throws -> PageServer { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) + let listener = try NWListener(using: parameters) + listener.newConnectionHandler = { connection in + connection.start(queue: .global()) + respond(on: connection, pages: pages, request: Data()) + } + let port: UInt16 = try await withCheckedThrowingContinuation { ready in + listener.stateUpdateHandler = { state in + switch state { + case .ready: listener.stateUpdateHandler = nil; ready.resume(returning: listener.port?.rawValue ?? 0) + case let .failed(error): listener.stateUpdateHandler = nil; ready.resume(throwing: error) + default: break + } + } + listener.start(queue: .global()) + } + return PageServer(listener: listener, port: port) + } + + /// Reads one request's head, answers it, and closes the connection. + private static func respond(on connection: NWConnection, pages: [String: Page], request: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { data, _, isComplete, error in + let request = request + (data ?? Data()) + let head = String(decoding: request, as: UTF8.self) + guard head.contains("\r\n\r\n") else { + if error == nil && !isComplete { respond(on: connection, pages: pages, request: request) } else { connection.cancel() } + return + } + let path = head.split(separator: " ", maxSplits: 2).dropFirst().first.map(String.init) ?? "/" + let page = pages[path] + let body = Data((page?.html ?? "Not found").utf8) + var lines = ["HTTP/1.1 \(page == nil ? "404 Not Found" : "200 OK")", "Content-Length: \(body.count)", "Connection: close"] + lines += (page?.headers ?? ["Content-Type": "text/plain"]).map { "\($0.key): \($0.value)" } + connection.send(content: Data((lines.joined(separator: "\r\n") + "\r\n\r\n").utf8) + body, + completion: .contentProcessed { _ in connection.cancel() }) + } + } + + func stop() { listener.cancel() } +} + +@MainActor +private final class Recorder: NSObject, WKScriptMessageHandler { + let continuation: AsyncStream.Continuation + init(continuation: AsyncStream.Continuation) { self.continuation = continuation } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: Any] else { return } + if let link = body["openLink"] as? String { + continuation.yield("link \(link) \(body["id"].map { "\($0)" } ?? "")") + } else if let height = body["height"] as? NSNumber { + continuation.yield("height \(height.intValue)") + } + } +} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8c66ff036..48840df1f 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -471,6 +471,112 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("signs stored HTML attachments for sandboxed inline display", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000004-html"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.html`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "

Chart

"); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + fileName: "Chart.html", + mimeType: "text/html", + disposition: "inline", + }, + }); + expect(result.relativeUrl).toMatch(/\/Chart\.html$/); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + const [payload, signature] = token.split("."); + + expect(yield* resolveAsset(token, "ignored.html")).toEqual({ + kind: "file", + path: attachmentPath, + inlineHtml: true, + fileName: "Chart.html", + mimeType: "text/html", + }); + expect(yield* resolveAsset(`${token}x`, "Chart.html")).toBeNull(); + expect(yield* resolveAsset(`${payload}x.${signature}`, "Chart.html")).toBeNull(); + yield* TestClock.setTime(result.expiresAt); + expect(yield* resolveAsset(token, "Chart.html")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("downloads HTML attachments by default and for an attachment disposition", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const htmlId = "thread-1-00000000-0000-4000-8000-000000000005-html"; + const imageId = "thread-1-00000000-0000-4000-8000-000000000006"; + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(config.attachmentsDir, `${htmlId}.html`), + "

Chart

", + ); + yield* fileSystem.writeFile( + path.join(config.attachmentsDir, `${imageId}.png`), + new Uint8Array([1, 2, 3]), + ); + const resolve = (resource: Parameters[0]["resource"]) => + Effect.gen(function* () { + const { relativeUrl } = yield* issueAssetUrl({ resource }); + const suffix = relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + return yield* resolveAsset(suffix.slice(0, suffix.indexOf("/")), "ignored"); + }); + + for (const disposition of [undefined, "attachment"] as const) { + const asset = yield* resolve({ + _tag: "attachment", + attachmentId: htmlId, + mimeType: "text/html", + ...(disposition === undefined ? {} : { disposition }), + }); + expect(asset).toMatchObject({ download: true }); + expect(asset).not.toHaveProperty("inlineHtml"); + } + expect( + yield* resolve({ _tag: "attachment", attachmentId: imageId, disposition: "attachment" }), + ).toMatchObject({ download: true }); + expect( + yield* resolve({ _tag: "attachment", attachmentId: imageId, disposition: "inline" }), + ).toEqual({ kind: "file", path: path.join(config.attachmentsDir, `${imageId}.png`) }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("never lets a caller MIME type make other attachments active inline content", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + for (const [index, extension] of [".htm", ".js", ".bin", ".txt"].entries()) { + const attachmentId = `thread-1-00000000-0000-4000-8000-00000000001${index}`; + yield* fileSystem.writeFileString( + path.join(config.attachmentsDir, `${attachmentId}${extension}`), + "", + ); + const error = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + fileName: "page.html", + mimeType: "text/html", + disposition: "inline", + }, + }).pipe(Effect.flip); + expect(error, extension).toBeInstanceOf(AssetPreviewTypeValidationError); + } + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues project favicon capabilities with a signed fallback", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index fcbc6395b..d170a01d3 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -106,6 +106,8 @@ export type ResolvedAsset = { readonly kind: "file"; readonly path: string; readonly download?: boolean; + /** A stored `.html` attachment signed for inline display; served inside a sandbox. */ + readonly inlineHtml?: boolean; readonly fileName?: string; readonly mimeType?: string; }; @@ -331,7 +333,14 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i resource: input.resource, }); } - const shouldDownload = !isWorkspaceImagePreviewPath(attachmentPath); + // The stored extension, never the caller's MIME type, decides what may render inline. + const disposition = input.resource.disposition; + const isHtml = path.extname(attachmentPath).toLowerCase() === ".html"; + const isImage = isWorkspaceImagePreviewPath(attachmentPath); + if (disposition === "inline" && !isHtml && !isImage) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + const shouldDownload = disposition === "attachment" || (disposition !== "inline" && !isImage); claims = { version: 1, kind: "attachment", @@ -498,6 +507,9 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( kind: "file", path: attachmentPath, ...(claims.download ? { download: true } : {}), + ...(!claims.download && attachmentPath.toLowerCase().endsWith(".html") + ? { inlineHtml: true } + : {}), ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}), ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}), } satisfies ResolvedAsset) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index dbe8627c8..caa6b04b6 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -9,7 +9,9 @@ import { attachmentMetadataMatchesStoredPath, createAttachmentId, createIssueAttachmentId, + createPendingAttachmentId, attachmentRelativePath, + isThreadHtmlRenderAttachmentId, parseIssueSegmentFromAttachmentId, createDeterministicAttachmentId, planAttachmentClaim, @@ -215,3 +217,27 @@ describe("attachmentStore", () => { } }); }); + +describe("HTML render ownership", () => { + it("recognizes only the html ids minted for exactly this thread", () => { + const own = createAttachmentId("thread-a", "html")!; + expect(isThreadHtmlRenderAttachmentId("thread-a", own)).toBe(true); + for (const attachmentId of [ + createAttachmentId("thread-a-b", "html")!, + createAttachmentId("thread-b", "html")!, + createAttachmentId("thread-a", "png")!, + createAttachmentId("thread-a")!, + createIssueAttachmentId("thread-a")!, + own.toUpperCase(), + `${own}.html`, + `../${own}`, + `${own}-html`, + ]) { + expect(isThreadHtmlRenderAttachmentId("thread-a", attachmentId), attachmentId).toBe(false); + } + // A thread literally named "pending" can never claim pending uploads. + expect(isThreadHtmlRenderAttachmentId("pending", createPendingAttachmentId("html"))).toBe( + false, + ); + }); +}); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 0070305b8..4d028ac5b 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -154,6 +154,18 @@ export function parseAttachmentFileExtension(attachmentId: string): string | nul return normalizedId?.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null; } +/** + * Whether an id is exactly one `createAttachmentId(threadId, "html")` minted for this thread, + * which is how published HTML renders are stored. + */ +export function isThreadHtmlRenderAttachmentId(threadId: string, attachmentId: string): boolean { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + const uuid = parseAttachmentUuid(attachmentId); + return ( + threadSegment !== null && uuid !== null && attachmentId === `${threadSegment}-${uuid}-html` + ); +} + export function parseIssueSegmentFromAttachmentId(attachmentId: string): string | null { const normalizedId = normalizeAttachmentId(attachmentId); if (normalizedId === null) { diff --git a/apps/server/src/cloud/orchestratorWorkerAuthority.test.ts b/apps/server/src/cloud/orchestratorWorkerAuthority.test.ts index 25caae9aa..370aa4219 100644 --- a/apps/server/src/cloud/orchestratorWorkerAuthority.test.ts +++ b/apps/server/src/cloud/orchestratorWorkerAuthority.test.ts @@ -21,3 +21,11 @@ describe("delegated worker privileges", () => { expect(checkWorkerToolAccess(access, "future_tool", {})).toContain("permissions"); }); }); + +for (const name of ["html_preview", "html_render"]) { + it(`${name} is never available to orchestrator workers`, () => { + expect( + checkWorkerToolAccess({ allowed: true, capabilities: ["threads.delegate"] }, name, {}), + ).toContain("permissions"); + }); +} diff --git a/apps/server/src/htmlRender/HtmlPreviewBrowser.integration.test.ts b/apps/server/src/htmlRender/HtmlPreviewBrowser.integration.test.ts new file mode 100644 index 000000000..26ae7d3d4 --- /dev/null +++ b/apps/server/src/htmlRender/HtmlPreviewBrowser.integration.test.ts @@ -0,0 +1,171 @@ +// @effect-diagnostics nodeBuiltinImport:off - isolated listeners and files are security fixtures. +import * as NodeHttp from "node:http"; +import * as NodeDgram from "node:dgram"; +import * as NodeURL from "node:url"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import * as HtmlRender from "./HtmlRender.ts"; +import * as HtmlPreviewBrowser from "./HtmlPreviewBrowser.ts"; + +// Intentionally opt-in. The unit command excludes this file. Do not launch Chromium +// without explicit user authorization, even when the binary is already installed. +const enabled = process.env.PATHWAY_HTML_RENDER_BROWSER_TESTS === "1"; +const testLayer = HtmlRender.layer.pipe( + Layer.provide(HtmlPreviewBrowser.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "pathway-html-security-" })), + Layer.provideMerge(NodeServices.layer), +); + +describe.skipIf(!enabled)("HtmlPreviewBrowser real Chromium security", () => { + it.live( + "never reads a sentinel local file through script/frame/fetch/popup or renders its bytes", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-html-sentinel-" }); + const sentinel = "PATHWAY_LOCAL_SENTINEL_7c92d128"; + const file = `${directory}/secret.js`; + yield* fs.writeFileString( + file, + `window.secret = '${sentinel}'; document.getElementById('result').textContent = window.secret; console.log(window.secret);`, + ); + const render = yield* HtmlRender.HtmlRender; + const html = (url: string) => + [ + '
safe
', + ``, + ``, + ].join(""); + const result = yield* render.preview({ + html: html(NodeURL.pathToFileURL(file).href), + width: 400, + }); + const baseline = yield* render.preview({ + html: html(NodeURL.pathToFileURL(`${directory}/missing.js`).href), + width: 400, + }); + expect(result.metadata.consoleMessages.map(({ text }) => text)).toContain("secret: none"); + expect(result.metadata.consoleMessages.map(({ text }) => text).join(" ")).not.toContain( + sentinel, + ); + // A leaked script would replace visible content as well as logging the sentinel. + expect(Buffer.from(result.png)).toEqual(Buffer.from(baseline.png)); + }).pipe(Effect.provide(testLayer)), + 30_000, + ); + + it.live( + "sends no private HTTP/WebSocket/STUN traffic and blocks popup and WebRTC constructors", + () => + Effect.gen(function* () { + const connections: Array = []; + const requests: Array = []; + const server = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((request, response) => { + requests.push(request.url ?? ""); + if (request.url === "/redirect") { + response.writeHead(302, { Location: "/secret" }); + response.end(); + } else response.end("PRIVATE_SENTINEL"); + }); + server.on("connection", () => connections.push("tcp")); + server.on("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.callback((resume) => { + server.closeAllConnections(); + server.close(() => resume(Effect.void)); + }), + ); + const datagrams: Array = []; + const udp = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const socket = NodeDgram.createSocket("udp4"); + socket.on("message", (message) => datagrams.push(message.toString("hex"))); + socket.on("error", (error) => resume(Effect.fail(error))); + socket.bind(0, "127.0.0.1", () => resume(Effect.succeed(socket))); + }), + (socket) => + Effect.callback((resume) => { + socket.close(() => resume(Effect.void)); + }), + ); + const address = server.address(); + if (!address || typeof address === "string") return yield* Effect.die("No fixture port."); + const origin = `http://127.0.0.1:${address.port}`; + const render = yield* HtmlRender.HtmlRender; + const result = yield* render.preview({ + html: [ + "

safe page

", + ``, + ``, + ``, + ``, + ].join(""), + }); + const text = result.metadata.consoleMessages.map(({ text }) => text); + expect(text).toContain("rtc: undefined undefined"); + expect(text).toContain("popup: blocked"); + expect(text.join(" ")).not.toContain("PRIVATE_SENTINEL"); + expect(connections).toEqual([]); + expect(requests).toEqual([]); + expect(datagrams).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + 30_000, + ); + + // Optional controlled PUBLIC fixture: /resource.js synchronously logs + // PATHWAY_PUBLIC_RESOURCE; /redirect?url=... redirects to that URL. + // No external network is needed for the two tests above. + it.live( + "allows a controlled public resource but refuses its redirect to a private address", + (ctx) => + Effect.gen(function* () { + const publicOrigin = process.env.PATHWAY_HTML_RENDER_PUBLIC_FIXTURE_ORIGIN; + if (!publicOrigin) + return ctx.skip( + "Set PATHWAY_HTML_RENDER_PUBLIC_FIXTURE_ORIGIN to an authorized public fixture origin.", + ); + const requests: Array = []; + const privateServer = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((_request, response) => + response.end("PRIVATE_SENTINEL"), + ); + server.on("connection", () => requests.push("tcp")); + server.on("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.callback((resume) => { + server.closeAllConnections(); + server.close(() => resume(Effect.void)); + }), + ); + const address = privateServer.address(); + if (!address || typeof address === "string") return yield* Effect.die("No fixture port."); + const destination = `http://127.0.0.1:${address.port}/secret`; + const render = yield* HtmlRender.HtmlRender; + const result = yield* render.preview({ + html: `

safe

`, + }); + const text = result.metadata.consoleMessages.map(({ text }) => text).join(" "); + expect(text).toContain("PATHWAY_PUBLIC_RESOURCE"); + expect(text).not.toContain("PRIVATE_SENTINEL"); + expect(requests).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + 30_000, + ); +}); diff --git a/apps/server/src/htmlRender/HtmlPreviewBrowser.test.ts b/apps/server/src/htmlRender/HtmlPreviewBrowser.test.ts new file mode 100644 index 000000000..6ddd3fe56 --- /dev/null +++ b/apps/server/src/htmlRender/HtmlPreviewBrowser.test.ts @@ -0,0 +1,435 @@ +// @effect-diagnostics nodeBuiltinImport:off - native event emitters fake Playwright's event API. +import * as NodeEvents from "node:events"; +import type { Browser, BrowserContext, Page, Route } from "playwright"; +import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { + browserLaunchOptions, + consoleCapture, + make, + PAGE_URL, + requestPolicy, + type HtmlPreviewBrowserDependencies, +} from "./HtmlPreviewBrowser.ts"; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +// The assertions below are confined to the fake native adapter, never Effects. +function fixture( + input: { height?: number; evaluate?: () => Promise; loadError?: Error } = {}, +) { + let handler: ((route: Route) => Promise) | undefined; + const contextEvents = new NodeEvents.EventEmitter(); + const pages: Array> = []; + let activePages = 0; + let maxActivePages = 0; + function newPage() { + const events = new NodeEvents.EventEmitter(); + let closed = false; + const frame = { page: () => page as unknown as Page, url: () => PAGE_URL }; + const page = { + on: events.on.bind(events), + opener: async () => null, + isClosed: () => closed, + mainFrame: () => frame, + url: () => frame.url(), + setViewportSize: vi.fn(async (_size: { width: number; height: number }) => {}), + goto: vi.fn(async (_url: string) => { + if (input.loadError) throw input.loadError; + await request(PAGE_URL, page); + }), + evaluate: vi.fn(async (expression: string) => + expression.includes("document.fonts") + ? input.evaluate + ? input.evaluate() + : true + : (input.height ?? 123), + ), + screenshot: vi.fn(async (_options: Parameters[0]) => Buffer.from("PNG")), + close: vi.fn(async () => { + if (!closed) { + activePages -= 1; + closed = true; + } + }), + }; + activePages += 1; + maxActivePages = Math.max(maxActivePages, activePages); + contextEvents.emit("page", page); + return { page, events }; + } + const context = { + on: contextEvents.on.bind(contextEvents), + addInitScript: vi.fn(async (_source: string) => {}), + route: vi.fn(async (_glob: string, callback: (route: Route) => Promise) => { + handler = callback; + }), + newPage: vi.fn(async () => { + const created = newPage(); + pages.push(created); + return created.page as unknown as Page; + }), + close: vi.fn(async () => {}), + }; + const browser = { + newContext: vi.fn( + async (_options: Parameters[0]) => + context as unknown as BrowserContext, + ), + close: vi.fn(async () => {}), + }; + const launch = vi.fn( + async (_options: ReturnType) => browser as unknown as Browser, + ); + let proxyClosed = 0; + const deps: HtmlPreviewBrowserDependencies = { + executablePath: () => "/pinned/chromium", + exists: vi.fn(async (_path) => true), + launch, + proxy: Effect.acquireRelease(Effect.succeed(12345), () => + Effect.sync(() => { + proxyClosed += 1; + }), + ), + }; + async function request( + url: string, + page = pages[0]!.page, + mainFrame: boolean | "unavailable" = true, + document = true, + ) { + const frame = mainFrame === true ? page.mainFrame() : { page: () => page }; + const route = { + request: () => ({ + url: () => url, + // Playwright throws here for a popup's first navigation, before its page exists. + frame: () => { + if (mainFrame === "unavailable") + throw new Error("Frame for this request is not available"); + return frame; + }, + isNavigationRequest: () => document, + }), + fulfill: vi.fn(async (_response: Parameters[0]) => {}), + abort: vi.fn(async (_code?: string) => {}), + continue: vi.fn(async () => {}), + }; + await handler!(route as unknown as Route); + return route; + } + return { + deps, + browser, + context, + contextEvents, + pages, + launch, + request, + newPage, + proxyClosed: () => proxyClosed, + maxActivePages: () => maxActivePages, + }; +} + +describe("HtmlPreviewBrowser", () => { + it("uses the probed full Chromium channel with every hardening option and no sandbox fallback", () => { + expect(browserLaunchOptions(1234)).toEqual({ + channel: "chromium", + headless: true, + chromiumSandbox: true, + proxy: { server: "socks5://127.0.0.1:1234" }, + ignoreDefaultArgs: ["--disable-popup-blocking"], + args: [ + "--block-new-web-contents", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", + "--proxy-bypass-list=<-loopback>", + ], + }); + }); + + it("keeps fake-origin resources and the main document locked down", () => { + const policy = (url: string, mainFrame = true, document = true) => + requestPolicy({ url, mainFrame, document }); + expect(policy(PAGE_URL)).toBe("fulfill"); + for (const url of [ + "file:///etc/passwd", + "ftp://example.com/x", + "data:text/html,x", + "javascript:alert(1)", + "not a URL", + `${PAGE_URL}?reload=1`, + "http://pathway-page.localhost/other.html", + ]) + expect(policy(url)).toBe("abort"); + expect(policy("https://example.com/redirect")).toBe("abort"); + expect(policy(PAGE_URL, false)).toBe("abort"); + expect(policy(PAGE_URL, true, false)).toBe("abort"); + expect(policy("https://example.com/frame", false)).toBe("continue"); + expect(policy("http://example.com/image.png", true, false)).toBe("continue"); + expect(policy("file:///etc/passwd", false)).toBe("abort"); + }); + + it("bounds console ingestion and records an omission notice", () => { + const console = consoleCapture(); + for (let index = 0; index < 100; index += 1) console.append("error", "x".repeat(1000)); + expect(console.messages()).toHaveLength(21); + expect( + console + .messages() + .slice(0, 20) + .every((message) => message.text.length === 500), + ).toBe(true); + expect(console.messages()[20]).toEqual({ + level: "warning", + text: "80 more console messages were omitted.", + }); + }); + + it.effect( + "reports missing Chromium with the pinned installation command, without launching", + () => + Effect.gen(function* () { + const f = fixture(); + const browser = make({ ...f.deps, exists: async () => false }); + expect(yield* browser.available).toBe(false); + const error = yield* browser + .capture({ html: "

x

", width: 400, urlFragment: "" }) + .pipe(Effect.flip); + expect(error.message).toContain("npx playwright@1.60.0 install chromium"); + expect(f.launch).not.toHaveBeenCalled(); + }), + ); + + it.effect("never retries a failed sandbox launch and closes the proxy", () => + Effect.gen(function* () { + const f = fixture(); + f.launch.mockRejectedValueOnce(new Error("No usable sandbox")); + const error = yield* make(f.deps) + .capture({ html: "

x

", width: 400, urlFragment: "" }) + .pipe(Effect.flip); + expect(error._tag).toBe("HtmlRenderBrowserError"); + expect(error.message).toContain("does not retry without it"); + expect(f.launch).toHaveBeenCalledTimes(1); + expect(f.proxyClosed()).toBe(1); + }), + ); + + it.effect( + "serves exact UTF-8 memory bytes, blocks redirects/files/popups, and scopes resources", + () => + Effect.gen(function* () { + const f = fixture(); + const body = "

€ / 中文

"; + let inspected = false; + const originalScreenshot = Buffer.from("PNG"); + f.context.newPage.mockImplementationOnce(async () => { + const created = f.newPage(); + f.pages.push(created); + created.page.screenshot.mockImplementationOnce(async () => { + const memory = await f.request(PAGE_URL); + expect(memory.fulfill).toHaveBeenCalledWith({ + status: 200, + contentType: "text/html; charset=utf-8", + body: Buffer.from(body, "utf8"), + headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }, + }); + for (const [url, mainFrame, document] of [ + ["file:///tmp/secret.txt", false, true], + ["https://example.com/redirect", true, true], + ["http://pathway-page.localhost/favicon.ico", true, false], + [PAGE_URL, false, true], + ] as const) + expect( + (await f.request(url, created.page, mainFrame, document)).abort, + ).toHaveBeenCalled(); + expect( + (await f.request("https://example.com/frame", created.page, false, true)).continue, + ).toHaveBeenCalled(); + const popup = f.newPage(); + expect((await f.request(PAGE_URL, popup.page)).abort).toHaveBeenCalled(); + await popup.page.close(); + inspected = true; + return originalScreenshot; + }); + return created.page as unknown as Page; + }); + const result = yield* make(f.deps).capture({ + html: body, + width: 400, + urlFragment: "#theme", + }); + expect(inspected).toBe(true); + expect(result.png).toBe(originalScreenshot); + expect(f.deps.exists).toHaveBeenCalledWith("/pinned/chromium"); + expect(f.context.route).toHaveBeenCalledTimes(1); + expect(f.context.addInitScript).toHaveBeenCalledWith( + "delete window.RTCPeerConnection; delete window.webkitRTCPeerConnection;", + ); + expect(f.browser.newContext).toHaveBeenCalledWith({ + serviceWorkers: "block", + acceptDownloads: false, + permissions: [], + viewport: { width: 728, height: 800 }, + deviceScaleFactor: 1, + }); + expect(f.pages[0]!.page.goto).toHaveBeenCalledWith(`${PAGE_URL}#theme`, { + waitUntil: "load", + timeout: 20_000, + }); + expect(f.pages[0]!.page.close).toHaveBeenCalledOnce(); + expect(f.context.close).toHaveBeenCalledOnce(); + expect(f.browser.close).toHaveBeenCalledOnce(); + expect(f.proxyClosed()).toBe(1); + }), + ); + + it.effect("refuses a request whose frame is unavailable instead of rejecting the route", () => + Effect.gen(function* () { + const f = fixture(); + yield* make(f.deps).capture({ html: "x", width: 400, urlFragment: "" }); + // A rejected handler would surface as an unhandled rejection in the server. + const route = yield* Effect.promise(() => + f.request("http://10.0.0.1/", f.pages[0]!.page, "unavailable"), + ); + expect(route.abort).toHaveBeenCalledWith("blockedbyclient"); + expect(route.continue).not.toHaveBeenCalled(); + expect(route.fulfill).not.toHaveBeenCalled(); + }), + ); + + it.effect("keeps installation advice out of failures that are not about Chromium", () => + Effect.gen(function* () { + const f = fixture({ loadError: new Error("navigation failed") }); + const error = yield* make(f.deps) + .capture({ html: "x", width: 400, urlFragment: "" }) + .pipe(Effect.flip); + expect(error.message).toBe( + "Chromium could not render the HTML page: the page could not load.", + ); + }), + ); + + it.effect("caps PNG capture height at 4000 without losing the actual content height", () => + Effect.gen(function* () { + const f = fixture({ height: 100_000 }); + const result = yield* make(f.deps).capture({ html: "

x

", width: 728, urlFragment: "" }); + expect(result.contentHeight).toBe(100_000); + expect(result.capturedHeight).toBe(4000); + expect(f.pages[0]!.page.screenshot).toHaveBeenCalledWith({ + type: "png", + clip: { x: 0, y: 0, width: 728, height: 4000 }, + fullPage: true, + timeout: 20_000, + }); + }), + ); + + it.effect("closes every acquired resource on load failure", () => + Effect.gen(function* () { + const f = fixture({ loadError: new Error("navigation failed") }); + yield* make(f.deps) + .capture({ html: "

x

", width: 400, urlFragment: "" }) + .pipe(Effect.flip); + expect(f.pages[0]!.page.close).toHaveBeenCalledOnce(); + expect(f.context.close).toHaveBeenCalledOnce(); + expect(f.browser.close).toHaveBeenCalledOnce(); + expect(f.proxyClosed()).toBe(1); + }), + ); + + it.effect("closes main-frame blob/data navigations that bypass network routing", () => + Effect.gen(function* () { + const f = fixture(); + f.context.newPage.mockImplementationOnce(async () => { + const created = f.newPage(); + f.pages.push(created); + created.page.screenshot.mockImplementationOnce(async () => { + const frame = created.page.mainFrame(); + frame.url = () => "blob:http://pathway-page.localhost/other"; + created.events.emit("framenavigated", frame); + expect(created.page.close).toHaveBeenCalledOnce(); + return Buffer.from("PNG"); + }); + return created.page as unknown as Page; + }); + const error = yield* make(f.deps) + .capture({ html: "x", width: 400, urlFragment: "" }) + .pipe(Effect.flip); + expect(error.reason).toContain("main frame left"); + expect(f.browser.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("closes the page, context, browser and proxy when interrupted during load", () => + Effect.gen(function* () { + const entered = deferred(); + const gate = deferred(); + const f = fixture({ + evaluate: () => { + entered.resolve(); + return gate.promise; + }, + }); + const fiber = yield* make(f.deps) + .capture({ html: "

x

", width: 400, urlFragment: "" }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => entered.promise); + yield* Fiber.interrupt(fiber); + expect(f.pages[0]!.page.close).toHaveBeenCalledOnce(); + expect(f.context.close).toHaveBeenCalledOnce(); + expect(f.browser.close).toHaveBeenCalledOnce(); + expect(f.proxyClosed()).toBe(1); + gate.resolve(true); + }), + ); + + it.effect("closes a browser that finishes launching after cancellation", () => + Effect.gen(function* () { + const f = fixture(); + const launched = deferred(); + const entered = deferred(); + const closed = deferred(); + f.launch.mockImplementationOnce(() => { + entered.resolve(); + return launched.promise; + }); + f.browser.close.mockImplementationOnce(async () => { + closed.resolve(); + }); + const fiber = yield* make(f.deps) + .capture({ html: "x", width: 400, urlFragment: "" }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => entered.promise); + yield* Fiber.interrupt(fiber); + expect(f.proxyClosed()).toBe(1); + launched.resolve(f.browser as unknown as Browser); + yield* Effect.promise(() => closed.promise); + expect(f.browser.close).toHaveBeenCalledOnce(); + expect(f.browser.newContext).not.toHaveBeenCalled(); + }), + ); + + it.effect("uses a fresh page per measurement and never more than three pages at once", () => + Effect.gen(function* () { + const f = fixture({ height: 250 }); + const widths = [320, 375, 430, 520, 640, 728, 860, 1000, 1144]; + const result = yield* make(f.deps).measure({ html: "x", widths, urlFragment: "" }); + expect(result).toEqual(widths.map((width) => [width, 250])); + expect(f.launch).toHaveBeenCalledOnce(); + expect(f.pages).toHaveLength(9); + expect(f.maxActivePages()).toBeLessThanOrEqual(3); + expect(f.pages.every(({ page }) => page.close.mock.calls.length === 1)).toBe(true); + expect(f.context.close).toHaveBeenCalledOnce(); + expect(f.browser.close).toHaveBeenCalledOnce(); + }), + ); +}); diff --git a/apps/server/src/htmlRender/HtmlPreviewBrowser.ts b/apps/server/src/htmlRender/HtmlPreviewBrowser.ts new file mode 100644 index 000000000..67cc7fb7e --- /dev/null +++ b/apps/server/src/htmlRender/HtmlPreviewBrowser.ts @@ -0,0 +1,366 @@ +// @effect-diagnostics nodeBuiltinImport:off - Playwright and SOCKS sockets are the browser adapter boundary. +import * as NodeFSP from "node:fs/promises"; +import { chromium, type Browser, type BrowserContext, type Page, type Route } from "playwright"; +import type { HtmlPreviewMetadata } from "@spiritdevs/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +import { publicProxy } from "./publicProxy.ts"; + +export class HtmlRenderBrowserError extends Schema.TaggedErrorClass()( + "HtmlRenderBrowserError", + { reason: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `Chromium could not render the HTML page: ${this.reason}.`; + } +} + +const INSTALL_ADVICE = + "Install Chromium on this environment with npx playwright@1.60.0 install chromium (Linux may also need install-deps chromium). Chromium must be able to run its OS sandbox; Pathway does not retry without it"; + +export type ConsoleMessage = HtmlPreviewMetadata["consoleMessages"][number]; +export interface HtmlCapture { + readonly png: Uint8Array; + readonly contentHeight: number; + readonly capturedHeight: number; + readonly consoleMessages: ReadonlyArray; +} +export interface HtmlBrowserInput { + readonly html: string; + readonly urlFragment: string; +} + +export class HtmlPreviewBrowser extends Context.Service< + HtmlPreviewBrowser, + { + readonly available: Effect.Effect; + readonly capture: ( + input: HtmlBrowserInput & { readonly width: number }, + ) => Effect.Effect; + readonly measure: ( + input: HtmlBrowserInput & { readonly widths: ReadonlyArray }, + ) => Effect.Effect, HtmlRenderBrowserError>; + } +>()("@spiritdevs/pathway/htmlRender/HtmlPreviewBrowser") {} + +export const PAGE_ORIGIN = "http://pathway-page.localhost"; +export const PAGE_URL = `${PAGE_ORIGIN}/page.html`; +const VIEWPORT_HEIGHT = 800; +const MAX_CAPTURE_HEIGHT = 4000; +const MAX_CONSOLE_MESSAGES = 20; +const MAX_CONSOLE_TEXT_CHARS = 500; +const MEASURE_CONCURRENCY = 3; + +export const browserLaunchOptions = (proxyPort: number) => ({ + channel: "chromium", + headless: true, + chromiumSandbox: true, + proxy: { server: `socks5://127.0.0.1:${proxyPort}` }, + ignoreDefaultArgs: ["--disable-popup-blocking"], + args: [ + "--block-new-web-contents", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", + "--proxy-bypass-list=<-loopback>", + ], +}); + +/** One context route owns interception, including nested frames and popup requests. + * Chromium's web-origin file restrictions and the proxy also cover requests that + * do not reach routing. No CDP Fetch handler competes with Playwright's handler. + */ +export const requestPolicy = (input: { + readonly url: string; + readonly document: boolean; + readonly mainFrame: boolean; +}) => { + let url: URL; + try { + url = new URL(input.url); + } catch { + return "abort" as const; + } + if (url.origin === PAGE_ORIGIN) { + return url.href === PAGE_URL && input.document && input.mainFrame + ? ("fulfill" as const) + : ("abort" as const); + } + if (url.protocol !== "http:" && url.protocol !== "https:") return "abort" as const; + if (input.document && input.mainFrame) return "abort" as const; + return "continue" as const; +}; + +/** Bounded at ingestion, so a logging loop cannot retain unbounded strings. */ +export const consoleCapture = () => { + const messages: Array = []; + let omitted = 0; + return { + append: (level: ConsoleMessage["level"], text: string) => { + if (messages.length < MAX_CONSOLE_MESSAGES) + messages.push({ level, text: text.slice(0, MAX_CONSOLE_TEXT_CHARS) }); + else omitted += 1; + }, + messages: (): Array => + omitted === 0 + ? [...messages] + : [ + ...messages, + { level: "warning", text: `${omitted} more console messages were omitted.` }, + ], + }; +}; + +const WEBRTC_INIT_SCRIPT = + "delete window.RTCPeerConnection; delete window.webkitRTCPeerConnection;"; +// String expressions execute in Chromium; the server project has no DOM types. +const SETTLE_EXPRESSION = + "document.fonts.ready.then(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)))))"; +const MEASURE_EXPRESSION = + "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()"; +const decodeHeight = Schema.decodeUnknownEffect(Schema.Finite); +const isMemoryPage = (url: string) => url.split("#", 1)[0] === PAGE_URL; + +export interface HtmlPreviewBrowserDependencies { + readonly executablePath: () => string; + readonly exists: (path: string) => Promise; + readonly launch: (options: ReturnType) => Promise; + readonly proxy: Effect.Effect; +} +const dependencies: HtmlPreviewBrowserDependencies = { + executablePath: () => chromium.executablePath(), + exists: (path) => + NodeFSP.access(path).then( + () => true, + () => false, + ), + launch: (options) => chromium.launch(options), + proxy: publicProxy, +}; + +const attempt =
(reason: string, run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (cause) => new HtmlRenderBrowserError({ reason, cause }), + }); +const close = (run: () => Promise) => Effect.promise(run).pipe(Effect.ignoreCause); + +// Playwright acquisitions cannot be cancelled. Close a result that arrives after +// interruption instead of letting it escape the scope or extending the deadline. +const acquire = ( + reason: string, + run: () => Promise, + release: (resource: A) => Promise, +) => + Effect.acquireRelease( + Effect.interruptible( + Effect.tryPromise({ + try: async (signal) => { + const resource = await run(); + if (signal.aborted) { + await release(resource); + throw new Error("The browser job was interrupted during acquisition."); + } + return resource; + }, + catch: (cause) => new HtmlRenderBrowserError({ reason, cause }), + }), + ), + (resource) => close(() => release(resource)), + ); + +export const make = (deps: HtmlPreviewBrowserDependencies = dependencies) => { + const available = attempt("the pinned Chromium executable could not be checked", () => + deps.exists(deps.executablePath()), + ).pipe(Effect.orElseSucceed(() => false)); + + const open = Effect.fn("HtmlPreviewBrowser.open")(function* (html: string) { + if (!(yield* available)) + return yield* new HtmlRenderBrowserError({ + reason: `the pinned Chromium executable is missing. ${INSTALL_ADVICE}`, + }); + const port = yield* deps.proxy.pipe( + Effect.mapError( + (cause) => + new HtmlRenderBrowserError({ reason: "the public-only proxy could not start", cause }), + ), + ); + const browser = yield* acquire( + `the sandboxed browser could not start. ${INSTALL_ADVICE}`, + () => deps.launch(browserLaunchOptions(port)), + (browser) => browser.close(), + ); + const context = yield* acquire( + "an isolated context could not start", + () => + browser.newContext({ + serviceWorkers: "block", + acceptDownloads: false, + permissions: [], + viewport: { width: 728, height: VIEWPORT_HEIGHT }, + deviceScaleFactor: 1, + }), + (context) => context.close(), + ); + const pages = new Set(); + yield* attempt("the page guards could not be installed", async () => { + context.on("page", (page) => { + page.on("dialog", (dialog) => { + void dialog.dismiss().catch(() => {}); + }); + page.on("download", (download) => { + void download.cancel().catch(() => {}); + }); + // Launch flags prevent popup scripts; this is a second guard for unexpected targets. + void page + .opener() + .then((opener) => { + if (opener && !page.isClosed()) return page.close(); + }) + .catch(() => {}); + }); + await context.addInitScript(WEBRTC_INIT_SCRIPT); + const body = Buffer.from(html, "utf8"); + const handle = async (route: Route) => { + const request = route.request(); + const frame = request.frame(); + const mainFrame = frame === frame.page().mainFrame(); + const policy = requestPolicy({ + url: request.url(), + document: request.isNavigationRequest(), + mainFrame, + }); + // Only pages created by this job can load the memory document, never popup targets. + if (policy === "fulfill" && pages.has(frame.page())) { + await route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body, + headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }, + }); + } else if (policy === "continue") await route.continue(); + else await route.abort("blockedbyclient"); + }; + // Playwright rethrows a rejected route handler as an unhandled rejection, + // which would stop the server. `request.frame()` throws for a popup's first + // navigation (Playwright's evaluate and screenshot grant user activation), + // so a request the handler cannot classify is refused instead. + await context.route("**/*", (route: Route) => + handle(route).catch(() => route.abort("blockedbyclient").catch(() => {})), + ); + }); + return { context, pages }; + }); + + const load = Effect.fn("HtmlPreviewBrowser.load")(function* ( + runtime: { context: BrowserContext; pages: Set }, + width: number, + urlFragment: string, + ) { + const page = yield* acquire( + "a page could not start", + () => runtime.context.newPage(), + (page) => { + runtime.pages.delete(page); + return page.close(); + }, + ); + runtime.pages.add(page); + // Blob/data/about navigations may not issue a routable network request. + // Fail the job rather than capture another main document in those cases. + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame() && !isMemoryPage(frame.url())) { + void page.close().catch(() => {}); + } + }); + const console = consoleCapture(); + page.on("console", (message) => { + const level = message.type(); + console.append( + level === "error" || level === "assert" + ? "error" + : level === "warning" + ? "warning" + : level === "info" + ? "info" + : "log", + message.text(), + ); + }); + page.on("pageerror", (error) => console.append("error", error.message)); + yield* attempt("the page could not load", async () => { + await page.setViewportSize({ width, height: VIEWPORT_HEIGHT }); + await page.goto(`${PAGE_URL}${urlFragment}`, { waitUntil: "load", timeout: 20_000 }); + await page.evaluate(SETTLE_EXPRESSION); + }); + const measured: unknown = yield* attempt("the page could not be measured", () => + page.evaluate(MEASURE_EXPRESSION), + ); + const height = yield* decodeHeight(measured).pipe( + Effect.mapError( + (cause) => + new HtmlRenderBrowserError({ reason: "the page returned an invalid height", cause }), + ), + ); + if (page.isClosed() || !isMemoryPage(page.url())) { + return yield* new HtmlRenderBrowserError({ reason: "the main frame left the memory page" }); + } + return { page, contentHeight: Math.max(1, Math.ceil(height)), console }; + }); + + const capture = Effect.fn("HtmlPreviewBrowser.capture")( + function* (input: HtmlBrowserInput & { readonly width: number }) { + const runtime = yield* open(input.html); + const { page, contentHeight, console } = yield* load(runtime, input.width, input.urlFragment); + const capturedHeight = Math.min(contentHeight, MAX_CAPTURE_HEIGHT); + const png = yield* attempt("the PNG could not be captured", () => + page.screenshot({ + type: "png", + clip: { x: 0, y: 0, width: input.width, height: capturedHeight }, + fullPage: true, + timeout: 20_000, + }), + ); + if (page.isClosed() || !isMemoryPage(page.url())) { + return yield* new HtmlRenderBrowserError({ + reason: "the main frame left the memory page during capture", + }); + } + return { + png, + contentHeight, + capturedHeight, + consoleMessages: console.messages(), + } satisfies HtmlCapture; + }, + Effect.scoped, + Effect.timeoutOrElse({ + duration: "20 seconds", + orElse: () => + Effect.fail( + new HtmlRenderBrowserError({ reason: "the page did not finish within 20 seconds" }), + ), + }), + ); + + const measure = Effect.fn("HtmlPreviewBrowser.measure")(function* ( + input: HtmlBrowserInput & { readonly widths: ReadonlyArray }, + ) { + const runtime = yield* open(input.html); + return yield* Effect.forEach( + input.widths, + (width) => + load(runtime, width, input.urlFragment).pipe( + Effect.map(({ contentHeight }) => [width, contentHeight] as [number, number]), + Effect.scoped, + ), + { concurrency: MEASURE_CONCURRENCY }, + ); + }, Effect.scoped); + + return HtmlPreviewBrowser.of({ available, capture, measure }); +}; + +export const layer = Layer.sync(HtmlPreviewBrowser, make); diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts new file mode 100644 index 000000000..beb46754a --- /dev/null +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -0,0 +1,576 @@ +// @effect-diagnostics nodeBuiltinImport:off - plain local servers stand in for LAN services. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { ThreadId } from "@spiritdevs/contracts"; +import { + HTML_RENDER_MEASURE_FONTS, + HTML_RENDER_MEASURE_WIDTHS, + htmlRenderTheme, + htmlRenderThemeFragment, +} from "@spiritdevs/shared/htmlRender"; +import { Pathway_CODE_DARK_THEME_COLORS } from "@spiritdevs/shared/themePalettes"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; +import * as Stream from "effect/Stream"; + +import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as HtmlRender from "./HtmlRender.ts"; +import { HtmlPreviewBrowser, HtmlRenderBrowserError } from "./HtmlPreviewBrowser.ts"; + +const defaultBrowser = HtmlPreviewBrowser.of({ + available: Effect.succeed(false), + capture: () => Effect.die("This unit test must not launch a browser."), + measure: () => Effect.die("This unit test must not launch a browser."), +}); +const htmlRenderLayer = (browser = defaultBrowser) => + HtmlRender.layer.pipe( + Layer.provide(Layer.succeed(HtmlPreviewBrowser, browser)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "pathway-html-render-" })), + Layer.provideMerge(NodeServices.layer), + ); +const testLayer = htmlRenderLayer(); + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const publishInput = { + threadId: ThreadId.make("thread-html"), + html: "

x

", + title: " X ", + height: 200, +}; + +const fakeService = (overrides: Partial) => + HtmlPreviewBrowser.of({ ...defaultBrowser, ...overrides }); + +describe("HtmlRender", () => { + it.effect("removes a partially written attachment when saving fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const render = yield* HtmlRender.make.pipe( + Effect.provideService(HtmlPreviewBrowser, defaultBrowser), + Effect.provideService(FileSystem.FileSystem, { + ...fs, + writeFileString: (path, data) => + fs + .writeFileString(path, data) + .pipe(Effect.andThen(fs.readFileString(`${path}.not-found`)), Effect.asVoid), + }), + ); + expect((yield* render.publish(publishInput).pipe(Effect.flip))._tag).toBe( + "HtmlRenderStoreError", + ); + expect(yield* fs.readDirectory(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects partial PNG/GIF signatures instead of inlining renamed text", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const png = `${directory}/secret.png`; + const gif = `${directory}/secret.gif`; + yield* fs.writeFile(png, Buffer.from("\x89PNG secret", "latin1")); + yield* fs.writeFileString(gif, "GIF8 secret"); + const render = yield* HtmlRender.HtmlRender; + const error = yield* render.prepare(``).pipe(Effect.flip); + expect(error._tag === "HtmlRenderImagesNotFoundError" && error.paths).toEqual([png, gif]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects oversized HTML before scanning or reading files", () => + Effect.gen(function* () { + const render = yield* HtmlRender.HtmlRender; + const error = yield* render + .prepare("x".repeat(HtmlRender.MAX_HTML_CHARACTERS + 1)) + .pipe(Effect.flip); + expect(error._tag).toBe("HtmlRenderHtmlTooLargeError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect( + "uses bounded reads, checks growth after stat, and reads duplicate references only once", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let reads = 0; + const directory = yield* fs.makeTempDirectoryScoped(); + const image = `${directory}/image.png`; + yield* fs.writeFile(image, PNG_BYTES); + const fake = { + ...fs, + stream: ( + path: string, + options?: { readonly bytesToRead?: FileSystem.SizeInput | undefined }, + ) => { + reads += 1; + expect(path).toBe(image); + expect(options?.bytesToRead).toBe(HtmlRender.MAX_IMAGE_BYTES + 1); + return Stream.succeed(PNG_BYTES); + }, + }; + const render = yield* HtmlRender.make.pipe( + Effect.provideService(FileSystem.FileSystem, fake), + Effect.provideService(HtmlPreviewBrowser, defaultBrowser), + ); + const result = yield* render.prepare(``); + expect(result.match(/data:image\/png;base64/g)).toHaveLength(2); + expect(reads).toBe(1); + + const growing = yield* HtmlRender.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fake, + stream: () => Stream.succeed(new Uint8Array(HtmlRender.MAX_IMAGE_BYTES + 1)), + }), + Effect.provideService(HtmlPreviewBrowser, defaultBrowser), + ); + const error = yield* growing.prepare(``).pipe(Effect.flip); + expect(error._tag).toBe("HtmlRenderImageTooLargeError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("preflights per-image size and repeated base64 expansion before reading", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const image = `${directory}/big.png`; + yield* fs.writeFile(image, PNG_BYTES); + const info = yield* fs.stat(image); + let reads = 0; + const makeSized = (size: number) => + HtmlRender.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: () => Effect.succeed({ ...info, size: FileSystem.Size(size) }), + stream: () => { + reads += 1; + return Stream.succeed(PNG_BYTES); + }, + }), + Effect.provideService(HtmlPreviewBrowser, defaultBrowser), + ); + const oversized = yield* makeSized(HtmlRender.MAX_IMAGE_BYTES + 1); + expect((yield* oversized.prepare(``).pipe(Effect.flip))._tag).toBe( + "HtmlRenderImageTooLargeError", + ); + const repeated = yield* makeSized(HtmlRender.MAX_IMAGE_BYTES); + expect( + (yield* repeated.prepare(``.repeat(2)).pipe(Effect.flip))._tag, + ).toBe("HtmlRenderPageTooLargeError"); + expect(reads).toBe(0); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("checks final repeated expansion when a file grows within the per-image bound", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const image = `${directory}/grew.png`; + yield* fs.writeFile(image, PNG_BYTES); + const bytes = new Uint8Array(HtmlRender.MAX_IMAGE_BYTES); + bytes.set(PNG_BYTES); + const render = yield* HtmlRender.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stream: () => Stream.succeed(bytes), + }), + Effect.provideService(HtmlPreviewBrowser, defaultBrowser), + ); + expect((yield* render.prepare(``.repeat(2)).pipe(Effect.flip))._tag).toBe( + "HtmlRenderPageTooLargeError", + ); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("previews missing images, returning raw PNG separately from bounded metadata", () => + Effect.gen(function* () { + let requestedWidth = 0; + let requestedFragment = ""; + const browser = fakeService({ + capture: (input) => { + requestedWidth = input.width; + requestedFragment = input.urlFragment; + expect(input.html).toContain("pathway-theme"); + return Effect.succeed({ + png: PNG_BYTES, + contentHeight: 123, + capturedHeight: 123, + consoleMessages: [{ level: "error", text: "broken image" }], + }); + }, + }); + const render = yield* HtmlRender.make.pipe( + Effect.provideService(HtmlPreviewBrowser, browser), + ); + const preview = yield* render.preview({ + html: '', + width: 2000, + appearance: "light", + }); + expect(requestedWidth).toBe(1600); + expect(requestedFragment).toContain("pathway-theme="); + expect(preview.png).toBe(PNG_BYTES); + expect(preview.metadata).toEqual({ + width: 1600, + contentHeight: 123, + capturedHeight: 123, + consoleMessages: [{ level: "error", text: "broken image" }], + missingImages: ["/nonexistent/pathway-image.png"], + screenshot: { mimeType: "image/png", width: 1600, height: 123 }, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("measures all widths in the standard dark theme and sorts the returned tuples", () => + Effect.gen(function* () { + const render = yield* HtmlRender.make.pipe( + Effect.provideService( + HtmlPreviewBrowser, + fakeService({ + available: Effect.succeed(true), + measure: (input) => { + expect(input.widths).toEqual(HTML_RENDER_MEASURE_WIDTHS); + expect(input.urlFragment).toBe( + htmlRenderThemeFragment( + htmlRenderTheme( + Pathway_CODE_DARK_THEME_COLORS, + "dark", + HTML_RENDER_MEASURE_FONTS, + ), + ), + ); + return Effect.succeed([ + [728, 240], + [320, 410], + ]); + }, + }), + ), + ); + expect((yield* render.publish(publishInput)).heights).toEqual([ + [320, 410], + [728, 240], + ]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("publishes without heights when sandboxed measurement fails", () => + Effect.gen(function* () { + const render = yield* HtmlRender.make.pipe( + Effect.provideService( + HtmlPreviewBrowser, + fakeService({ + available: Effect.succeed(true), + measure: () => Effect.fail(new HtmlRenderBrowserError({ reason: "No usable sandbox" })), + }), + ), + ); + expect(yield* render.publish(publishInput)).not.toHaveProperty("heights"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("omits measurements at the six-second deadline and interrupts the measurement", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const finished = yield* Deferred.make(); + const render = yield* HtmlRender.make.pipe( + Effect.provideService( + HtmlPreviewBrowser, + fakeService({ + available: Effect.succeed(true), + measure: () => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(finished, undefined)), + ), + }), + ), + ); + const publishing = yield* render.publish(publishInput).pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* TestClock.adjust("6 seconds"); + expect(yield* Fiber.join(publishing)).not.toHaveProperty("heights"); + yield* Deferred.await(finished); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("limits concurrent browser jobs to two", () => + Effect.gen(function* () { + const twoEntered = yield* Deferred.make(); + const release = yield* Deferred.make(); + let active = 0; + let maximum = 0; + const capture = Effect.sync(() => { + active += 1; + maximum = Math.max(maximum, active); + }).pipe( + Effect.andThen( + Effect.suspend(() => + active === 2 ? Deferred.succeed(twoEntered, undefined) : Effect.void, + ), + ), + Effect.andThen(Deferred.await(release)), + Effect.as({ png: PNG_BYTES, contentHeight: 10, capturedHeight: 10, consoleMessages: [] }), + Effect.ensuring( + Effect.sync(() => { + active -= 1; + }), + ), + ); + const render = yield* HtmlRender.make.pipe( + Effect.provideService(HtmlPreviewBrowser, fakeService({ capture: () => capture })), + ); + const jobs = yield* Effect.forEach([1, 2, 3], () => + render.preview({ html: "

x

" }).pipe(Effect.forkChild), + ); + yield* Deferred.await(twoEntered); + expect(active).toBe(2); + yield* Deferred.succeed(release, undefined); + yield* Effect.forEach(jobs, Fiber.join); + expect(maximum).toBe(2); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect( + "discards a saved render after post-save authorization fails, and rejects arbitrary paths", + () => + Effect.gen(function* () { + const render = yield* HtmlRender.HtmlRender; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const reference = yield* render.publish(publishInput); + yield* Effect.fail("authorization expired").pipe( + Effect.onError(() => render.discardHtmlRender(reference).pipe(Effect.ignore)), + Effect.flip, + ); + expect(yield* fs.readDirectory(config.attachmentsDir)).toEqual([]); + const error = yield* render + .discardHtmlRender({ ...reference, attachmentId: "folder/other-html" }) + .pipe(Effect.flip); + expect(error._tag).toBe("HtmlRenderStoreError"); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("inlines local images by absolute path and leaves URLs and relative paths alone", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const htmlRender = yield* HtmlRender.HtmlRender; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "pathway-html-images-", + }); + const png = path.join(directory, "shot.png"); + const svg = path.join(directory, "logo.svg"); + yield* fileSystem.writeFile(png, PNG_BYTES); + yield* fileSystem.writeFileString(svg, ""); + const kept = [ + "https://example.com/a.png", + "//cdn.example.com/b.png", + "./c.png", + "data:image/png;base64,AAAA", + ]; + + const prepared = yield* htmlRender.prepare( + [ + "Shots", + `
`, + ``, + ...kept.map((src) => ``), + "", + ].join(""), + ); + + const pngUri = `data:image/png;base64,${Buffer.from(PNG_BYTES).toString("base64")}`; + const svgUri = `data:image/svg+xml;base64,${Buffer.from("").toString("base64")}`; + expect(prepared).toContain(``); + expect(prepared).toContain(`url(${svgUri})`); + expect(prepared).toContain(`['${pngUri}', \`${svgUri}\`]`); + expect(prepared).not.toContain(directory); + for (const src of kept) expect(prepared).toContain(``); + // The theme bootstrap opens the head, ahead of the page's own markup. + expect(prepared.indexOf("")).toBeLessThan( + prepared.indexOf('x"; + const injected = injectHtmlRenderBootstrap(html); + const themeAt = injected.indexOf('", + "--other": "red;background:blue", + color: "red", + "--font-sans": '"Quoted Font", sans-serif', + }, + }; + harness.windowEvents.get("message")!({ + source: harness.parent, + data: htmlRenderThemeMessage(incoming), + }); + expect(harness.style.textContent).toContain("--background:oklch(0.1 0 0);"); + expect(harness.style.textContent).toContain('--font-sans:"Quoted Font", sans-serif;'); + expect(harness.style.textContent).not.toMatch(/--bad:|--other:|alert\(1\)|background:blue/); + const before = harness.style.textContent; + harness.windowEvents.get("message")!({ source: {}, data: htmlRenderThemeMessage(theme) }); + expect(harness.style.textContent).toBe(before); + }); + + it("ignores malformed fragment and theme messages", () => { + const harness = bootstrapHarness(false, "#pathway-theme=%broken"); + for (const data of [ + {}, + { type: "pathway-html-render-theme", theme: {} }, + { + jsonrpc: "2.0", + method: "ui/notifications/host-context-changed", + params: { theme: "invalid", styles: { variables: {} } }, + }, + ]) { + harness.windowEvents.get("message")!({ source: harness.parent, data }); + } + expect(harness.style.textContent).toBe(""); + }); + + it("reports measured content height only on change, with no continuous animation loop", () => { + const harness = bootstrapHarness(); + harness.documentEvents.get("DOMContentLoaded")!(); + harness.resize(); + harness.windowEvents.get("load")!({}); + expect(harness.posted).toEqual([ + { jsonrpc: "2.0", method: "ui/notifications/size-changed", params: { height: 412 } }, + ]); + harness.root.scrollHeight = 400; + harness.root.getBoundingClientRect = () => ({ height: 399.5 }); + harness.resize(); + expect(harness.posted[1]).toEqual({ + jsonrpc: "2.0", + method: "ui/notifications/size-changed", + params: { height: 400 }, + }); + expect(harness.observed).toHaveLength(2); + expect(bootstrapHarness(true).observed).toHaveLength(0); + }); + + it("bridges only trusted external http links, including shadow-DOM composed paths", () => { + const harness = bootstrapHarness(); + const click = harness.documentEvents.get("click")!; + let prevented = 0; + const link = (href: string) => ({ matches: () => true, getAttribute: () => href }); + const event = (href: string, isTrusted = true) => ({ + isTrusted, + composedPath: () => [{}, link(href)], + preventDefault: () => prevented++, + }); + click(event("https://example.com/a", false)); + click(event("javascript:alert(1)")); + click(event("https://environment.example/page.html?token=x#anchor")); + expect(harness.posted).toEqual([]); + click(event("https://example.com/a")); + expect(prevented).toBe(1); + expect(harness.posted).toEqual([ + { + jsonrpc: "2.0", + id: "pathway-link-1", + method: "ui/open-link", + params: { url: "https://example.com/a" }, + }, + ]); + }); + + it("opens trusted top-level links in a new window for the native host", () => { + const harness = bootstrapHarness(true); + const attrs = new Map(); + const link = { + matches: () => true, + getAttribute: () => "https://example.com", + setAttribute: (name: string, value: string) => attrs.set(name, value), + }; + harness.documentEvents.get("click")!({ isTrusted: true, composedPath: () => [link] }); + expect(attrs.get("target")).toBe("_blank"); + expect(attrs.get("rel")).toBe("noopener"); + expect(harness.posted).toEqual([]); + }); +}); + +describe("htmlRenderFromToolItem", () => { + const result = { htmlRender: reference, message: "Rendered above your reply." }; + + it("reads only normalized completed own-tool results", () => { + for (const toolName of [ + "pathway.html_render", + "mcp__pathway__html_render", + "pathway_html_render", + "html_render", + "pathway_code/html_render", + ]) { + expect( + htmlRenderFromToolItem({ + type: "dynamic_tool", + status: "completed", + toolName, + output: result, + }), + ).toEqual(reference); + } + }); + + it("ignores other tools, unnormalized envelopes, errors, and stale failed outputs", () => { + const item = { + type: "dynamic_tool", + status: "completed", + toolName: "pathway.html_render", + output: result, + }; + for (const change of [ + { type: "command_execution" }, + { status: "running" }, + { status: "failed" }, + { status: "cancelled" }, + { toolName: "pathway.html_preview" }, + { toolName: "mcp__other__html_render" }, + { output: { ...result, isError: true } }, + { output: { htmlRender: {} } }, + { output: JSON.stringify(result) }, + { output: { structuredContent: result } }, + { output: [{ type: "text", text: JSON.stringify(result) }] }, + ]) { + expect(htmlRenderFromToolItem({ ...item, ...change })).toBeUndefined(); + } + }); +}); + +describe("htmlRenderFrameHeight", () => { + const measured = readHtmlRenderReference({ + ...reference, + height: 1500, + heights: [ + [728, 1403], + [390, 1290], + [1000, 1660], + ], + })!; + + it("takes the taller neighbor between measured widths and holds the ends", () => { + expect(measured.heights?.map(([width]) => width)).toEqual([390, 728, 1000]); + expect(htmlRenderFrameHeight(measured, 728)).toBe(1403); + expect(htmlRenderFrameHeight(measured, 559)).toBe(1403); + expect(htmlRenderFrameHeight(measured, 320)).toBe(1290); + }); + + it("takes the taller layout when a breakpoint falls between measured widths", () => { + // 900px tall below a 600px media query, 450px above it. + const responsive = readHtmlRenderReference({ + ...reference, + height: 2000, + heights: [ + [520, 900], + [640, 450], + ], + })!; + expect(htmlRenderFrameHeight(responsive, 590)).toBe(900); + expect(htmlRenderFrameHeight(responsive, 640)).toBe(450); + }); + + it("fits a page the client lays out taller than the server measured", () => { + // The agent passed contentHeight at the column width, so the page should never scroll. + const fitted = { ...measured, height: 1403 }; + expect(htmlRenderFrameHeight(fitted, 728, 1415)).toBe(1415); + expect(htmlRenderFrameHeight(fitted, 1400)).toBe(1660); + expect(htmlRenderFrameHeight(fitted, 728, 5000)).toBe(HTML_RENDER_MAX_HEIGHT); + }); + + it("keeps the agent's height when it asked for a scrolling frame or the page is unmeasured", () => { + const scrolling = { ...measured, height: 600 }; + expect(htmlRenderFrameHeight(scrolling, 728, 1415)).toBe(600); + expect(htmlRenderFrameHeight(scrolling, 1400)).toBe(600); + expect(htmlRenderFrameHeight(reference, 728)).toBe(reference.height); + expect(htmlRenderFrameHeight(reference, 728, 900)).toBe(reference.height); + expect(htmlRenderFrameHeight(reference, 728, 300)).toBe(300); + }); + + it("drops a malformed table and compares tables by value", () => { + expect(readHtmlRenderReference({ ...reference, heights: [[728, "x"]] })?.heights).toBe( + undefined, + ); + const copy = readHtmlRenderReference(JSON.parse(JSON.stringify(measured)))!; + expect(htmlRenderReferencesEqual(measured, copy)).toBe(true); + expect(htmlRenderReferencesEqual(measured, { ...copy, heights: [[390, 1290]] })).toBe(false); + }); +}); diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts new file mode 100644 index 000000000..aba19c37e --- /dev/null +++ b/packages/shared/src/htmlRender.ts @@ -0,0 +1,520 @@ +import type { HtmlRenderReference } from "@spiritdevs/contracts"; +import { resolvePathwayMcpToolId } from "./pathwayMcpToolPresentation.ts"; + +import { + Pathway_CODE_DARK_THEME_COLORS, + Pathway_CODE_LIGHT_THEME_COLORS, + type ThemeAppearance, + type ThemeColors, +} from "./themePalettes.ts"; + +export type { HtmlRenderReference } from "@spiritdevs/contracts"; + +/** + * Agent-authored HTML pages ("HTML renders") are self-contained documents an + * agent publishes into a thread with Pathway's `html_render` MCP tool. The server + * stores each one as a thread attachment with a small bootstrap injected into + * its head; clients show it in a sandboxed iframe (web, desktop) or WebView + * (mobile) and hand it the active theme as CSS custom properties. + */ + +export const HTML_RENDER_TOOL_NAME = "html_render"; +export const HTML_RENDER_MIN_HEIGHT = 80; +export const HTML_RENDER_MAX_HEIGHT = 2000; +export const HTML_RENDER_MAX_TITLE_LENGTH = 200; + +/** Frame widths the server measures a page at, from phones to the wide chat setting. */ +export const HTML_RENDER_MEASURE_WIDTHS = [320, 375, 430, 520, 640, 728, 860, 1000, 1144] as const; + +/** + * The reply column's frame width at the default chat width. Agents preview at + * it, and it picks the measured height when a client cannot know its width. + */ +export const HTML_RENDER_COLUMN_WIDTH = 728; + +const MAX_MEASURED_HEIGHTS = 24; + +function readMeasuredHeights(value: unknown) { + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_MEASURED_HEIGHTS) { + return undefined; + } + const heights = value.flatMap((entry) => + Array.isArray(entry) && + entry.length === 2 && + Number.isInteger(entry[0]) && + entry[0] >= 1 && + entry[0] <= 10_000 && + typeof entry[1] === "number" && + Number.isFinite(entry[1]) && + entry[1] > 0 + ? [[entry[0] as number, clampHtmlRenderHeight(entry[1])] as const] + : [], + ); + if (heights.length !== value.length) return undefined; + const sorted = [...heights].sort((left, right) => left[0] - right[0]); + return sorted.every(([width], index) => index === 0 || width > sorted[index - 1]![0]) + ? sorted + : undefined; +} + +export function readHtmlRenderReference(value: unknown): HtmlRenderReference | undefined { + if (typeof value !== "object" || value === null) return undefined; + const { attachmentId, title, height, heights } = value as Record; + if ( + typeof attachmentId !== "string" || + attachmentId.length === 0 || + attachmentId.length > 256 || + typeof title !== "string" || + typeof height !== "number" || + !Number.isFinite(height) + ) { + return undefined; + } + const measured = readMeasuredHeights(heights); + return { + attachmentId, + title: title.trim().slice(0, HTML_RENDER_MAX_TITLE_LENGTH) || "HTML", + height: clampHtmlRenderHeight(height), + ...(measured === undefined ? {} : { heights: measured }), + }; +} + +/** Selects a page from a normalized v2 tool item; native envelopes belong in adapters. */ +export function htmlRenderFromToolItem(item: { + readonly type: string; + readonly status: string; + readonly toolName?: string | null | undefined; + readonly output?: unknown; +}): HtmlRenderReference | undefined { + if ( + item.type !== "dynamic_tool" || + item.status !== "completed" || + resolvePathwayMcpToolId(item.toolName) !== HTML_RENDER_TOOL_NAME + ) + return undefined; + if (typeof item.output !== "object" || item.output === null || Array.isArray(item.output)) + return undefined; + const output = item.output as Record; + return output.isError === true ? undefined : readHtmlRenderReference(output.htmlRender); +} + +/** Whether two references show the same page at the same sizes. */ +export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: HtmlRenderReference) { + return ( + left.attachmentId === right.attachmentId && + left.title === right.title && + left.height === right.height && + (left.heights ?? []).length === (right.heights ?? []).length && + (left.heights ?? []).every( + ([width, height], index) => + right.heights?.[index]?.[0] === width && right.heights[index][1] === height, + ) + ); +} + +// The taller of the heights measured at the nearest widths on each side. A +// breakpoint between two measured widths can make the page as tall as either. +function measuredHeight(heights: NonNullable, width: number) { + const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width); + const high = above === -1 ? heights.length - 1 : above; + const low = heights[high]![0] === width ? high : Math.max(0, high - 1); + return Math.max(heights[low]![1], heights[high]![1]); +} + +/** + * The frame height for a page at a frame width. It is the page's own reported + * `contentHeight` when the client has one, else the server's measurement for + * that width. A page even a few pixels taller than its frame scrolls inside it + * and takes the reader's scroll, so the frame fits the page. The agent's height + * caps it only when it is below the page's height at the column width (the + * agent asked for a scrolling frame) or when the page was never measured. + */ +export function htmlRenderFrameHeight( + reference: HtmlRenderReference, + width: number, + contentHeight?: number, +) { + const heights = reference.heights; + if (heights === undefined || heights.length === 0) { + return clampHtmlRenderHeight(Math.min(reference.height, contentHeight ?? reference.height)); + } + const cap = + measuredHeight(heights, HTML_RENDER_COLUMN_WIDTH) > reference.height + ? reference.height + : HTML_RENDER_MAX_HEIGHT; + return clampHtmlRenderHeight(Math.min(cap, contentHeight ?? measuredHeight(heights, width))); +} + +/** A readable download name: the title without characters file systems reject. */ +export function htmlRenderFileName(title: string) { + const name = title + .replace(/[\\/:*?"<>|\p{Cc}]+/gu, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120) + .trim(); + return `${name || "Page"}.html`; +} + +export function clampHtmlRenderHeight(height: number): number { + return Math.min(HTML_RENDER_MAX_HEIGHT, Math.max(HTML_RENDER_MIN_HEIGHT, Math.round(height))); +} + +export interface HtmlRenderFonts { + readonly sans: string; + readonly mono: string; +} + +export const HTML_RENDER_DEFAULT_FONTS: HtmlRenderFonts = { + sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', + // Concrete names only: some engines alias `ui-monospace` to the proportional UI font. + mono: '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace', +}; + +/** + * Fonts the server's headless browser lays pages out with. Arial-metric faces + * stand in for the system UI fonts readers see, so measured heights and + * preview screenshots wrap text close to how a client will. + */ +export const HTML_RENDER_MEASURE_FONTS: HtmlRenderFonts = { + sans: '"Liberation Sans", Arimo, Arial, Helvetica, sans-serif', + mono: '"Liberation Mono", Cousine, Menlo, Consolas, monospace', +}; + +/** The resolved theme a client hands an HTML render. */ +export interface HtmlRenderTheme { + readonly appearance: ThemeAppearance; + readonly variables: Readonly>; +} + +// Roles themes cannot change (index.css keeps info/success fixed), plus a +// categorical chart series after the theme's own accent. +const FIXED_COLORS = { + light: { + success: "#10b981", + successForeground: "#047857", + info: "#3b82f6", + infoForeground: "#1d4ed8", + chart: ["#0d9488", "#d97706", "#9333ea", "#e11d48", "#65a30d"], + }, + dark: { + success: "#10b981", + successForeground: "#34d399", + info: "#3b82f6", + infoForeground: "#60a5fa", + chart: ["#2dd4bf", "#fbbf24", "#c084fc", "#fb7185", "#a3e635"], + }, +} as const; + +export type HtmlRenderThemeColors = Pick< + ThemeColors, + | "canvas" + | "text" + | "muted" + | "mutedForeground" + | "surface" + | "surfaceOverlay" + | "secondary" + | "secondaryForeground" + | "border" + | "input" + | "focus" + | "messageAction" + | "messageActionForeground" + | "accent" + | "accentForeground" + | "accentSurface" + | "accentSurfaceForeground" + | "error" + | "errorForeground" + | "errorSurface" + | "warning" + | "warningForeground" + | "warningSurface" + | "codeBackground" + | "codeForeground" +>; + +/** + * Maps a theme palette to the variables HTML renders style against. Names + * follow the app's own tokens, except `--accent`, which is the theme's brand + * color here rather than the app's neutral hover surface. + */ +export function htmlRenderTheme( + colors: HtmlRenderThemeColors, + appearance: ThemeAppearance, + fonts: HtmlRenderFonts = HTML_RENDER_DEFAULT_FONTS, +): HtmlRenderTheme { + const fixed = FIXED_COLORS[appearance]; + return { + appearance, + variables: { + "--background": colors.canvas, + "--foreground": colors.text, + "--muted": colors.muted, + "--muted-foreground": colors.mutedForeground, + "--card": colors.surface, + "--card-foreground": colors.text, + "--popover": colors.surfaceOverlay, + "--popover-foreground": colors.text, + "--secondary": colors.secondary, + "--secondary-foreground": colors.secondaryForeground, + "--border": colors.border, + "--input": colors.input, + "--ring": colors.focus, + "--primary": colors.messageAction, + "--primary-foreground": colors.messageActionForeground, + "--accent": colors.accent, + "--accent-foreground": colors.accentForeground, + "--accent-surface": colors.accentSurface, + "--accent-surface-foreground": colors.accentSurfaceForeground, + "--destructive": colors.error, + "--destructive-foreground": colors.errorForeground, + "--destructive-surface": colors.errorSurface, + "--warning": colors.warning, + "--warning-foreground": colors.warningForeground, + "--warning-surface": colors.warningSurface, + "--success": fixed.success, + "--success-foreground": fixed.successForeground, + "--info": fixed.info, + "--info-foreground": fixed.infoForeground, + "--code-background": colors.codeBackground, + "--code-foreground": colors.codeForeground, + "--chart-1": colors.accent, + ...Object.fromEntries(fixed.chart.map((color, index) => [`--chart-${index + 2}`, color])), + "--radius": "0.625rem", + "--font-sans": fonts.sans, + "--font-mono": fonts.mono, + }, + }; +} + +/** Agent-facing reference for the injected variables, used in tool descriptions. */ +export const HTML_RENDER_THEME_GUIDE = [ + "Pathway injects its active theme as CSS custom properties on :root, and they follow the user's theme and light/dark mode live:", + "--background (page background, identical to the thread around the frame), --foreground, --muted, --muted-foreground,", + "--card, --card-foreground, --popover, --popover-foreground, --secondary, --secondary-foreground, --border, --input, --ring,", + "--primary, --primary-foreground (solid buttons), --accent, --accent-foreground (brand accent), --accent-surface, --accent-surface-foreground,", + "--destructive, --destructive-foreground, --destructive-surface, --warning, --warning-foreground, --warning-surface,", + "--success, --success-foreground, --info, --info-foreground, --code-background, --code-foreground,", + "--chart-1 … --chart-6 (categorical series for charts), --radius, --font-sans, --font-mono.", + "The base stylesheet sets html background/color/font from these, body margin to 0, and hides the page's scrollbar; your own CSS overrides it.", +].join(" "); + +/** Agent-facing layout rules for a page that sits inside a reply. */ +export const HTML_RENDER_LAYOUT_GUIDE = [ + `The frame is borderless on the thread's background, as wide as the reply column (${HTML_RENDER_COLUMN_WIDTH}px on desktop by default, wider if the reader widens chat, about 360px on phones), and its left edge lines up with your reply text.`, + "Use a fluid width with no horizontal padding on the outermost element, and no outer card, border, or banner title: the page is part of your reply.", + "Give charts fixed pixel heights rather than heights that scale with width.", + "Let content set the page's height. Avoid viewport-based heights such as 100vh or height:100% on html or body; the frame grows to fit the page, so they can make it grow again and again.", +].join(" "); + +// The bridge between a render and its client speaks the MCP Apps protocol +// (JSON-RPC over postMessage), so the same host code can later drive upstream +// MCP apps: https://github.com/modelcontextprotocol/ext-apps +const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; +const OPEN_LINK_METHOD = "ui/open-link"; +const SIZE_CHANGED_METHOD = "ui/notifications/size-changed"; + +/** The content height in a framed render's `ui/notifications/size-changed` notification. */ +export function readHtmlRenderContentHeight(data: unknown): number | undefined { + if (typeof data !== "object" || data === null) return undefined; + const { jsonrpc, method, params } = data as Record; + if (jsonrpc !== "2.0" || method !== SIZE_CHANGED_METHOD) return undefined; + const height = + typeof params === "object" && params !== null + ? (params as { height?: unknown }).height + : undefined; + return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined; +} + +/** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */ +export function readHtmlRenderLinkRequest( + data: unknown, +): { readonly id: string | number; readonly url: string } | undefined { + if (typeof data !== "object" || data === null) return undefined; + const { jsonrpc, id, method, params } = data as Record; + if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined; + if (typeof id !== "string" && typeof id !== "number") return undefined; + if (typeof id === "number" && !Number.isFinite(id)) return undefined; + const url = + typeof params === "object" && params !== null ? (params as { url?: unknown }).url : undefined; + if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return undefined; + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? { id, url } : undefined; + } catch { + return undefined; + } +} + +/** The empty result a client sends back for a render's request. */ +export function htmlRenderResult(id: string | number) { + return { jsonrpc: "2.0", id, result: {} } as const; +} + +const THEME_FRAGMENT_KEY = "pathway-theme"; + +/** URL fragment that hands a render its theme before first paint. */ +export function htmlRenderThemeFragment(theme: HtmlRenderTheme): string { + return `#${THEME_FRAGMENT_KEY}=${encodeURIComponent(JSON.stringify(theme))}`; +} + +/** The `host-context-changed` notification a client posts into a mounted render when the theme changes. */ +export function htmlRenderThemeMessage(theme: HtmlRenderTheme) { + return { + jsonrpc: "2.0", + method: HOST_CONTEXT_CHANGED_METHOD, + params: { theme: theme.appearance, styles: { variables: theme.variables } }, + } as const; +} + +// The frame scrolls a page taller than itself, but a scrollbar inside the +// reply reads as a box within the thread, so it stays hidden. +const BASE_CSS = + "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + + "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}"; + +function rootRule(theme: HtmlRenderTheme): string { + const declarations = Object.entries(theme.variables) + .map(([name, value]) => `${name}:${value};`) + .join(""); + return `:root{color-scheme:${theme.appearance};${declarations}}`; +} + +// Runs synchronously in , before the page's own styles and body, so the +// first paint is already themed. It rewrites its own `, + ``, + ].join(""); +} + +// A forward scanner skips inert content and quoted attributes, preserving actual tag offsets. +function scanDocumentMarkup(html: string) { + const result: { + headEnd?: number; + htmlEnd?: number; + doctypeEnd?: number; + hasCharset: boolean; + hasViewport: boolean; + } = { + hasCharset: false, + hasViewport: false, + }; + const tag = /<(\/?)([a-z][a-z0-9:-]*)(?=[\s/>])/giy; + const rawTags = new Set([ + "script", + "style", + "textarea", + "title", + "xmp", + "iframe", + "noembed", + "noframes", + "noscript", + ]); + let depth = 0; + let at = 0; + + function tagEnd(start: number): number | undefined { + let quote = ""; + for (let index = start; index < html.length; index++) { + const char = html[index]!; + if (quote) { + if (char === quote) quote = ""; + } else if (char === '"' || char === "'") quote = char; + else if (char === ">") return index + 1; + } + return undefined; + } + + while (at < html.length) { + const start = html.indexOf("<", at); + if (start === -1) break; + if (html.startsWith("", start + 4); + at = end === -1 ? html.length : end + 3; + continue; + } + if (/^])`, "gi"); + close.lastIndex = at; + const end = close.exec(html); + at = end ? (tagEnd(close.lastIndex) ?? html.length) : html.length; + continue; + } + if (depth > 0 || closing) continue; + if (name === "head") result.headEnd ??= at; + if (name === "html") result.htmlEnd ??= at; + if (name === "meta") { + const markup = html.slice(start, at); + if (start < 4096 && /\scharset\s*=/i.test(markup)) result.hasCharset = true; + if (/\sname\s*=\s*(?:"viewport"|'viewport'|viewport(?=[\s/>]))/i.test(markup)) + result.hasViewport = true; + } + } + return result; +} + +/** + * Inserts the theme bootstrap at the start of the document head, so a page's + * own styles and scripts come after it. + */ +export function injectHtmlRenderBootstrap(html: string): string { + const scan = scanDocumentMarkup(html); + const markup = bootstrapMarkup(scan); + if (scan.headEnd !== undefined) { + const at = scan.headEnd; + return html.slice(0, at) + markup + html.slice(at); + } + if (scan.htmlEnd !== undefined) { + const at = scan.htmlEnd; + return `${html.slice(0, at)}${markup}${html.slice(at)}`; + } + if (scan.doctypeEnd !== undefined) { + const at = scan.doctypeEnd; + return `${html.slice(0, at)}${markup}${html.slice(at)}`; + } + return `${markup}${html}`; +} diff --git a/packages/shared/src/pathwayMcpToolPresentation.test.ts b/packages/shared/src/pathwayMcpToolPresentation.test.ts index aae41f1ad..aae9e8410 100644 --- a/packages/shared/src/pathwayMcpToolPresentation.test.ts +++ b/packages/shared/src/pathwayMcpToolPresentation.test.ts @@ -1,8 +1,48 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolvePathwayMcpToolPresentation } from "./pathwayMcpToolPresentation.ts"; +import { + resolvePathwayMcpToolId, + resolvePathwayMcpToolPresentation, +} from "./pathwayMcpToolPresentation.ts"; describe("resolvePathwayMcpToolPresentation", () => { + it("labels inline HTML tools and resolves every own provider spelling", () => { + for (const id of ["html_render", "html_preview"]) { + for (const spelling of [ + id, + `pathway.${id}`, + `pathway/${id}`, + `pathway:${id}`, + `mcp__pathway__${id}`, + `mcp__pathway_code__${id}`, + `pathway_${id}`, + `pathway_code_${id}`, + `${id} completed`, + ]) { + expect(resolvePathwayMcpToolId(spelling)).toBe(id); + expect(resolvePathwayMcpToolPresentation(spelling)?.displayName).toBe( + id === "html_render" ? "Render an HTML page" : "Preview an HTML page", + ); + } + } + }); + + it("rejects unrelated servers, unknown ids, and ambiguous underscore suffixes", () => { + for (const spelling of [ + null, + undefined, + "mcp__other__html_render", + "other.html_render", + "other_html_render", + "pathway-thread_1_html_render", + "pathway_other_html_render", + "pathway.not_a_tool", + "pathway_not_a_tool", + ]) { + expect(resolvePathwayMcpToolId(spelling)).toBeNull(); + } + expect(resolvePathwayMcpToolId("pathway_pathway_thread_read")).toBe("pathway_thread_read"); + }); it("pretty prints Claude and Cursor Pathway MCP tool names", () => { expect(resolvePathwayMcpToolPresentation("mcp__pathway__pathway_thread_read")).toEqual({ displayName: "Read a Pathway thread", diff --git a/packages/shared/src/pathwayMcpToolPresentation.ts b/packages/shared/src/pathwayMcpToolPresentation.ts index 0c16ac5ec..0bdd254e4 100644 --- a/packages/shared/src/pathwayMcpToolPresentation.ts +++ b/packages/shared/src/pathwayMcpToolPresentation.ts @@ -91,6 +91,8 @@ const PATHWAY_MCP_TOOL_DISPLAY_NAMES: Record = { email_latest_code: "Get latest email code", email_list: "List captured email", email_get: "Read captured email", + html_preview: "Preview an HTML page", + html_render: "Render an HTML page", }; function normalizePathwayMcpToolLabel(value: string): string { @@ -116,14 +118,28 @@ function resolvePathwayMcpToolName(value: string): string | null { return namespaceMatch.groups.tool ?? null; } + // OpenCode joins the stable server name and tool with one underscore. + // Match a complete known id, never an arbitrary other-server suffix. + for (const server of PATHWAY_MCP_SERVER_ALIASES) { + if (label.toLowerCase().startsWith(`${server}_`)) { + const tool = label.slice(server.length + 1); + if (Object.hasOwn(PATHWAY_MCP_TOOL_DISPLAY_NAMES, tool)) return tool; + } + } + return Object.hasOwn(PATHWAY_MCP_TOOL_DISPLAY_NAMES, label) ? label : null; } +/** The bare tool id for a known Pathway MCP spelling. */ +export function resolvePathwayMcpToolId(toolName: string | null | undefined): string | null { + const name = toolName == null ? null : resolvePathwayMcpToolName(toolName); + return name !== null && Object.hasOwn(PATHWAY_MCP_TOOL_DISPLAY_NAMES, name) ? name : null; +} + export function resolvePathwayMcpToolPresentation( toolName: string | null | undefined, ): PathwayMcpToolPresentation | null { - const resolvedToolName = - toolName === undefined || toolName === null ? null : resolvePathwayMcpToolName(toolName); + const resolvedToolName = resolvePathwayMcpToolId(toolName); if (resolvedToolName === null) return null; const displayName = PATHWAY_MCP_TOOL_DISPLAY_NAMES[resolvedToolName]; return displayName === undefined ? null : { displayName, logo: "pathway" }; diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts new file mode 100644 index 000000000..9fd26d33d --- /dev/null +++ b/packages/shared/src/themePalettes.ts @@ -0,0 +1,183 @@ +export const THEME_COLOR_ROLES = [ + "canvas", + "chrome", + "toolbar", + "toolbarForeground", + "toolbarBorder", + "toolbarControl", + "toolbarControlForeground", + "toolbarControlHover", + "surface", + "surfaceRaised", + "surfaceOverlay", + "text", + "textMuted", + "border", + "input", + "focus", + "accent", + "accentForeground", + "secondary", + "secondaryForeground", + "muted", + "mutedForeground", + "placeholder", + "secondaryLabel", + "iconMuted", + "error", + "errorForeground", + "errorSurface", + "warning", + "warningForeground", + "warningSurface", + "update", + "updateForeground", + "updateSurface", + "accentSurface", + "accentSurfaceForeground", + "messageSurface", + "messageForeground", + "messageAction", + "messageActionForeground", + "messageActionHover", + "codeBackground", + "codeForeground", + "sidebar", + "sidebarForeground", + "sidebarMutedForeground", + "sidebarControlSurface", + "sidebarRowHover", + "sidebarRowActive", + "sidebarRowSelected", + "sidebarBorder", + "terminalBackground", + "terminalForeground", + "terminalCursor", + "terminalSelection", + "terminalScrollbar", + "terminalScrollbarHover", +] as const; + +export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number]; +export type ThemeAppearance = "light" | "dark"; + +export type ThemeColors = Readonly>; +export const Pathway_CODE_LIGHT_THEME_COLORS: ThemeColors = { + canvas: "#fcfcfc", + chrome: "#fcfcfc", + toolbar: "#fcfcfc", + toolbarForeground: "#27272a", + toolbarBorder: "#e4e4e7", + toolbarControl: "#ffffff", + toolbarControlForeground: "#27272a", + toolbarControlHover: "#f4f4f5", + surface: "#ffffff", + surfaceRaised: "#fcfcfc", + surfaceOverlay: "#ffffff", + text: "#27272a", + textMuted: "#71717b", + border: "#e4e4e7", + input: "#d4d4d8", + focus: "#1b4ed8", + accent: "#1b4ed8", + accentForeground: "#ffffff", + secondary: "#fafafa", + secondaryForeground: "#27272a", + muted: "#fafafa", + mutedForeground: "#71717b", + placeholder: "#71717b", + secondaryLabel: "#71717b", + iconMuted: "#71717b", + error: "#fb2c36", + errorForeground: "#c10007", + errorSurface: "#fcebec", + warning: "#fe9a00", + warningForeground: "#bb4d00", + warningSurface: "#fcf4e8", + update: "#1b4ed8", + updateForeground: "#1b4ed8", + updateSurface: "#e0e6f7", + accentSurface: "#f4f4f5", + accentSurfaceForeground: "#18181b", + messageSurface: "#f4f4f5", + messageForeground: "#27272a", + messageAction: "#1b4ed8", + messageActionForeground: "#ffffff", + messageActionHover: "#3160db", + codeBackground: "#ffffff", + codeForeground: "#27272a", + sidebar: "#fafafa", + sidebarForeground: "#27272a", + sidebarMutedForeground: "#71717b", + sidebarControlSurface: "#f4f4f5", + sidebarRowHover: "#fcfcfc", + sidebarRowActive: "#ffffff", + sidebarRowSelected: "#ffffff", + sidebarBorder: "#e4e4e7", + terminalBackground: "#fcfcfc", + terminalForeground: "#27272a", + terminalCursor: "#26384e", + terminalSelection: "#d0d6dd", + terminalScrollbar: "#d6d6d6", + terminalScrollbarHover: "#bdbdbd", +}; + +export const Pathway_CODE_DARK_THEME_COLORS: ThemeColors = { + canvas: "#0a0a0a", + chrome: "#0a0a0a", + toolbar: "#0a0a0a", + toolbarForeground: "#f5f5f5", + toolbarBorder: "#191919", + toolbarControl: "#191919", + toolbarControlForeground: "#f5f5f5", + toolbarControlHover: "#141414", + surface: "#111111", + surfaceRaised: "#141414", + surfaceOverlay: "#191919", + text: "#f5f5f5", + textMuted: "#818181", + border: "#191919", + input: "#1e1e1e", + focus: "#346bf1", + accent: "#346bf1", + accentForeground: "#ffffff", + secondary: "#141414", + secondaryForeground: "#f5f5f5", + muted: "#141414", + mutedForeground: "#818181", + placeholder: "#818181", + secondaryLabel: "#818181", + iconMuted: "#818181", + error: "#fb414a", + errorForeground: "#ff6467", + errorSurface: "#301214", + warning: "#fe9a00", + warningForeground: "#ffb900", + warningSurface: "#312108", + update: "#346bf1", + updateForeground: "#51a2ff", + updateSurface: "#121b34", + accentSurface: "#141414", + accentSurfaceForeground: "#f5f5f5", + messageSurface: "#141414", + messageForeground: "#f5f5f5", + messageAction: "#346bf1", + messageActionForeground: "#ffffff", + messageActionHover: "#3061d9", + codeBackground: "#111111", + codeForeground: "#f5f5f5", + sidebar: "#000000", + sidebarForeground: "#f1f3f7", + sidebarMutedForeground: "#a3a3a3", + sidebarControlSurface: "#0a0a0a", + sidebarRowHover: "#131313", + sidebarRowActive: "#1a1b1b", + sidebarRowSelected: "#111111", + sidebarBorder: "#141414", + terminalBackground: "#0a0a0a", + terminalForeground: "#f5f5f5", + terminalCursor: "#b4cbff", + terminalSelection: "#343a47", + terminalScrollbar: "#222222", + terminalScrollbarHover: "#363636", +}; diff --git a/packages/shared/src/toolOutput.test.ts b/packages/shared/src/toolOutput.test.ts new file mode 100644 index 000000000..3d7ae3079 --- /dev/null +++ b/packages/shared/src/toolOutput.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { compactHtmlToolProjection, readToolOutput } from "./toolOutput.ts"; + +const reference = { + attachmentId: "thread-uuid-html", + title: "Chart", + height: 480, + heights: [ + [320, 620], + [728, 480], + ], +}; +const result = { htmlRender: reference, message: "Rendered above your reply." }; + +describe("readToolOutput", () => { + it("reads each native envelope without accepting arbitrary nesting", () => { + for (const output of [ + result, + JSON.stringify(result), + { structuredContent: result }, + { content: [{ type: "text", text: JSON.stringify(result) }] }, + [{ type: "text", text: JSON.stringify(result) }], + { content: [{ type: "text", text: { text: JSON.stringify(result) } }] }, + ]) { + expect(readToolOutput(output)).toEqual({ data: result, isError: false }); + } + expect(readToolOutput({ unrelated: result }).data).toBeUndefined(); + expect(readToolOutput("broken JSON").data).toBeUndefined(); + }); + + it("retains outer, nested, and mirrored error flags", () => { + for (const output of [ + { isError: true, structuredContent: result }, + { structuredContent: { ...result, isError: true } }, + { + structuredContent: result, + content: [{ type: "text", text: JSON.stringify({ isError: true }) }], + }, + { is_error: true, content: [{ text: { text: JSON.stringify(result) } }] }, + { ...result, error: "Publish failed" }, + ]) + expect(readToolOutput(output).isError).toBe(true); + expect(readToolOutput({ ...result, error: false }).isError).toBe(false); + expect( + compactHtmlToolProjection({ + toolName: "html_render", + input: {}, + output: { isError: true, content: [{ text: "Publish failed" }] }, + }).output, + ).toEqual({ isError: true, message: "Publish failed" }); + }); + + it("bounds bytes, depth, blocks, total nodes, and cycles", () => { + expect( + readToolOutput(JSON.stringify({ ...result, padding: "x".repeat(16_384) })).data, + ).toBeUndefined(); + expect( + readToolOutput(JSON.stringify({ ...result, padding: "😀".repeat(5000) })).data, + ).toBeUndefined(); + let nested: unknown = result; + for (let depth = 0; depth < 8; depth++) nested = { structuredContent: nested }; + expect(readToolOutput(nested).data).toBeUndefined(); + expect( + readToolOutput({ + content: [ + ...Array.from({ length: 32 }, () => ({ type: "image", data: "big" })), + { text: JSON.stringify(result) }, + ], + }).data, + ).toBeUndefined(); + const cyclic: { structuredContent?: unknown } = {}; + cyclic.structuredContent = cyclic; + expect(readToolOutput(cyclic).data).toBeUndefined(); + const hiddenError = { + structuredContent: { + structuredContent: { + structuredContent: { + structuredContent: { structuredContent: { structuredContent: { isError: true } } }, + }, + }, + }, + }; + expect(readToolOutput({ ...hiddenError, ...result }).isError).toBe(true); + expect( + readToolOutput({ structuredContent: result, content: Array.from({ length: 33 }, () => ({})) }) + .data, + ).toBeUndefined(); + }); +}); + +describe("compactHtmlToolProjection", () => { + it("normalizes own identities and bounds inputs and publish results", () => { + for (const toolName of [ + "pathway.html_render", + "html_render", + "pathway_html_render", + "mcp__pathway__html_render", + ]) { + expect( + compactHtmlToolProjection({ + toolName, + input: { html: "

😀

", title: "Chart", height: 480, raw: "secret" }, + output: { structuredContent: result }, + }), + ).toEqual({ + toolName: "pathway.html_render", + input: { title: "Chart", height: 480, htmlBytes: 11 }, + output: result, + }); + } + }); + + it("compacts JSON inputs and omits outputs for running calls", () => { + expect( + compactHtmlToolProjection({ + toolName: "html_preview", + input: JSON.stringify({ html: "hi", width: 728, appearance: "dark" }), + }), + ).toEqual({ + toolName: "pathway.html_preview", + input: { htmlBytes: 2, width: 728, appearance: "dark" }, + }); + expect(compactHtmlToolProjection({ toolName: "html_render", input: "broken" }).input).toEqual( + {}, + ); + }); + + it("strips all image/base64 and unknown metadata from preview projection", () => { + const data = { + width: 728, + contentHeight: 500, + capturedHeight: 500, + consoleMessages: [{ level: "log", text: "loaded" }], + missingImages: ["/missing.png"], + screenshot: { mimeType: "image/png", width: 728, height: 500, data: "PNG_BASE64" }, + image: "PNG_BASE64", + html: "RAW_HTML", + }; + const projection = compactHtmlToolProjection({ + toolName: "pathway_html_preview", + input: { html: "RAW_HTML", width: 728 }, + output: { structuredContent: data, content: [{ type: "image", data: "PNG_BASE64" }] }, + }); + expect(projection.output).toEqual({ + width: 728, + contentHeight: 500, + capturedHeight: 500, + consoleMessages: [{ level: "log", text: "loaded" }], + missingImages: ["/missing.png"], + screenshot: { mimeType: "image/png", width: 728, height: 500 }, + }); + expect(JSON.stringify(projection)).not.toMatch(/PNG_BASE64|RAW_HTML/); + }); + + it("does not promote malformed or error results to successful renders", () => { + for (const output of [ + { isError: true, structuredContent: result }, + { structuredContent: { htmlRender: {} } }, + "bad JSON", + ]) { + expect( + compactHtmlToolProjection({ toolName: "html_render", input: {}, output }).output, + ).toMatchObject({ isError: true }); + } + }); + + it("leaves every unrelated tool projection intact", () => { + for (const toolName of [ + "mcp__other__html_render", + "other_html_render", + "pathway_other_html_render", + "pathway.create_threads", + null, + ]) { + const projection = { toolName, input: { html: "keep" }, output: result }; + expect(compactHtmlToolProjection(projection)).toBe(projection); + } + }); + + it("keeps serialized metadata within 8 KiB even with escaped or multibyte text", () => { + const render = compactHtmlToolProjection({ + toolName: "html_render", + input: {}, + output: { ...result, message: '😀"\\'.repeat(10_000) }, + }); + expect(new TextEncoder().encode(JSON.stringify(render.output)).byteLength).toBeLessThanOrEqual( + 8192, + ); + const preview = compactHtmlToolProjection({ + toolName: "html_preview", + input: {}, + output: { + width: 728, + contentHeight: 500, + capturedHeight: 500, + consoleMessages: Array.from({ length: 50 }, () => ({ + level: "log", + text: '😀"\\'.repeat(500), + })), + missingImages: Array.from({ length: 100 }, () => "\\".repeat(500)), + }, + }); + expect(new TextEncoder().encode(JSON.stringify(preview.output)).byteLength).toBeLessThanOrEqual( + 8192, + ); + }); +}); diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts new file mode 100644 index 000000000..394a1c488 --- /dev/null +++ b/packages/shared/src/toolOutput.ts @@ -0,0 +1,240 @@ +import * as Predicate from "effect/Predicate"; + +import { readHtmlRenderReference } from "./htmlRender.ts"; +import { resolvePathwayMcpToolId } from "./pathwayMcpToolPresentation.ts"; + +const MAX_PARSED_BYTES = 16_384; +const MAX_METADATA_BYTES = 8_192; +const MAX_DEPTH = 6; +const MAX_NODES = 64; +const MAX_CONTENT_BLOCKS = 32; +const encoder = new TextEncoder(); + +export interface ToolOutputEnvelope { + readonly data: Readonly> | undefined; + readonly isError: boolean; +} + +/** Reads only bounded MCP result envelopes, including Cursor's nested text.text blocks. */ +export function readToolOutput(value: unknown): ToolOutputEnvelope { + let parsedBytes = 0; + let nodes = 0; + let isError = false; + let exhausted = false; + let data: Record | undefined; + let fallback: Record | undefined; + const visited = new Set(); + + function visit(current: unknown, depth: number): void { + if (depth > MAX_DEPTH || ++nodes > MAX_NODES) { + exhausted = true; + return; + } + if (typeof current === "string") { + // The character guard avoids encoding an unbounded string just to reject it. + if (current.length > MAX_PARSED_BYTES - parsedBytes) { + exhausted = true; + return; + } + const bytes = encoder.encode(current).byteLength; + if (bytes > MAX_PARSED_BYTES - parsedBytes) { + exhausted = true; + return; + } + parsedBytes += bytes; + try { + visit(JSON.parse(current), depth + 1); + } catch { + // Ordinary text and malformed JSON are not structured results. + } + return; + } + if (Array.isArray(current)) { + if (visited.has(current)) return; + visited.add(current); + if (current.length > MAX_CONTENT_BLOCKS) exhausted = true; + for (const block of current.slice(0, MAX_CONTENT_BLOCKS)) visit(block, depth + 1); + return; + } + if (!Predicate.isObject(current) || visited.has(current)) return; + visited.add(current); + if ( + current.isError === true || + current.is_error === true || + (current.error != null && current.error !== false) + ) { + isError = true; + } + if ( + current.htmlRender !== undefined || + (current.width !== undefined && current.contentHeight !== undefined) + ) { + data ??= current; + } else if (typeof current.message === "string" || current.error != null) { + fallback ??= current; + } + if (typeof current.text === "string") fallback ??= { message: current.text }; + // Prefer the structured mirror; still inspect text mirrors for error flags. + if (current.structuredContent !== undefined) visit(current.structuredContent, depth + 1); + if (current.content !== undefined) visit(current.content, depth + 1); + if (current.text !== undefined) visit(current.text, depth + 1); + } + + visit(value, 0); + // An unread branch could carry an error: never promote a partial traversal to success. + return { data: exhausted ? undefined : (data ?? fallback), isError: isError || exhausted }; +} + +function boundedText(value: unknown, maxBytes = MAX_METADATA_BYTES): string | undefined { + if (typeof value !== "string") return undefined; + const text = value.slice(0, maxBytes); + const bytes = encoder.encode(text); + return bytes.byteLength <= maxBytes + ? text + : new TextDecoder().decode(bytes.subarray(0, maxBytes), { stream: true }); +} + +function boundedJsonText(value: unknown, maxBytes: number): string { + const text = boundedText(value, maxBytes) ?? ""; + if (encoder.encode(JSON.stringify(text)).byteLength <= maxBytes) return text; + let low = 0; + let high = text.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (encoder.encode(JSON.stringify(text.slice(0, middle))).byteLength <= maxBytes) low = middle; + else high = middle - 1; + } + // Do not leave half a surrogate pair at the truncation boundary. + return text.slice(0, low).replace(/[\uD800-\uDBFF]$/, ""); +} + +function compactInput(tool: string, value: unknown) { + let input = value; + if (typeof input === "string" && input.length <= 600_000) { + try { + input = JSON.parse(input); + } catch { + input = undefined; + } + } + const compact: Record = {}; + if (!Predicate.isObject(input) || Array.isArray(input)) return compact; + if (typeof input.html === "string") compact.htmlBytes = encoder.encode(input.html).byteLength; + else if (Number.isSafeInteger(input.htmlBytes) && (input.htmlBytes as number) >= 0) { + compact.htmlBytes = input.htmlBytes as number; + } + if (tool === "html_render") { + const title = boundedText(input.title, 800)?.trim().slice(0, 200); + if (title !== undefined) compact.title = title; + if (typeof input.height === "number" && Number.isFinite(input.height)) { + compact.height = input.height; + } + } else { + if (typeof input.width === "number" && Number.isFinite(input.width)) + compact.width = input.width; + if (input.appearance === "light" || input.appearance === "dark") { + compact.appearance = input.appearance; + } + } + return compact; +} + +function compactPreview(data: Readonly> | undefined) { + const output: Record = {}; + if (data === undefined) return output; + for (const key of ["width", "contentHeight", "capturedHeight"] as const) { + if (typeof data[key] === "number" && Number.isFinite(data[key]) && data[key] > 0) { + output[key] = data[key]; + } + } + if (Predicate.isObject(data.screenshot)) { + const { width, height, mimeType } = data.screenshot; + if ( + mimeType === "image/png" && + typeof width === "number" && + typeof height === "number" && + Number.isFinite(width) && + width > 0 && + Number.isFinite(height) && + height > 0 + ) { + output.screenshot = { mimeType, width, height }; + } + } + // Keep all projected metadata within one budget; never serialize raw provider output. + let remaining = MAX_METADATA_BYTES - encoder.encode(JSON.stringify(output)).byteLength - 100; + if (Array.isArray(data.consoleMessages)) { + const messages: Array<{ level: string; text: string }> = []; + for (const entry of data.consoleMessages.slice(0, 21)) { + if ( + !Predicate.isObject(entry) || + typeof entry.level !== "string" || + !["log", "info", "warning", "error"].includes(entry.level) + ) + continue; + const text = boundedText(entry.text, Math.min(2000, Math.max(0, remaining)))?.slice(0, 500); + if (text === undefined) continue; + const message = { level: entry.level, text }; + const bytes = encoder.encode(JSON.stringify(message)).byteLength + 1; + if (bytes > remaining) break; + messages.push(message); + remaining -= bytes; + } + output.consoleMessages = messages; + } + if (Array.isArray(data.missingImages)) { + const images: string[] = []; + for (const entry of data.missingImages.slice(0, 24)) { + const path = boundedText(entry, Math.min(1024, Math.max(0, remaining))); + if (path === undefined) continue; + const bytes = encoder.encode(JSON.stringify(path)).byteLength + 1; + if (bytes > remaining) break; + images.push(path); + remaining -= bytes; + } + output.missingImages = images; + } + return output; +} + +export interface HtmlToolProjection { + readonly toolName: TToolName; + readonly input: unknown; + readonly output?: unknown; +} + +/** Normalizes only own HTML calls at adapter boundaries, keeping HTML and PNG bytes off the wire. */ +export function compactHtmlToolProjection( + projection: HtmlToolProjection, +): HtmlToolProjection { + const tool = resolvePathwayMcpToolId(projection.toolName); + if (tool !== "html_render" && tool !== "html_preview") return projection; + const normalized = { toolName: `pathway.${tool}`, input: compactInput(tool, projection.input) }; + if (projection.output === undefined) return normalized; + const { data, isError } = readToolOutput(projection.output); + const message = boundedText(data?.message) ?? boundedText(data?.error) ?? ""; + if (isError) + return { + ...normalized, + output: { isError: true, message: boundedJsonText(message, MAX_METADATA_BYTES - 100) }, + }; + if (tool === "html_preview") return { ...normalized, output: compactPreview(data) }; + const htmlRender = readHtmlRenderReference(data?.htmlRender); + if (htmlRender === undefined) { + return { + ...normalized, + output: { + isError: true, + message: boundedJsonText( + message || "Invalid HTML render result.", + MAX_METADATA_BYTES - 100, + ), + }, + }; + } + const referenceBytes = encoder.encode(JSON.stringify({ htmlRender, message: "" })).byteLength; + return { + ...normalized, + output: { htmlRender, message: boundedJsonText(message, MAX_METADATA_BYTES - referenceBytes) }, + }; +}