@marimo-team/islands 0.23.3-dev4 → 0.23.3-dev42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chat-ui-CTt4WX0V.js → chat-ui-DEd_Ndal.js} +82 -82
- package/dist/{html-to-image-BdsDysfl.js → html-to-image-DBosi5GK.js} +2243 -2217
- package/dist/main.js +1104 -1099
- package/dist/{process-output-COL2Pf5I.js → process-output-k-4WHpxz.js} +1 -1
- package/dist/{reveal-component-Cd5Y35Ny.js → reveal-component-agH2Be6_.js} +2 -2
- package/dist/{slide-BEerfanN.js → slide-CoAyRjHI.js} +693 -574
- package/package.json +2 -2
- package/src/components/editor/file-tree/__tests__/requesting-tree.test.ts +84 -2
- package/src/components/editor/file-tree/file-explorer.tsx +142 -203
- package/src/components/editor/file-tree/file-name-input.tsx +41 -0
- package/src/components/editor/file-tree/file-operations.tsx +266 -0
- package/src/components/editor/file-tree/renderers.tsx +1 -1
- package/src/components/editor/file-tree/requesting-tree.tsx +68 -49
- package/src/components/editor/output/JsonOutput.tsx +157 -4
- package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
- package/src/components/editor/output/__tests__/json-output.test.ts +147 -2
- package/src/components/home/state.ts +13 -1
- package/src/components/pages/home-page.tsx +116 -10
- package/src/core/islands/__tests__/bridge.test.ts +116 -5
- package/src/core/islands/bridge.ts +5 -1
- package/src/core/network/requests-network.ts +0 -3
- package/src/core/static/__tests__/export-context.test.ts +122 -0
- package/src/core/static/__tests__/static-state.test.ts +80 -0
- package/src/core/static/export-context.ts +84 -0
- package/src/core/static/static-state.ts +44 -6
- package/src/plugins/core/RenderHTML.tsx +23 -2
- package/src/plugins/core/__test__/RenderHTML.test.ts +86 -1
- package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
- package/src/plugins/core/sanitize.ts +11 -5
- package/src/plugins/core/trusted-url.ts +32 -10
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
- package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
- package/src/utils/__tests__/path.test.ts +20 -0
- package/src/utils/pathUtils.test.ts +141 -1
- package/src/utils/pathUtils.ts +46 -0
- package/src/utils/paths.ts +9 -1
|
@@ -10,6 +10,7 @@ import { generateUUID } from "@/utils/uuid";
|
|
|
10
10
|
import type { CommandMessage, NotificationPayload } from "../kernel/messages";
|
|
11
11
|
import type { EditRequests, RunRequests } from "../network/types";
|
|
12
12
|
import { store as defaultStore } from "../state/jotai";
|
|
13
|
+
import { getMarimoExportContext } from "../static/export-context";
|
|
13
14
|
import { createMarimoFile, parseMarimoIslandApps } from "./parse";
|
|
14
15
|
import { islandsInitializedAtom } from "./state";
|
|
15
16
|
import type { WorkerSchema } from "./worker/worker";
|
|
@@ -123,8 +124,11 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
123
124
|
`Starting sessions for ${apps.length} app(s):`,
|
|
124
125
|
apps.map((a) => `${a.id} (${a.cells.length} cells)`),
|
|
125
126
|
);
|
|
127
|
+
const exportContext =
|
|
128
|
+
apps.length === 1 ? getMarimoExportContext() : undefined;
|
|
129
|
+
const notebookCode = exportContext?.notebookCode;
|
|
126
130
|
for (const app of apps) {
|
|
127
|
-
const file = createMarimoFile(app);
|
|
131
|
+
const file = notebookCode || createMarimoFile(app);
|
|
128
132
|
Logger.debug(`App ${app.id} marimo file:\n`, file);
|
|
129
133
|
this.startSession({
|
|
130
134
|
code: file,
|
|
@@ -305,7 +305,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
305
305
|
.then(handleResponse);
|
|
306
306
|
},
|
|
307
307
|
sendDeleteFileOrFolder: async (request) => {
|
|
308
|
-
await waitForConnectionOpen();
|
|
309
308
|
return getClient()
|
|
310
309
|
.POST("/api/files/delete", {
|
|
311
310
|
body: request,
|
|
@@ -313,7 +312,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
313
312
|
.then(handleResponse);
|
|
314
313
|
},
|
|
315
314
|
sendCopyFileOrFolder: async (request) => {
|
|
316
|
-
await waitForConnectionOpen();
|
|
317
315
|
return getClient()
|
|
318
316
|
.POST("/api/files/copy", {
|
|
319
317
|
body: request,
|
|
@@ -321,7 +319,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
321
319
|
.then(handleResponse);
|
|
322
320
|
},
|
|
323
321
|
sendRenameFileOrFolder: async (request) => {
|
|
324
|
-
await waitForConnectionOpen();
|
|
325
322
|
return getClient()
|
|
326
323
|
.POST("/api/files/move", {
|
|
327
324
|
body: request,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
|
|
4
|
+
import type { ExtractAtomValue } from "jotai";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
7
|
+
import { userConfigAtom } from "@/core/config/config";
|
|
8
|
+
import { parseUserConfig } from "@/core/config/config-schema";
|
|
9
|
+
import { initialModeAtom } from "@/core/mode";
|
|
10
|
+
import { store } from "@/core/state/jotai";
|
|
11
|
+
import {
|
|
12
|
+
getMarimoExportContext,
|
|
13
|
+
hasTrustedExportContext,
|
|
14
|
+
hasTrustedNotebookContext,
|
|
15
|
+
} from "../export-context";
|
|
16
|
+
|
|
17
|
+
type ExportContextWindow = Window & {
|
|
18
|
+
__MARIMO_EXPORT_CONTEXT__?: {
|
|
19
|
+
trusted: boolean;
|
|
20
|
+
notebookCode?: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function setAutoInstantiate(value: boolean) {
|
|
25
|
+
const cleared = parseUserConfig({});
|
|
26
|
+
store.set(userConfigAtom, {
|
|
27
|
+
...cleared,
|
|
28
|
+
runtime: { ...cleared.runtime, auto_instantiate: value },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("hasTrustedNotebookContext", () => {
|
|
33
|
+
let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
|
|
34
|
+
let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
|
|
35
|
+
let previousMode: ExtractAtomValue<typeof initialModeAtom>;
|
|
36
|
+
let w: ExportContextWindow;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
w = window as ExportContextWindow;
|
|
40
|
+
previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
|
|
41
|
+
previousConfig = store.get(userConfigAtom);
|
|
42
|
+
previousMode = store.get(initialModeAtom);
|
|
43
|
+
store.set(hasRunAnyCellAtom, false);
|
|
44
|
+
setAutoInstantiate(false);
|
|
45
|
+
store.set(initialModeAtom, "edit");
|
|
46
|
+
delete w.__MARIMO_EXPORT_CONTEXT__;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
|
|
51
|
+
store.set(userConfigAtom, previousConfig);
|
|
52
|
+
store.set(initialModeAtom, previousMode);
|
|
53
|
+
delete w.__MARIMO_EXPORT_CONTEXT__;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns false in untrusted edit mode before interaction", () => {
|
|
57
|
+
expect(hasTrustedNotebookContext()).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns true once the user has run a cell", () => {
|
|
61
|
+
store.set(hasRunAnyCellAtom, true);
|
|
62
|
+
expect(hasTrustedNotebookContext()).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns true when a trusted export context is installed", () => {
|
|
66
|
+
w.__MARIMO_EXPORT_CONTEXT__ = { trusted: true };
|
|
67
|
+
expect(hasTrustedNotebookContext()).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns true when auto_instantiate is enabled", () => {
|
|
71
|
+
setAutoInstantiate(true);
|
|
72
|
+
expect(hasTrustedNotebookContext()).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns true in read mode", () => {
|
|
76
|
+
store.set(initialModeAtom, "read");
|
|
77
|
+
expect(hasTrustedNotebookContext()).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns false if initialMode throws (config not yet applied)", () => {
|
|
81
|
+
store.set(initialModeAtom, undefined);
|
|
82
|
+
expect(hasTrustedNotebookContext()).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("hasTrustedExportContext / getMarimoExportContext shape validation", () => {
|
|
87
|
+
let w: ExportContextWindow;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
w = window as ExportContextWindow;
|
|
91
|
+
delete w.__MARIMO_EXPORT_CONTEXT__;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
delete w.__MARIMO_EXPORT_CONTEXT__;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("accepts a valid context", () => {
|
|
99
|
+
w.__MARIMO_EXPORT_CONTEXT__ = { trusted: true, notebookCode: "x = 1" };
|
|
100
|
+
expect(hasTrustedExportContext()).toBe(true);
|
|
101
|
+
expect(getMarimoExportContext()).toEqual({
|
|
102
|
+
trusted: true,
|
|
103
|
+
notebookCode: "x = 1",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects a context where `trusted` is not exactly true", () => {
|
|
108
|
+
w.__MARIMO_EXPORT_CONTEXT__ = {
|
|
109
|
+
trusted: "yes" as unknown as true,
|
|
110
|
+
};
|
|
111
|
+
expect(hasTrustedExportContext()).toBe(false);
|
|
112
|
+
expect(getMarimoExportContext()).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("rejects a context with non-string notebookCode", () => {
|
|
116
|
+
w.__MARIMO_EXPORT_CONTEXT__ = {
|
|
117
|
+
trusted: true,
|
|
118
|
+
notebookCode: 42 as unknown as string,
|
|
119
|
+
};
|
|
120
|
+
expect(getMarimoExportContext()).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
getStaticModelNotifications,
|
|
7
|
+
getStaticVirtualFiles,
|
|
8
|
+
isStaticNotebook,
|
|
9
|
+
} from "../static-state";
|
|
10
|
+
|
|
11
|
+
function setMarimoStatic(value: unknown): void {
|
|
12
|
+
(window as unknown as { __MARIMO_STATIC__?: unknown }).__MARIMO_STATIC__ =
|
|
13
|
+
value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clearMarimoStatic(): void {
|
|
17
|
+
delete (window as unknown as { __MARIMO_STATIC__?: unknown })
|
|
18
|
+
.__MARIMO_STATIC__;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("static-state shape validation", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
clearMarimoStatic();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
clearMarimoStatic();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("treats an absent global as not-a-static-notebook", () => {
|
|
31
|
+
expect(isStaticNotebook()).toBe(false);
|
|
32
|
+
expect(getStaticModelNotifications()).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts a well-formed state", () => {
|
|
36
|
+
setMarimoStatic({
|
|
37
|
+
files: { "/@file/a.txt": "data:text/plain;base64,YQ==" },
|
|
38
|
+
modelNotifications: [],
|
|
39
|
+
});
|
|
40
|
+
expect(isStaticNotebook()).toBe(true);
|
|
41
|
+
expect(getStaticVirtualFiles()).toEqual({
|
|
42
|
+
"/@file/a.txt": "data:text/plain;base64,YQ==",
|
|
43
|
+
});
|
|
44
|
+
expect(getStaticModelNotifications()).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects a malformed global (missing files)", () => {
|
|
48
|
+
setMarimoStatic({ modelNotifications: [] });
|
|
49
|
+
expect(isStaticNotebook()).toBe(false);
|
|
50
|
+
expect(getStaticModelNotifications()).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects a malformed global (non-array modelNotifications)", () => {
|
|
54
|
+
setMarimoStatic({ files: {}, modelNotifications: "oops" });
|
|
55
|
+
expect(isStaticNotebook()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects a non-object global", () => {
|
|
59
|
+
setMarimoStatic("pwned");
|
|
60
|
+
expect(isStaticNotebook()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects an array as the state", () => {
|
|
64
|
+
setMarimoStatic([]);
|
|
65
|
+
expect(isStaticNotebook()).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects files when it is an array", () => {
|
|
69
|
+
setMarimoStatic({ files: [], modelNotifications: [] });
|
|
70
|
+
expect(isStaticNotebook()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects files that contain non-string values", () => {
|
|
74
|
+
setMarimoStatic({
|
|
75
|
+
files: { "/@file/a.txt": 42 },
|
|
76
|
+
modelNotifications: [],
|
|
77
|
+
});
|
|
78
|
+
expect(isStaticNotebook()).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
4
|
+
import { autoInstantiateAtom } from "@/core/config/config";
|
|
5
|
+
import { getInitialAppMode } from "@/core/mode";
|
|
6
|
+
import { store } from "@/core/state/jotai";
|
|
7
|
+
|
|
8
|
+
export interface MarimoExportContext {
|
|
9
|
+
trusted: true;
|
|
10
|
+
notebookCode?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
interface Window {
|
|
15
|
+
__MARIMO_EXPORT_CONTEXT__?: Readonly<MarimoExportContext>;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isMarimoExportContext(
|
|
20
|
+
value: unknown,
|
|
21
|
+
): value is Readonly<MarimoExportContext> {
|
|
22
|
+
if (typeof value !== "object" || value === null) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const candidate = value as MarimoExportContext;
|
|
27
|
+
if (candidate.trusted !== true) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (
|
|
31
|
+
candidate.notebookCode !== undefined &&
|
|
32
|
+
typeof candidate.notebookCode !== "string"
|
|
33
|
+
) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getMarimoExportContext():
|
|
40
|
+
| Readonly<MarimoExportContext>
|
|
41
|
+
| undefined {
|
|
42
|
+
const context = window?.__MARIMO_EXPORT_CONTEXT__;
|
|
43
|
+
return isMarimoExportContext(context) ? context : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasTrustedExportContext(): boolean {
|
|
47
|
+
return getMarimoExportContext()?.trusted === true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when the current page is a context where notebook-authored script
|
|
52
|
+
* execution is expected, and therefore the user has consented (explicitly or
|
|
53
|
+
* by the nature of the page) to running arbitrary notebook content:
|
|
54
|
+
*
|
|
55
|
+
* - the user has run at least one cell, OR
|
|
56
|
+
* - a first-party exported notebook page installed a trusted export context
|
|
57
|
+
* (islands / static exports / Quarto islands), OR
|
|
58
|
+
* - `auto_instantiate` is enabled (the notebook runs on page load by user
|
|
59
|
+
* configuration), OR
|
|
60
|
+
* - the page was loaded in `read` / app mode (served by marimo as an app).
|
|
61
|
+
*
|
|
62
|
+
* Edit mode before any user interaction is intentionally NOT trusted — that
|
|
63
|
+
* is the only surface where we must prevent notebook-authored content from
|
|
64
|
+
* loading scripts or bypassing HTML sanitization.
|
|
65
|
+
*/
|
|
66
|
+
export function hasTrustedNotebookContext(): boolean {
|
|
67
|
+
if (store.get(hasRunAnyCellAtom)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
if (hasTrustedExportContext()) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (store.get(autoInstantiateAtom)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
if (getInitialAppMode() === "read") {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// getInitialAppMode throws before mount config is applied; treat as untrusted.
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
@@ -5,20 +5,58 @@ import type { MarimoStaticState, StaticVirtualFiles } from "./types";
|
|
|
5
5
|
|
|
6
6
|
declare global {
|
|
7
7
|
interface Window {
|
|
8
|
-
__MARIMO_STATIC__?: MarimoStaticState
|
|
8
|
+
__MARIMO_STATIC__?: Readonly<MarimoStaticState>;
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function isStringToStringRecord(
|
|
13
|
+
value: unknown,
|
|
14
|
+
): value is Record<string, string> {
|
|
15
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
for (const entry of Object.values(value)) {
|
|
19
|
+
if (typeof entry !== "string") {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMarimoStaticState(
|
|
27
|
+
value: unknown,
|
|
28
|
+
): value is Readonly<MarimoStaticState> {
|
|
29
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const candidate = value as MarimoStaticState;
|
|
33
|
+
if (!isStringToStringRecord(candidate.files)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
candidate.modelNotifications !== undefined &&
|
|
38
|
+
!Array.isArray(candidate.modelNotifications)
|
|
39
|
+
) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getMarimoStaticState(): Readonly<MarimoStaticState> | undefined {
|
|
46
|
+
const state = window?.__MARIMO_STATIC__;
|
|
47
|
+
return isMarimoStaticState(state) ? state : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
export function isStaticNotebook(): boolean {
|
|
13
|
-
return
|
|
51
|
+
return getMarimoStaticState() !== undefined;
|
|
14
52
|
}
|
|
15
53
|
|
|
16
54
|
export function getStaticVirtualFiles(): StaticVirtualFiles {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return
|
|
55
|
+
const state = getMarimoStaticState();
|
|
56
|
+
invariant(state !== undefined, "Not a static notebook");
|
|
57
|
+
return state.files;
|
|
20
58
|
}
|
|
21
59
|
|
|
22
60
|
export function getStaticModelNotifications(): ModelLifecycle[] | undefined {
|
|
23
|
-
return
|
|
61
|
+
return getMarimoStaticState()?.modelNotifications;
|
|
24
62
|
}
|
|
@@ -16,6 +16,8 @@ import { CopyClipboardIcon } from "@/components/icons/copy-icon";
|
|
|
16
16
|
import { QueryParamPreservingLink } from "@/components/ui/query-param-preserving-link";
|
|
17
17
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
18
18
|
import { DocHoverTarget } from "@/core/documentation/DocHoverTarget";
|
|
19
|
+
import { hasTrustedNotebookContext } from "@/core/static/export-context";
|
|
20
|
+
import { Logger } from "@/utils/Logger";
|
|
19
21
|
import { sanitizeHtml, useSanitizeHtml } from "./sanitize";
|
|
20
22
|
|
|
21
23
|
type ReplacementFn = NonNullable<HTMLReactParserOptions["replace"]>;
|
|
@@ -98,8 +100,27 @@ const replaceSrcScripts = (domNode: DOMNode): JSX.Element | undefined => {
|
|
|
98
100
|
if (!src) {
|
|
99
101
|
return;
|
|
100
102
|
}
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
+
// Only append notebook-authored scripts when the page is a trusted
|
|
104
|
+
// context (the user has run a cell, the page is a trusted export, or
|
|
105
|
+
// we're running in read/app mode). In untrusted edit mode before any
|
|
106
|
+
// user interaction, drop the script and log a warning. Outer
|
|
107
|
+
// sanitization will normally strip <script> tags already; this is
|
|
108
|
+
// defense-in-depth for flows that reparse children with
|
|
109
|
+
// alwaysSanitizeHtml: false (see registerReactComponent.getChildren).
|
|
110
|
+
if (!hasTrustedNotebookContext()) {
|
|
111
|
+
Logger.warn(
|
|
112
|
+
`[RenderHTML] refusing <script src> in untrusted context: ${src}`,
|
|
113
|
+
);
|
|
114
|
+
// oxlint-disable-next-line react/jsx-no-useless-fragment
|
|
115
|
+
return <></>;
|
|
116
|
+
}
|
|
117
|
+
// Check if script already exists. Avoid building a CSS selector from
|
|
118
|
+
// notebook-provided input, which can throw for valid URLs containing
|
|
119
|
+
// selector-significant characters (e.g. IPv6 hosts with `[`/`]`).
|
|
120
|
+
const scriptExists = [...document.querySelectorAll("script[src]")].some(
|
|
121
|
+
(existingScript) => existingScript.getAttribute("src") === src,
|
|
122
|
+
);
|
|
123
|
+
if (!scriptExists) {
|
|
103
124
|
const script = document.createElement("script");
|
|
104
125
|
script.src = src;
|
|
105
126
|
document.head.append(script);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import {
|
|
2
|
+
import type { ExtractAtomValue } from "jotai";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
4
|
+
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
5
|
+
import { userConfigAtom } from "@/core/config/config";
|
|
6
|
+
import { parseUserConfig } from "@/core/config/config-schema";
|
|
7
|
+
import { initialModeAtom } from "@/core/mode";
|
|
8
|
+
import { store } from "@/core/state/jotai";
|
|
3
9
|
import { visibleForTesting } from "../RenderHTML";
|
|
4
10
|
|
|
5
11
|
const { parseHtml } = visibleForTesting;
|
|
@@ -197,6 +203,85 @@ describe("parseHtml", () => {
|
|
|
197
203
|
});
|
|
198
204
|
});
|
|
199
205
|
|
|
206
|
+
describe("replaceSrcScripts trust gate", () => {
|
|
207
|
+
let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
|
|
208
|
+
let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
|
|
209
|
+
let previousMode: ExtractAtomValue<typeof initialModeAtom>;
|
|
210
|
+
const windowWithExport = window as Window & {
|
|
211
|
+
__MARIMO_EXPORT_CONTEXT__?: unknown;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function clearTrustSignals() {
|
|
215
|
+
const cleared = parseUserConfig({});
|
|
216
|
+
store.set(hasRunAnyCellAtom, false);
|
|
217
|
+
store.set(userConfigAtom, {
|
|
218
|
+
...cleared,
|
|
219
|
+
runtime: { ...cleared.runtime, auto_instantiate: false },
|
|
220
|
+
});
|
|
221
|
+
store.set(initialModeAtom, "edit");
|
|
222
|
+
delete windowWithExport.__MARIMO_EXPORT_CONTEXT__;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
|
|
227
|
+
previousConfig = store.get(userConfigAtom);
|
|
228
|
+
previousMode = store.get(initialModeAtom);
|
|
229
|
+
clearTrustSignals();
|
|
230
|
+
for (const s of document.head.querySelectorAll(
|
|
231
|
+
'script[src^="https://cdn.example.com/"]',
|
|
232
|
+
)) {
|
|
233
|
+
s.remove();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
|
|
239
|
+
store.set(userConfigAtom, previousConfig);
|
|
240
|
+
store.set(initialModeAtom, previousMode);
|
|
241
|
+
delete windowWithExport.__MARIMO_EXPORT_CONTEXT__;
|
|
242
|
+
for (const s of document.head.querySelectorAll(
|
|
243
|
+
'script[src^="https://cdn.example.com/"]',
|
|
244
|
+
)) {
|
|
245
|
+
s.remove();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("drops <script src> in untrusted edit mode", () => {
|
|
250
|
+
parseHtml({
|
|
251
|
+
html: '<script src="https://cdn.example.com/unrun.js"></script>',
|
|
252
|
+
});
|
|
253
|
+
expect(
|
|
254
|
+
document.head.querySelector(
|
|
255
|
+
'script[src="https://cdn.example.com/unrun.js"]',
|
|
256
|
+
),
|
|
257
|
+
).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("loads <script src> once a cell has been run", () => {
|
|
261
|
+
store.set(hasRunAnyCellAtom, true);
|
|
262
|
+
parseHtml({
|
|
263
|
+
html: '<script src="https://cdn.example.com/ok.js"></script>',
|
|
264
|
+
});
|
|
265
|
+
expect(
|
|
266
|
+
document.head.querySelector(
|
|
267
|
+
'script[src="https://cdn.example.com/ok.js"]',
|
|
268
|
+
),
|
|
269
|
+
).not.toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("loads <script src> when a trusted export context is installed", () => {
|
|
273
|
+
windowWithExport.__MARIMO_EXPORT_CONTEXT__ = { trusted: true };
|
|
274
|
+
parseHtml({
|
|
275
|
+
html: '<script src="https://cdn.example.com/export.js"></script>',
|
|
276
|
+
});
|
|
277
|
+
expect(
|
|
278
|
+
document.head.querySelector(
|
|
279
|
+
'script[src="https://cdn.example.com/export.js"]',
|
|
280
|
+
),
|
|
281
|
+
).not.toBeNull();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
200
285
|
describe("wrapTooltipTargets", () => {
|
|
201
286
|
test("data-tooltip wraps element in Tooltip component", () => {
|
|
202
287
|
const html = '<span data-tooltip="Hello world">Hover me</span>';
|