Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
427 changes: 211 additions & 216 deletions docs/specs/dor-iframe.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,13 @@ export function Wall({
}, []);

useEffect(() => {
const handleBlur = () => clearSessionAttention();
// An iframe surface taking focus blurs this window without backgrounding the
// app (document.hasFocus() stays true). Only clear cross-session attention
// on a real blur, else focusing an iframe wipes attention (spec → "#2").
const handleBlur = () => {
if (document.hasFocus()) return;
clearSessionAttention();
};
window.addEventListener('blur', handleBlur);
return () => window.removeEventListener('blur', handleBlur);
}, []);
Expand Down
191 changes: 152 additions & 39 deletions lib/src/components/wall/IframePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useContext, useEffect, useRef, useState } from 'react';
import type { IDockviewPanelProps } from 'dockview-react';
import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../design';
import { getPlatform } from '../../lib/platform';
import { registerProxyOrigin } from '../../lib/iframe-proxy-registry';
import { registerSurfaceFocusHandle } from '../../lib/terminal-registry';
import type { IframeProxyResult } from '../../lib/platform/types';
import { usePaneChrome } from './use-pane-chrome';
import { WallActionsContext } from './wall-context';

Expand All @@ -9,62 +13,171 @@ type IframePanelParams = {
url?: string;
};

// Sandbox the proxied frame so a tool's `if (top !== self) top.location = …`
// framebust cannot navigate the Wall away — allow-top-navigation is omitted on
// purpose (docs/specs/dor-iframe.md → "Anti-framebust"). Everything else a local
// dev tool needs is granted; allow-same-origin is safe here because the frame's
// origin (the loopback proxy) is never same-origin with the host webview.
const PROXY_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads';
const IFRAME_ALLOW = 'autoplay; clipboard-read; clipboard-write; fullscreen; geolocation; microphone; camera';

type Resolution =
| { kind: 'empty' }
| { kind: 'resolving' }
| { kind: 'proxied'; src: string; origin: string }
// The host can't run a proxy (e.g. the web host) — keep the blind raw-iframe
// fallback rather than hiding the surface (Open Decision #4).
| { kind: 'raw'; src: string }
| { kind: 'error'; reason: 'frame-refused' | 'unreachable' | 'scheme'; detail?: string };

function originOf(url: string): string {
try {
return new URL(url).origin;
} catch {
return '';
}
}

