fix(preview): hung-frame watchdog + require approval for guessed action calls #11
@@ -2,6 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<!-- Preview documents use data: URLs. Any later navigation of that child
|
||||||
|
browsing context is denied before a network request is sent. -->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="frame-src data:;" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/n small.png" />
|
<link rel="icon" type="image/svg+xml" href="/n small.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>NexusOS</title>
|
<title>NexusOS</title>
|
||||||
|
|||||||
@@ -304,8 +304,8 @@ const _MIN_PREVIEW_H = 160;
|
|||||||
const _MAX_PREVIEW_H = 720;
|
const _MAX_PREVIEW_H = 720;
|
||||||
const _MAX_H_STEPS = 60;
|
const _MAX_H_STEPS = 60;
|
||||||
|
|
||||||
// Live preview for a ```html or ```svg fenced block: a Preview/Code toggle,
|
// Live preview for a renderable fenced block: a Preview/Code toggle rendered
|
||||||
// rendered via a sandboxed <iframe srcDoc>.
|
// via a sandboxed iframe whose document is an encoded data: URL.
|
||||||
//
|
//
|
||||||
// Trust boundary: `sandbox="allow-scripts"` — deliberately without
|
// Trust boundary: `sandbox="allow-scripts"` — deliberately without
|
||||||
// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No
|
// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No
|
||||||
@@ -313,11 +313,13 @@ const _MAX_H_STEPS = 60;
|
|||||||
// actually matters here: even the inline scripts the CSP allows to run can't
|
// actually matters here: even the inline scripts the CSP allows to run can't
|
||||||
// read this app's cookies/localStorage, can't call its API (no credentialed
|
// read this app's cookies/localStorage, can't call its API (no credentialed
|
||||||
// or same-origin fetch is possible), and can't reach `window.parent`. The CSP
|
// or same-origin fetch is possible), and can't reach `window.parent`. The CSP
|
||||||
// above closes the remaining gap same-origin-denial doesn't cover on its own:
|
// above blocks resource and script-initiated network access. The embedding
|
||||||
// arbitrary outbound network access to the open internet. Nothing here is
|
// document's `frame-src data:` policy in index.html closes a separate CSP gap:
|
||||||
// treated as a substitute for real code execution sandboxing (Docker, a WASM
|
// a child is otherwise allowed to navigate its own browsing context to a URL.
|
||||||
// runtime, etc.) - this block never runs anything outside the browser's own
|
// The initial data: document is allowed and inherits the parent policy, while
|
||||||
// iframe sandbox, which is the point: no backend attack surface at all.
|
// an http(s) navigation is rejected before its request is sent. Nothing here
|
||||||
|
// substitutes for a general code-execution sandbox (Docker, WASM, etc.);
|
||||||
|
// model-authored code runs only inside the browser's sandboxed frame.
|
||||||
function RenderBlock({ lang, value, streaming }) {
|
function RenderBlock({ lang, value, streaming }) {
|
||||||
const [tab, setTab] = useState("preview");
|
const [tab, setTab] = useState("preview");
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -331,7 +333,7 @@ function RenderBlock({ lang, value, streaming }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Don't preview a block whose fence hasn't closed yet - it's incomplete
|
// Don't preview a block whose fence hasn't closed yet - it's incomplete
|
||||||
// markup by definition, and re-pointing an iframe's srcDoc at a half-formed
|
// markup by definition, and re-pointing an iframe at a half-formed
|
||||||
// document on every streamed token is both wasteful and flickery. Code view
|
// document on every streamed token is both wasteful and flickery. Code view
|
||||||
// already has its own streaming indicator (the same dot CodeBlock uses).
|
// already has its own streaming indicator (the same dot CodeBlock uses).
|
||||||
const showPreview = tab === "preview" && !streaming;
|
const showPreview = tab === "preview" && !streaming;
|
||||||
@@ -434,16 +436,17 @@ function PreviewFrame({ lang, value, expanded }) {
|
|||||||
// A build failure (JSX that doesn't parse) has no document to show at all, so
|
// A build failure (JSX that doesn't parse) has no document to show at all, so
|
||||||
// the message stands in for the frame rather than sitting under it.
|
// the message stands in for the frame rather than sitting under it.
|
||||||
const { doc, error: buildError } = buildSrcDoc(lang, value);
|
const { doc, error: buildError } = buildSrcDoc(lang, value);
|
||||||
|
const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
|
||||||
const shown = buildError || error;
|
const shown = buildError || error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{doc && (
|
{frameUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
ref={frameRef}
|
ref={frameRef}
|
||||||
title="rendered output"
|
title="rendered output"
|
||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts"
|
||||||
srcDoc={doc}
|
src={frameUrl}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: expanded ? "70vh" : `${height}px`,
|
height: expanded ? "70vh" : `${height}px`,
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ function handleWord(sc, word) {
|
|||||||
if (word === "import" && atStatement) {
|
if (word === "import" && atStatement) {
|
||||||
if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; } // import()/import.meta
|
if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; } // import()/import.meta
|
||||||
const from = sc.i;
|
const from = sc.i;
|
||||||
skipStatement(sc);
|
skipImportStatement(sc);
|
||||||
recordImport(sc, sc.src.slice(from, sc.i));
|
recordImport(sc, sc.src.slice(from, sc.i));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -400,6 +400,51 @@ function skipStatement(sc) {
|
|||||||
sc.prevWord = word;
|
sc.prevWord = word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a static import, including the common multiline named-import form.
|
||||||
|
* A bare newline before the module string is part of the import; one after it
|
||||||
|
* ends a semicolon-free import. Semicolons always win so a malformed import
|
||||||
|
* (for example a missing `}`) cannot swallow the rest of the component.
|
||||||
|
*/
|
||||||
|
function skipImportStatement(sc) {
|
||||||
|
const from = sc.i;
|
||||||
|
const sig = sc.prevSig;
|
||||||
|
const word = sc.prevWord;
|
||||||
|
let sawModule = false;
|
||||||
|
|
||||||
|
while (!sc.eof) {
|
||||||
|
const ch = sc.peek();
|
||||||
|
if (ch === ";") { sc.i++; break; }
|
||||||
|
if (ch === "\n" && sawModule) break;
|
||||||
|
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
const outLength = sc.out.length;
|
||||||
|
copyString(sc, ch);
|
||||||
|
sc.out.length = outLength;
|
||||||
|
sawModule = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && sc.peek(1) === "/") {
|
||||||
|
const outLength = sc.out.length;
|
||||||
|
copyLineComment(sc);
|
||||||
|
sc.out.length = outLength;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && sc.peek(1) === "*") {
|
||||||
|
const outLength = sc.out.length;
|
||||||
|
copyBlockComment(sc);
|
||||||
|
sc.out.length = outLength;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sc.i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep runtime error line numbers aligned with the source shown in Code.
|
||||||
|
sc.out.push(missingNewlines(sc.src.slice(from, sc.i), ""));
|
||||||
|
sc.prevSig = sig;
|
||||||
|
sc.prevWord = word;
|
||||||
|
}
|
||||||
|
|
||||||
function skipBalancedBraces(sc) {
|
function skipBalancedBraces(sc) {
|
||||||
const from = sc.i;
|
const from = sc.i;
|
||||||
const sig = sc.prevSig;
|
const sig = sc.prevSig;
|
||||||
|
|||||||
@@ -165,6 +165,20 @@ test("imports are dropped - the sandbox has no module loader", () => {
|
|||||||
assert.equal(js('import "./styles.css";\nconst x = 1;'), "const x = 1;");
|
assert.equal(js('import "./styles.css";\nconst x = 1;'), "const x = 1;");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("multiline named imports are dropped as one statement", () => {
|
||||||
|
const source = `import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
const x = 1;`;
|
||||||
|
const out = transform(source);
|
||||||
|
assert.equal(out.code, "\n\n\n\nconst x = 1;");
|
||||||
|
assert.deepEqual(out.imports, [
|
||||||
|
{ names: ["useState", "useEffect"], module: "react" },
|
||||||
|
]);
|
||||||
|
assert.doesNotThrow(() => new Function(out.code));
|
||||||
|
});
|
||||||
|
|
||||||
test("dropped imports report the bindings they would have provided", () => {
|
test("dropped imports report the bindings they would have provided", () => {
|
||||||
// So a missing name can say where it was supposed to come from, instead of
|
// So a missing name can say where it was supposed to come from, instead of
|
||||||
// surfacing as "useInView is not defined" at its first use.
|
// surfacing as "useInView is not defined" at its first use.
|
||||||
|
|||||||
+18
-5
@@ -393,7 +393,15 @@ def _critique_shared(markup: str) -> list[str]:
|
|||||||
issues: list[str] = []
|
issues: list[str] = []
|
||||||
lower = markup.lower()
|
lower = markup.lower()
|
||||||
|
|
||||||
if re.search(r"""(?i)(?:src|href)\s*=\s*['"]https?://""", markup):
|
external_attr = re.search(
|
||||||
|
r"""(?i)\b(?:src|srcset|href|xlink:href|poster|action|formaction|data)\b\s*=\s*(?:['"]\s*)?https?://""",
|
||||||
|
markup,
|
||||||
|
)
|
||||||
|
external_css = re.search(
|
||||||
|
r"""(?i)(?:url\s*\(\s*['"]?\s*https?://|@import\s+(?:url\s*\(\s*)?['"]?\s*https?://)""",
|
||||||
|
markup,
|
||||||
|
)
|
||||||
|
if external_attr or external_css:
|
||||||
issues.append(
|
issues.append(
|
||||||
"Remove external http(s) URLs — the sandboxed preview blocks them. "
|
"Remove external http(s) URLs — the sandboxed preview blocks them. "
|
||||||
"Inline CSS/JS; use data: URIs for images/fonts."
|
"Inline CSS/JS; use data: URIs for images/fonts."
|
||||||
@@ -1014,14 +1022,15 @@ STANDING_TOOLS = frozenset({"render_preview"})
|
|||||||
# User-message cues that justify running the (slow, non-stream) tool loop with
|
# User-message cues that justify running the (slow, non-stream) tool loop with
|
||||||
# render_preview. Kept narrow so ordinary chat isn't blocked behind a tool turn.
|
# render_preview. Kept narrow so ordinary chat isn't blocked behind a tool turn.
|
||||||
_RENDER_HINTS = (
|
_RENDER_HINTS = (
|
||||||
"visual", "visualize", "visualization", "chart", "graph", "diagram",
|
"visual", "visuals", "visualize", "visualization", "chart", "charts",
|
||||||
"canvas", "plot", "interactive", "animation", "render_preview",
|
"graph", "graphs", "diagram", "diagrams", "canvas", "plot", "plots",
|
||||||
|
"interactive", "animation", "animations", "render_preview",
|
||||||
"render preview", "svg", "draw me", "live preview",
|
"render preview", "svg", "draw me", "live preview",
|
||||||
"demonstrate", "demo", "html demo", "html snippet", "html file",
|
"demonstrate", "demo", "html demo", "html snippet", "html file",
|
||||||
# Ways of asking for something that reacts to the pointer. "interactive"
|
# Ways of asking for something that reacts to the pointer. "interactive"
|
||||||
# alone missed "mouse-over sensitive", and with it the whole feature.
|
# alone missed "mouse-over sensitive", and with it the whole feature.
|
||||||
"hover", "mouse", "drag", "click on", "real-time", "realtime",
|
"hover", "mouse", "drag", "click on", "real-time", "realtime",
|
||||||
"simulation", "simulate", "particle", "animate",
|
"simulation", "simulations", "simulate", "particle", "particles", "animate",
|
||||||
# Every language the render window can display. Naming one is asking for a
|
# Every language the render window can display. Naming one is asking for a
|
||||||
# preview, and this way a language added to PREVIEW_LANGS starts hinting
|
# preview, and this way a language added to PREVIEW_LANGS starts hinting
|
||||||
# for itself instead of being unreachable until someone edits this tuple -
|
# for itself instead of being unreachable until someone edits this tuple -
|
||||||
@@ -1031,8 +1040,12 @@ _RENDER_HINTS = (
|
|||||||
|
|
||||||
def wants_render_preview(message: str) -> bool:
|
def wants_render_preview(message: str) -> bool:
|
||||||
"""True when this turn should advertise render_preview / enter the tool loop."""
|
"""True when this turn should advertise render_preview / enter the tool loop."""
|
||||||
|
import re
|
||||||
lower = (message or "").lower()
|
lower = (message or "").lower()
|
||||||
return any(h in lower for h in _RENDER_HINTS)
|
return any(
|
||||||
|
re.search(rf"(?<![A-Za-z0-9_]){re.escape(hint)}(?![A-Za-z0-9_])", lower)
|
||||||
|
for hint in _RENDER_HINTS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_action(name: str) -> bool:
|
def is_action(name: str) -> bool:
|
||||||
|
|||||||
@@ -533,3 +533,17 @@ def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
|
|||||||
|
|
||||||
# Double-click must not launch a second pull/rebuild over the first.
|
# Double-click must not launch a second pull/rebuild over the first.
|
||||||
assert client.post("/update/apply").json()["started"] is False
|
assert client.post("/update/apply").json()["started"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_iframe_cannot_navigate_to_a_network_url():
|
||||||
|
"""The child CSP blocks resource loads; the parent CSP must separately
|
||||||
|
block a sandboxed frame from navigating its own browsing context."""
|
||||||
|
index = (REPO_ROOT / "interface" / "web" / "index.html").read_text(encoding="utf-8")
|
||||||
|
markdown = (REPO_ROOT / "interface" / "web" / "src" / "Markdown.jsx").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "frame-src data:" in index
|
||||||
|
assert 'sandbox="allow-scripts"' in markdown
|
||||||
|
assert "encodeURIComponent(doc)" in markdown
|
||||||
|
assert "src={frameUrl}" in markdown
|
||||||
|
assert "srcDoc={doc}" not in markdown
|
||||||
|
|||||||
+17
-1
@@ -514,9 +514,25 @@ def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool():
|
|||||||
assert tools.wants_render_preview(prompt), prompt
|
assert tools.wants_render_preview(prompt), prompt
|
||||||
|
|
||||||
# Still narrow: ordinary chat must not pay for a tool turn.
|
# Still narrow: ordinary chat must not pay for a tool turn.
|
||||||
for prompt in ("what's the weather vibe today", "summarise this email thread"):
|
for prompt in (
|
||||||
|
"what's the weather vibe today",
|
||||||
|
"summarise this email thread",
|
||||||
|
"write a concise paragraph about caching",
|
||||||
|
):
|
||||||
assert not tools.wants_render_preview(prompt), prompt
|
assert not tools.wants_render_preview(prompt), prompt
|
||||||
|
|
||||||
|
assert tools.wants_render_preview("compare these graphs")
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_preview_resources_are_rejected_in_attributes_and_css():
|
||||||
|
for markup in (
|
||||||
|
'<img src=https://example.com/chart.png alt="chart">',
|
||||||
|
'<style>.chart { background: url("https://example.com/chart.png"); }</style>',
|
||||||
|
'<style>@import "https://example.com/chart.css";</style>',
|
||||||
|
):
|
||||||
|
issues = tools._critique_shared(markup)
|
||||||
|
assert any("external http(s)" in issue for issue in issues), markup
|
||||||
|
|
||||||
|
|
||||||
def test_every_preview_language_hints_for_itself():
|
def test_every_preview_language_hints_for_itself():
|
||||||
for lang in tools.PREVIEW_LANGS:
|
for lang in tools.PREVIEW_LANGS:
|
||||||
|
|||||||
Reference in New Issue
Block a user