Skip to content

Commit 368941a

Browse files
100yenadminEvasteipete
authored
feat(workspaces): add secure preview widget (#101354)
* feat(workspaces): add secure preview widget * fix(workspaces): type preview reload assertion * fix(workspaces): harden preview widget * fix(workspaces): honor preview URL binding ownership * chore(workspaces): remove unused widget exports --------- Co-authored-by: Eva <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 8fef264 commit 368941a

77 files changed

Lines changed: 1116 additions & 100 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/web/workspaces.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ collapse it, hide it, or delete it.
3838

3939
## Built-in widgets
4040

41-
Ten trusted widgets ship with the plugin and render as first-party UI:
41+
Eleven trusted widgets ship with the plugin and render as first-party UI:
4242

4343
`stat-card`, `markdown`, `table`, `iframe-embed`, `sessions`, `usage`, `cron`,
44-
`instances`, `activity`, `chart`.
44+
`instances`, `activity`, `chart`, `preview`.
4545

4646
The `chart` widget renders dependency-free inline SVG as `line`, `bar`, `area`,
4747
`sparkline`, or `gauge`. Bind `value` to a numeric array or to an object shaped like
@@ -50,6 +50,13 @@ visual and optionally set finite numeric `props.min` and `props.max` bounds. Inv
5050
types, bounds, points, and series longer than 500 entries render a safe error state;
5151
empty series render an empty state.
5252

53+
The `preview` widget embeds a live page with reload plus desktop, tablet, and mobile
54+
viewport controls. Set `props.url`, or bind `value` to a URL when the preview target is
55+
data-driven; a binding takes precedence over the prop. Relative and same-origin HTTP(S)
56+
URLs are allowed. External HTTP(S) URLs follow the gateway's external-embed policy, and
57+
other schemes are blocked. Preview frames share the `iframe-embed` sandbox ceiling and
58+
never receive same-origin access.
59+
5360
Widgets declare data through **bindings**, they never fetch on their own:
5461

5562
| Binding | Resolves to |

extensions/workspaces/src/schema.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ describe("Workspaces document schema", () => {
4848
expect(validateWorkspaceDoc(doc).tabs[0]!.widgets[0]!.kind).toBe("builtin:chart");
4949
});
5050

51+
it("accepts the trusted builtin preview kind", () => {
52+
const doc = validDoc();
53+
doc.tabs[0]!.widgets[0]!.kind = "builtin:preview";
54+
expect(validateWorkspaceDoc(doc).tabs[0]!.widgets[0]!.kind).toBe("builtin:preview");
55+
});
56+
5157
it("rejects a prototype-setter custom widget kind", () => {
5258
expectInvalid((doc) => {
5359
doc.tabs[0]!.widgets[0]!.kind = "custom:__proto__";

extensions/workspaces/src/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const BUILTIN_WIDGET_KINDS = [
6565
"builtin:markdown",
6666
"builtin:table",
6767
"builtin:iframe-embed",
68+
"builtin:preview",
6869
"builtin:sessions",
6970
"builtin:usage",
7071
"builtin:cron",

extensions/workspaces/src/tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const WIDGET_KIND_DESCRIPTION = [
5959
"builtin:markdown (props {markdown} or {text}, or a file binding of a .md file),",
6060
"builtin:table (binding id `rows`; props {columns: string[]}),",
6161
"builtin:iframe-embed (props {url}),",
62+
"builtin:preview (props {url, defaultViewport?: desktop|tablet|mobile}, or binding id `value`; live sandboxed preview with reload and viewport controls),",
6263
"builtin:sessions, builtin:usage, builtin:cron, builtin:instances, builtin:activity",
6364
"(each reads its own rpc binding; see workspace_get for a worked example).",
6465
"builtin:chart (binding id `value`; props {type?: line|bar|area|sparkline|gauge, min?: number, max?: number}; value is number[] or {points: Array<number|{y|value}>}).",

ui/src/components/workspace-widget-cell.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
const BUILTIN_CONTEXT: BuiltinWidgetContext = {
1313
basePath: "",
1414
embed: { embedSandboxMode: "strict", allowExternalEmbedUrls: false },
15+
preview: { getViewport: (_widgetId, fallback) => fallback, setViewport: vi.fn() },
1516
};
1617

1718
function callbacks(): WorkspaceWidgetCellCallbacks {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { mkdir } from "node:fs/promises";
2+
import path from "node:path";
3+
import { chromium, type Browser, type Page } from "playwright";
4+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
5+
import {
6+
canRunPlaywrightChromium,
7+
installMockGateway,
8+
resolvePlaywrightChromiumExecutablePath,
9+
startControlUiE2eServer,
10+
type ControlUiE2eServer,
11+
} from "../test-helpers/control-ui-e2e.ts";
12+
13+
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
14+
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
15+
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
16+
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
17+
const proofDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
18+
19+
let browser: Browser;
20+
let server: ControlUiE2eServer;
21+
22+
function workspaceDoc() {
23+
return {
24+
doc: {
25+
schemaVersion: 1,
26+
workspaceVersion: 1,
27+
tabs: [
28+
{
29+
slug: "previews",
30+
title: "Secure previews",
31+
hidden: false,
32+
createdBy: "agent:main",
33+
widgets: [
34+
{
35+
id: "preview_allowed",
36+
kind: "builtin:preview",
37+
title: "Local app preview",
38+
grid: { x: 0, y: 0, w: 8, h: 5 },
39+
collapsed: false,
40+
createdBy: "agent:main",
41+
props: { url: "/stale", defaultViewport: "desktop" },
42+
bindings: { value: { source: "static", value: "/preview-proof" } },
43+
},
44+
{
45+
id: "preview_blocked",
46+
kind: "builtin:preview",
47+
title: "Blocked external preview",
48+
grid: { x: 8, y: 0, w: 4, h: 5 },
49+
collapsed: false,
50+
createdBy: "agent:main",
51+
props: { url: "https://blocked.example" },
52+
},
53+
],
54+
},
55+
],
56+
widgetsRegistry: {},
57+
prefs: { tabOrder: ["previews"] },
58+
},
59+
workspaceVersion: 1,
60+
};
61+
}
62+
63+
async function capture(page: Page, name: string): Promise<void> {
64+
if (!proofDir) {
65+
return;
66+
}
67+
await mkdir(proofDir, { recursive: true });
68+
await page.screenshot({ fullPage: true, path: path.join(proofDir, name) });
69+
}
70+
71+
describeControlUiE2e("Control UI secure preview widget", () => {
72+
beforeAll(async () => {
73+
if (!chromiumAvailable) {
74+
throw new Error(`Playwright Chromium is not available at ${chromiumExecutablePath}`);
75+
}
76+
server = await startControlUiE2eServer();
77+
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
78+
});
79+
80+
afterAll(async () => {
81+
await browser?.close();
82+
await server?.close();
83+
});
84+
85+
it("renders an allowed bound origin, blocks an external origin, and preserves controls", async () => {
86+
const context = await browser.newContext({
87+
locale: "en-US",
88+
serviceWorkers: "block",
89+
viewport: { height: 900, width: 1440 },
90+
});
91+
const page = await context.newPage();
92+
let previewRequests = 0;
93+
await page.route("**/preview-proof", async (route) => {
94+
previewRequests += 1;
95+
await route.fulfill({
96+
contentType: "text/html",
97+
body: `<!doctype html><style>body{margin:0;font:24px system-ui;background:#13293d;color:#fff;display:grid;place-items:center;height:100vh}strong{color:#62d9ff}</style><p><strong>Allowed</strong> same-origin preview</p>`,
98+
});
99+
});
100+
await installMockGateway(page, {
101+
controlUiTabs: [
102+
{
103+
pluginId: "workspaces",
104+
id: "workspaces",
105+
label: "Workspaces",
106+
group: "control",
107+
},
108+
],
109+
featureMethods: ["workspaces.get"],
110+
methodResponses: { "workspaces.get": workspaceDoc() },
111+
});
112+
113+
try {
114+
const response = await page.goto(`${server.baseUrl}plugin?plugin=workspaces&id=workspaces`);
115+
expect(response?.status()).toBe(200);
116+
const allowed = page.locator('[data-widget-id="preview_allowed"]');
117+
const blocked = page.locator('[data-widget-id="preview_blocked"]');
118+
const frame = allowed.locator('[data-test-id="workspace-preview-frame"]');
119+
await frame.waitFor({ timeout: 10_000 });
120+
await blocked.locator('[data-test-id="workspace-preview-blocked"]').waitFor();
121+
expect(await frame.getAttribute("src")).toBe("/preview-proof");
122+
expect(await frame.getAttribute("sandbox")).toBe("allow-scripts");
123+
expect(await frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
124+
expect(await blocked.locator('[data-test-id="workspace-preview-frame"]').count()).toBe(0);
125+
await expect.poll(() => previewRequests).toBeGreaterThan(0);
126+
await capture(page, "workspace-preview-allowed-and-blocked.png");
127+
128+
await allowed.locator('[data-test-id="workspace-preview-viewport-mobile"]').click();
129+
await expect
130+
.poll(() => allowed.locator(".workspace-preview__frame-wrap--mobile").count())
131+
.toBe(1);
132+
expect(
133+
await allowed
134+
.locator('[data-test-id="workspace-preview-viewport-mobile"]')
135+
.getAttribute("aria-pressed"),
136+
).toBe("true");
137+
await capture(page, "workspace-preview-mobile.png");
138+
139+
const requestsBeforeReload = previewRequests;
140+
await allowed.locator('[data-test-id="workspace-preview-reload"]').click();
141+
await expect.poll(() => previewRequests).toBeGreaterThan(requestsBeforeReload);
142+
expect(await allowed.locator(".workspace-preview__frame-wrap--mobile").count()).toBe(1);
143+
} finally {
144+
await context.close();
145+
}
146+
});
147+
});

ui/src/i18n/.i18n/ar.meta.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)