export function IframePanel({ api, params }: IDockviewPanelProps<IframePanelParams>) {
const actions = useContext(WallActionsContext);
const elRef = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
usePaneChrome(api, elRef);
const url = typeof params?.url === 'string' ? params.url : '';
const origin = useMemo(() => {
try {
return url ? new URL(url).origin : '';
} catch {
return '';

// Ask the host to front the target with its transparent proxy. The returned
// URL is a loopback origin that serves the page's bytes (instrumented for
// loopback) so Dormouse — now the server — gets a keyboard side-channel, an
// accurate focus model, and real error pages. Reachability/frame-refusal are
// diagnosed by the proxy and shown as a served page inside the frame.
const [resolution, setResolution] = useState<Resolution>(() => (url ? { kind: 'resolving' } : { kind: 'empty' }));
useEffect(() => {
if (!url) {
setResolution({ kind: 'empty' });
return;
}
const createProxy = getPlatform().createIframeProxyUrl;
if (!createProxy) {
setResolution({ kind: 'raw', src: url });
return;
}
let cancelled = false;
setResolution({ kind: 'resolving' });
createProxy(url).then(
(result: IframeProxyResult) => {
if (cancelled) return;
if (result.ok) setResolution({ kind: 'proxied', src: result.url, origin: originOf(result.url) });
else setResolution({ kind: 'error', reason: result.reason, detail: result.detail });
},
() => {
if (!cancelled) setResolution({ kind: 'error', reason: 'unreachable' });
},
);
return () => { cancelled = true; };
}, [url]);

// A cross-origin iframe never reports HTTP errors or CSP/X-Frame-Options
// blocks to us — onError doesn't fire, and onLoad fires even for a blocked
// frame. So we can't detect failure directly; instead we surface a hint if
// the frame hasn't reported a load within a few seconds, which covers the
// common dead ends (server down, wrong scheme, refused framing) without
// hiding a slow-but-fine page once it loads.
const [loaded, setLoaded] = useState(false);
const [stalled, setStalled] = useState(false);
// Trust postMessage from this frame's origin (validated by the Wall's
// keyboard/focus channel) only while the proxied surface is live.
const proxyOrigin = resolution.kind === 'proxied' ? resolution.origin : null;
useEffect(() => {
setLoaded(false);
setStalled(false);
if (!url) return;
const timer = setTimeout(() => setStalled(true), 5000);
return () => clearTimeout(timer);
}, [url]);
if (!proxyOrigin) return;
return registerProxyOrigin(proxyOrigin);
}, [proxyOrigin]);

// Register a focus handle so onClickPanel → enterTerminalMode can focus the
// frame like any other surface (spec → "#3"). Focusing the element moves
// keyboard focus into the frame; the shim then reports focus back to the Wall.
useEffect(() => {
if (resolution.kind !== 'proxied' && resolution.kind !== 'raw') return;
return registerSurfaceFocusHandle(api.id, {
focus: () => iframeRef.current?.focus(),
blur: () => iframeRef.current?.blur(),
});
}, [api.id, resolution.kind]);

// Clicking *into* a cross-origin frame doesn't bubble a mousedown to the pane,
// so the onMouseDown below never fires and the surface never enters
// passthrough. Detect the frame taking focus (window blurs while our iframe
// becomes activeElement, app still focused) and adopt it as entering the pane,
// so mode/selection stay consistent and the leader chord can round-trip out.
useEffect(() => {
if (resolution.kind !== 'proxied' && resolution.kind !== 'raw') return;
const onWindowBlur = () => {
if (document.hasFocus() && document.activeElement === iframeRef.current) {
actions.onClickPanel(api.id);
}
};
window.addEventListener('blur', onWindowBlur);
return () => window.removeEventListener('blur', onWindowBlur);
}, [api.id, resolution.kind, actions]);

const src = resolution.kind === 'proxied' || resolution.kind === 'raw' ? resolution.src : '';

return (
<div
ref={elRef}
className={`relative h-full w-full overflow-hidden bg-terminal-bg ${TERMINAL_BOTTOM_RADIUS_CLASS}`}
// A cross-origin iframe is an out-of-process frame; Chromium maps pointer
// events to it relative to its nearest compositing/containing ancestor.
// Dockview's root (.dv-dockview) sets `contain: layout`, so without this
// the frame's reference is that far-away root and clicks land offset by the
// pane's distance from it. translateZ(0) gives this container its own layer
// co-located with the frame, collapsing the offset to ~0. It's identity, so
// getBoundingClientRect (overlay measurement) is unaffected.
style={{ transform: 'translateZ(0)' }}
onMouseDown={() => actions.onClickPanel(api.id)}
>
{url ? (
<>
<iframe
className="block h-full w-full border-0 bg-white"
src={url}
title={api.title ?? url}
allow="autoplay; clipboard-read; clipboard-write; fullscreen; geolocation; microphone; camera"
referrerPolicy={origin ? 'strict-origin-when-cross-origin' : undefined}
onLoad={() => setLoaded(true)}
/>
{!loaded && stalled && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-terminal-bg/90 px-4 py-2 text-xs text-muted">
Still loading <span className="font-semibold">{url}</span> — if it stays blank, the server may be down, on a different scheme (http vs https), or refusing to be embedded in a frame.
</div>
)}
</>
{src ? (
<iframe
ref={iframeRef}
className="block h-full w-full border-0 bg-white"
src={src}
title={api.title ?? url}
allow={IFRAME_ALLOW}
{...(resolution.kind === 'proxied' ? { sandbox: PROXY_SANDBOX, 'data-dormouse-proxy': 'true' } : {})}
referrerPolicy="strict-origin-when-cross-origin"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-terminal-bg px-4 text-sm text-muted">
No iframe URL was provided.
</div>
<PanelMessage resolution={resolution} url={url} />
)}
</div>
);
}

function PanelMessage({ resolution, url }: { resolution: Resolution; url: string }) {
const base = 'flex h-full w-full items-center justify-center bg-terminal-bg px-6 text-center text-sm text-muted';

if (resolution.kind === 'resolving') {
return <div className={base}>Connecting to <span className="ml-1 font-semibold">{url}</span>…</div>;
}
if (resolution.kind === 'empty') {
return <div className={base}>No iframe URL was provided.</div>;
}
// proxied/raw render the iframe itself, never this fallback.
if (resolution.kind !== 'error') return null;
// 'error' — the proxy turned a dead end into something actionable. (Most
// unreachable/frame-refused cases are served as a page inside the frame; this
// covers the synchronous ones, chiefly an unproxyable scheme.)
return (
<div className={`${base} flex-col gap-2`}>
<div>{messageFor(resolution)}</div>
<div className="text-xs text-muted/80">
For arbitrary web pages, use <code className="rounded bg-app-bg px-1 py-0.5">dor ab open {url}</code>
</div>
</div>
);
}

function messageFor(resolution: Extract<Resolution, { kind: 'error' }>): string {
switch (resolution.reason) {
case 'scheme':
return resolution.detail
? `Can’t frame this URL — ${resolution.detail}.`
: 'The iframe surface only frames local http:// servers.';
case 'frame-refused':
return 'This page refuses to be embedded in a frame.';
case 'unreachable':
default:
return resolution.detail ? `Couldn’t reach the server — ${resolution.detail}.` : 'Couldn’t reach the server.';
}
}
20 changes: 19 additions & 1 deletion lib/src/components/wall/use-wall-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { handleMouseSelectionKeys } from './keyboard/handle-mouse-selection-keys
import { handleKillConfirm } from './keyboard/handle-kill-confirm';
import { handlePaneShortcuts } from './keyboard/handle-pane-shortcuts';
import { handlePaneNavigation } from './keyboard/handle-pane-navigation';
import { isProxyOrigin } from '../../lib/iframe-proxy-registry';
import type { NavHistoryRef, WallKeyboardCtx } from './keyboard/types';

export function useWallKeyboard(ctx: WallKeyboardCtx): void {
Expand Down Expand Up @@ -36,7 +37,24 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void {
handlePaneNavigation(e, c, navHistory);
};

// A focused cross-origin iframe owns the keyboard, so its keystrokes never
// reach the capturing window listener above. The proxy shim posts our
// reserved leader chord back out (docs/specs/dor-iframe.md → "The keyboard
// side-channel"); feed it into the same dispatch the in-document dual-tap
// would, after validating the message came from a live proxy origin.
const onMessage = (e: MessageEvent) => {
const data = e.data as { __dormouse?: unknown } | null;
if (!data || data.__dormouse !== 'leader') return;
if (!isProxyOrigin(e.origin)) return;
const c = ctxRef.current;
if (c.modeRef.current === 'passthrough') c.exitTerminalMode();
};

window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
window.addEventListener('message', onMessage);
return () => {
window.removeEventListener('keydown', handler, true);
window.removeEventListener('message', onMessage);
};
}, []);
}
7 changes: 6 additions & 1 deletion lib/src/components/wall/use-window-focused.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ export function useWindowFocused(): boolean {
const [focused, setFocused] = useState(() => document.hasFocus());
useEffect(() => {
const onFocus = () => setFocused(true);
const onBlur = () => setFocused(false);
// Focusing one of our own iframe surfaces fires `blur` on this window even
// though the app hasn't been backgrounded — the focused element is just an
// <iframe> *inside* this document, so `document.hasFocus()` stays true.
// Reading it instead of blindly setting false keeps headers/attention live
// when an iframe takes focus (docs/specs/dor-iframe.md → "#2").
const onBlur = () => setFocused(document.hasFocus());
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
return () => {
Expand Down
29 changes: 29 additions & 0 deletions lib/src/lib/iframe-proxy-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Tracks the loopback proxy origins of live iframe surfaces so the Wall's
* keyboard/focus channel can trust `postMessage` events from instrumented
* frames (docs/specs/dor-iframe.md → "The keyboard side-channel"). The shim we
* inject calls `parent.postMessage(...)`, which is cross-origin-safe by design;
* the Wall validates `event.origin` against this set before acting on a
* forwarded leader chord or focus/blur, so only a frame Dormouse itself served
* can drive those paths.
*
* Reference-counted because a surface can briefly re-register the same origin
* across a webview reload (mount of the new panel before unmount of the old).
*/
const proxyOriginCounts = new Map<string, number>();

export function registerProxyOrigin(origin: string): () => void {
proxyOriginCounts.set(origin, (proxyOriginCounts.get(origin) ?? 0) + 1);
let released = false;
return () => {
if (released) return;
released = true;
const next = (proxyOriginCounts.get(origin) ?? 1) - 1;
if (next <= 0) proxyOriginCounts.delete(origin);
else proxyOriginCounts.set(origin, next);
};
}

export function isProxyOrigin(origin: string): boolean {
return proxyOriginCounts.has(origin);
}
23 changes: 23 additions & 0 deletions lib/src/lib/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ export interface AgentBrowserEditResult {
error?: string;
}

/**
* Result of asking the host to front a `dor iframe` target with its transparent
* proxy (docs/specs/dor-iframe.md → "The Transparent Proxy"). On `ok` the panel
* points the `<iframe>` at `url` — a loopback proxy origin that fetches the
* target, strips frame-blocking headers (loopback only), and injects the
* Dormouse shim. On failure `reason` says why there is nothing to frame:
* `scheme` (not a proxyable `http://` upstream — e.g. an `https://` target,
* which v1 defers), `unreachable` (nothing answered), or `frame-refused` (a
* remote that forbids embedding — use `dor ab` instead). Reachability and
* frame-refusal are normally diagnosed lazily and surfaced as a served error
* *page* inside the frame, so v1 mostly returns `ok` or `scheme` here.
*/
export type IframeProxyResult =
| { ok: true; url: string }
| { ok: false; reason: 'frame-refused' | 'unreachable' | 'scheme'; detail?: string };

export interface PlatformAdapter {
// Lifecycle
init(): Promise<void>;
Expand Down Expand Up @@ -132,6 +148,13 @@ export interface PlatformAdapter {
// URL; absent or null falls back to ws://127.0.0.1:<port>.
getAgentBrowserStreamUrl?(port: number): Promise<string | null>;

// iframe surface support (see docs/specs/dor-iframe.md → "The Transparent
// Proxy"). Stands up a loopback proxy in front of a `dor iframe` target and
// returns the proxy URL the panel should frame, or a structured reason it
// could not. Absent on hosts with no process to run a proxy (e.g. the web
// host), where the panel falls back to a raw, uninstrumented `<iframe>`.
createIframeProxyUrl?(targetUrl: string): Promise<IframeProxyResult>;

// PTY event listeners
onPtyData(handler: (detail: { id: string; data: string }) => void): void;
offPtyData(handler: (detail: { id: string; data: string }) => void): void;
Expand Down
15 changes: 14 additions & 1 deletion lib/src/lib/platform/vscode-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserScreenshotResult, AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types';
import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserScreenshotResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyInfo } from './types';
import { OPEN_PORT_TIMEOUT_MS } from './types';
import { setDefaultShellOpts } from '../shell-defaults';
import {
Expand Down Expand Up @@ -32,6 +32,7 @@ export class VSCodeAdapter implements PlatformAdapter {
this.agentBrowserEdit = this.agentBrowserEdit.bind(this);
this.agentBrowserScreenshot = this.agentBrowserScreenshot.bind(this);
this.getAgentBrowserStreamUrl = this.getAgentBrowserStreamUrl.bind(this);
this.createIframeProxyUrl = this.createIframeProxyUrl.bind(this);

// Seed the default shell from the extension-injected global so that
// the first terminal on startup (which spawns synchronously on Wall
Expand Down Expand Up @@ -262,6 +263,18 @@ export class VSCodeAdapter implements PlatformAdapter {
);
}

async createIframeProxyUrl(url: string): Promise<IframeProxyResult> {
// The extension host stands up the loopback proxy and serves the bytes (see
// iframe-proxy-host.ts). On timeout, report unreachable so the panel shows a
// hint rather than hanging on a never-loading frame.
const result = await this.requestResponse<IframeProxyResult>(
'iframe:createProxyUrl', 'iframe:proxyUrl', { url },
(msg) => msg.result,
5000,
);
return result ?? { ok: false, reason: 'unreachable', detail: 'iframe proxy request timed out' };
}

onPtyData(handler: (detail: { id: string; data: string }) => void): void {
this.dataHandlers.add(handler);
}
Expand Down
Loading
Loading