feat(ai): add sandboxed HTML preview for AI rich content
This commit is contained in:
1 parent
11e0db5acb
commit
39b2d2a6ac
22 files changed
+1199
-13
No files matched your search
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { AlertTriangle, Check, Copy, Download, Maximize2 } from "@lucide/vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useToast } from "@/composables/useToast";
|
||||||
|
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||||
|
import { saveTextFile } from "@/lib/export/saveTextFile";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { acknowledgeAiHtmlCopyRisk, isAiHtmlCopyRiskAcknowledged } from "@/lib/ai/richContent/aiHtmlPreview";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Raw AI html fence body — what "copy source" puts on the clipboard. */
|
||||||
|
content: string;
|
||||||
|
/** `buildSafeHtmlPreview(content)` — shown in the iframe and written by "Save Safe HTML". */
|
||||||
|
document: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const copied = ref(false);
|
||||||
|
const previewExpanded = ref(false);
|
||||||
|
// Inline confirmation instead of a modal: the first copy attempt arms the risk
|
||||||
|
// strip; confirming copies (optionally remembering the choice for the
|
||||||
|
// session), canceling keeps the clipboard untouched.
|
||||||
|
const showCopyConfirm = ref(false);
|
||||||
|
const rememberChoice = ref(false);
|
||||||
|
|
||||||
|
function copySource() {
|
||||||
|
if (!isAiHtmlCopyRiskAcknowledged()) {
|
||||||
|
showCopyConfirm.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void warnAfterCopy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later copies skip the confirmation but keep a visible risk reminder — only
|
||||||
|
// when the copy actually succeeded.
|
||||||
|
async function warnAfterCopy() {
|
||||||
|
if (await doCopy()) toast(t("ai.htmlCopyRiskToast"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCopy() {
|
||||||
|
if (rememberChoice.value) acknowledgeAiHtmlCopyRisk();
|
||||||
|
showCopyConfirm.value = false;
|
||||||
|
void doCopy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCopy() {
|
||||||
|
showCopyConfirm.value = false;
|
||||||
|
rememberChoice.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCopy(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(props.content);
|
||||||
|
copied.value = true;
|
||||||
|
window.setTimeout(() => (copied.value = false), 1600);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// The raw AI HTML is the sensitive payload: do not cover up a clipboard
|
||||||
|
// failure with a success-looking toast, and keep the preview usable if
|
||||||
|
// the host denies clipboard access.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saves the WRAPPED safe document (inlined CSP), never the raw source: even
|
||||||
|
// once the file is opened outside the iframe sandbox the CSP still blocks
|
||||||
|
// scripts and network access.
|
||||||
|
async function saveSafeHtml() {
|
||||||
|
try {
|
||||||
|
await saveTextFile(props.document, "dbx-ai-html-preview.html", "HTML", "html");
|
||||||
|
} catch {
|
||||||
|
toast(t("ai.htmlSaveFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- `sandbox=""` is the strongest sandbox: no allow-scripts (nothing executes),
|
||||||
|
no allow-same-origin (no origin/cookie/storage access), no forms or
|
||||||
|
popups. The `srcdoc` is the CSP-wrapped standalone document — identical
|
||||||
|
bytes to the "Save Safe HTML" payload, so what is previewed is exactly
|
||||||
|
what gets saved. -->
|
||||||
|
<section class="my-2 flex min-h-60 h-[clamp(15rem,35vw,20rem)] flex-col overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-700/50 dark:bg-zinc-900" :aria-label="t('ai.htmlPreviewLabel')">
|
||||||
|
<div class="flex h-8 items-center justify-end gap-1 border-b border-zinc-200 px-2 dark:border-zinc-700/50">
|
||||||
|
<button type="button" class="mr-auto rounded p-1 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100" :title="t('ai.htmlExpandPreview')" :aria-label="t('ai.htmlExpandPreview')" @click="previewExpanded = true">
|
||||||
|
<Maximize2 class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100"
|
||||||
|
:title="copied ? t('ai.copied') : t('ai.htmlCopySource')"
|
||||||
|
:aria-label="copied ? t('ai.copied') : t('ai.htmlCopySource')"
|
||||||
|
@click="copySource"
|
||||||
|
>
|
||||||
|
<Check v-if="copied" class="h-3.5 w-3.5 text-green-500" />
|
||||||
|
<Copy v-else class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rounded p-1 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100" :title="t('ai.htmlSaveSafe')" :aria-label="t('ai.htmlSaveSafe')" @click="saveSafeHtml">
|
||||||
|
<Download class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCopyConfirm" class="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200" role="alertdialog" :aria-label="t('ai.htmlCopySource')">
|
||||||
|
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="leading-snug">{{ t("ai.htmlCopyRiskBody") }}</p>
|
||||||
|
<div class="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<label class="flex items-center gap-1 select-none">
|
||||||
|
<input v-model="rememberChoice" type="checkbox" class="h-3 w-3 accent-amber-600" />
|
||||||
|
<span>{{ t("ai.htmlCopyRiskRemember") }}</span>
|
||||||
|
</label>
|
||||||
|
<span class="flex-1" />
|
||||||
|
<button type="button" class="rounded border border-amber-400 px-2 py-0.5 hover:bg-amber-100 dark:border-amber-500/50 dark:hover:bg-amber-500/20" @click="cancelCopy">{{ t("common.cancel") }}</button>
|
||||||
|
<button type="button" class="rounded bg-amber-600 px-2 py-0.5 font-medium text-white hover:bg-amber-700" @click="confirmCopy">{{ t("ai.htmlCopyRiskAccept") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<iframe sandbox="" :srcdoc="document" class="h-full w-full bg-white" :title="t('ai.htmlPreviewLabel')" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- The expanded dialog renders the SAME sandboxed document as the inline
|
||||||
|
card — identical `sandbox=""` and the identical CSP-wrapped `srcdoc`
|
||||||
|
bytes. This is pure UX magnification: the zero-trust boundary does not
|
||||||
|
change, there is no path here that hands raw HTML to the OS browser. -->
|
||||||
|
<Dialog :open="previewExpanded" @update:open="previewExpanded = $event">
|
||||||
|
<!-- Match other large dialogs: override DialogContent's 384px default at
|
||||||
|
every breakpoint while keeping 16px viewport gutters. -->
|
||||||
|
<DialogContent class="max-w-[min(1100px,calc(100vw-32px))] flex h-[85vh] flex-col">
|
||||||
|
<DialogHeader class="shrink-0">
|
||||||
|
<DialogTitle>{{ t("ai.htmlPreviewLabel") }}</DialogTitle>
|
||||||
|
<DialogDescription>{{ t("ai.htmlExpandPreviewHint") }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<iframe sandbox="" :srcdoc="document" class="h-full w-full bg-white" :title="t('ai.htmlPreviewLabel')" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
copyToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||||
|
saveTextFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
toast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||||
|
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||||
|
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
|
||||||
|
vi.mock("@/lib/export/saveTextFile", () => ({ saveTextFile: mocks.saveTextFile }));
|
||||||
|
|
||||||
|
// The real Dialog portals its content only while open; we stub it as a
|
||||||
|
// transparent DOM passthrough that mirrors that gating (slot only rendered when
|
||||||
|
// `open` is true) and that closes through Vue's emit mechanism when clicked.
|
||||||
|
// This mirrors the CodeSnapshotDialog.spec.ts stub approach.
|
||||||
|
vi.mock("@/components/ui/dialog", async () => {
|
||||||
|
const { defineComponent, h } = await import("vue");
|
||||||
|
const passthrough = defineComponent({
|
||||||
|
setup(_props, { slots }) {
|
||||||
|
return () => h("div", slots.default?.());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
Dialog: defineComponent({
|
||||||
|
props: { open: Boolean },
|
||||||
|
emits: ["update:open"],
|
||||||
|
setup(props, { emit, slots }) {
|
||||||
|
return () => h("div", { "data-open": String(props.open), onClick: () => emit("update:open", false) }, props.open ? slots.default?.() : undefined);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
DialogContent: passthrough,
|
||||||
|
DialogHeader: passthrough,
|
||||||
|
DialogTitle: defineComponent({
|
||||||
|
setup(_props, { slots }) {
|
||||||
|
return () => h("div", { "data-slot": "dialog-title" }, slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
DialogDescription: passthrough,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { buildSafeHtmlPreview } from "@/lib/ai/richContent/aiHtmlPreview";
|
||||||
|
import type { App } from "vue";
|
||||||
|
import { createApp, nextTick } from "vue";
|
||||||
|
|
||||||
|
const content = "<p>Hello <strong>world</strong></p>";
|
||||||
|
const documentHtml = buildSafeHtmlPreview(content);
|
||||||
|
|
||||||
|
let app: App<Element> | null = null;
|
||||||
|
let root: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
async function mountPreview() {
|
||||||
|
// Dynamic import after resetModules so each test starts with a fresh copy-risk session state.
|
||||||
|
const { default: AiHtmlPreview } = await import("@/components/ai/rich/AiHtmlPreview.vue");
|
||||||
|
app = createApp(AiHtmlPreview, { content, document: documentHtml });
|
||||||
|
root = window.document.createElement("div");
|
||||||
|
window.document.body.appendChild(root);
|
||||||
|
app.mount(root);
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
app?.unmount();
|
||||||
|
app = null;
|
||||||
|
root?.remove();
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(title: string): HTMLButtonElement | null {
|
||||||
|
return root?.querySelector(`button[aria-label="${title}"]`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyButton() {
|
||||||
|
return button("ai.htmlCopySource");
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmStrip(): Element | null {
|
||||||
|
return root?.querySelector('[role="alertdialog"]') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function click(element: HTMLElement | null) {
|
||||||
|
element?.dispatchEvent(new window.Event("click", { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
mocks.copyToClipboard.mockClear();
|
||||||
|
mocks.saveTextFile.mockClear();
|
||||||
|
mocks.toast.mockClear();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AiHtmlPreview", () => {
|
||||||
|
it("renders a fully sandboxed iframe over the CSP-wrapped document", async () => {
|
||||||
|
await mountPreview();
|
||||||
|
const iframe = root?.querySelector("iframe");
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
// `sandbox=""` without allow-scripts / allow-same-origin: nothing may execute.
|
||||||
|
expect(iframe?.getAttribute("sandbox")).toBe("");
|
||||||
|
const srcdoc = iframe?.getAttribute("srcdoc") ?? "";
|
||||||
|
expect(srcdoc).toContain("Content-Security-Policy");
|
||||||
|
expect(srcdoc).toContain("default-src 'none'");
|
||||||
|
expect(srcdoc).toContain(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks before the first source copy and cancel keeps the clipboard untouched", async () => {
|
||||||
|
await mountPreview();
|
||||||
|
await click(copyButton());
|
||||||
|
expect(confirmStrip()).not.toBeNull();
|
||||||
|
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
||||||
|
// Cancel = the first (non-destructive) button in the strip.
|
||||||
|
const cancel = [...(confirmStrip()?.querySelectorAll("button") ?? [])][0];
|
||||||
|
await click(cancel);
|
||||||
|
expect(confirmStrip()).toBeNull();
|
||||||
|
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies after explicit confirmation and remembers the choice for the session", async () => {
|
||||||
|
await mountPreview();
|
||||||
|
await click(copyButton());
|
||||||
|
const strip = confirmStrip();
|
||||||
|
expect(strip).not.toBeNull();
|
||||||
|
const checkbox = strip?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
// v-model needs the change event, not just the property write.
|
||||||
|
checkbox.dispatchEvent(new window.Event("change"));
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
const buttons = [...(strip?.querySelectorAll("button") ?? [])];
|
||||||
|
await click(buttons[buttons.length - 1]);
|
||||||
|
expect(mocks.copyToClipboard).toHaveBeenCalledWith(content);
|
||||||
|
expect(confirmStrip()).toBeNull();
|
||||||
|
|
||||||
|
// A later copy in the same session — even from a freshly mounted preview —
|
||||||
|
// skips the confirmation and shows only the risk toast.
|
||||||
|
mocks.copyToClipboard.mockClear();
|
||||||
|
unmount();
|
||||||
|
await mountPreview();
|
||||||
|
await click(copyButton());
|
||||||
|
expect(confirmStrip()).toBeNull();
|
||||||
|
expect(mocks.copyToClipboard).toHaveBeenCalledWith(content);
|
||||||
|
expect(mocks.toast).toHaveBeenCalledWith("ai.htmlCopyRiskToast");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the same sandboxed document in an expanded dialog and closes it", async () => {
|
||||||
|
await mountPreview();
|
||||||
|
expect(root?.querySelectorAll("iframe").length).toBe(1);
|
||||||
|
|
||||||
|
await click(button("ai.htmlExpandPreview"));
|
||||||
|
// The stub Dialog renders inline; the second iframe carries the same
|
||||||
|
// sandbox boundary and the same CSP-wrapped srcdoc as the inline card.
|
||||||
|
const iframes = [...(root?.querySelectorAll("iframe") ?? [])];
|
||||||
|
expect(iframes.length).toBe(2);
|
||||||
|
const dialogIframe = iframes[1];
|
||||||
|
expect(dialogIframe.getAttribute("sandbox")).toBe("");
|
||||||
|
const srcdoc = dialogIframe.getAttribute("srcdoc") ?? "";
|
||||||
|
expect(srcdoc).toContain("Content-Security-Policy");
|
||||||
|
expect(srcdoc).toContain("default-src 'none'");
|
||||||
|
expect(srcdoc).toContain(content);
|
||||||
|
// The wide preview must override DialogContent's max-w-sm at every
|
||||||
|
// breakpoint, including narrow desktop windows and mobile-sized WebViews.
|
||||||
|
expect(root?.innerHTML).toContain("max-w-[min(1100px,calc(100vw-32px))]");
|
||||||
|
// The dialog title is the same preview label used for the inline card.
|
||||||
|
expect(root?.querySelector('[data-slot="dialog-title"]')?.textContent).toBe("ai.htmlPreviewLabel");
|
||||||
|
|
||||||
|
// Closing (Esc / overlay / programmatic update:open) removes the iframe.
|
||||||
|
const dialog = root?.querySelector('[data-open="true"]');
|
||||||
|
await click(dialog);
|
||||||
|
expect(root?.querySelectorAll("iframe").length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the wrapped safe document, never the raw source", async () => {
|
||||||
|
await mountPreview();
|
||||||
|
await click(button("ai.htmlSaveSafe"));
|
||||||
|
expect(mocks.saveTextFile).toHaveBeenCalledWith(documentHtml, "dbx-ai-html-preview.html", "HTML", "html");
|
||||||
|
expect(mocks.saveTextFile.mock.calls[0][0]).not.toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses the risk toast when the clipboard write fails", async () => {
|
||||||
|
await mountPreview();
|
||||||
|
// Arm the remembered session acknowledgment, mirroring the existing flow.
|
||||||
|
await click(copyButton());
|
||||||
|
const strip = confirmStrip();
|
||||||
|
expect(strip).not.toBeNull();
|
||||||
|
const checkbox = strip?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
checkbox.dispatchEvent(new window.Event("change"));
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
const buttons = [...(strip?.querySelectorAll("button") ?? [])];
|
||||||
|
await click(buttons[buttons.length - 1]);
|
||||||
|
expect(mocks.copyToClipboard).toHaveBeenCalledWith(content);
|
||||||
|
|
||||||
|
// A later copy in the same session skips the confirmation — but this time
|
||||||
|
// the clipboard write fails, so the risk toast must not claim success.
|
||||||
|
// (Regression for #I01: the previous code toasted unconditionally.)
|
||||||
|
mocks.copyToClipboard.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
mocks.toast.mockClear();
|
||||||
|
unmount();
|
||||||
|
await mountPreview();
|
||||||
|
await click(copyButton());
|
||||||
|
expect(confirmStrip()).toBeNull();
|
||||||
|
expect(mocks.copyToClipboard).toHaveBeenCalledWith(content);
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
// The failure is still silent (doCopy swallows), so the "copied" checkmark
|
||||||
|
// must not appear either — no false success signal at all.
|
||||||
|
expect(root?.querySelector(".text-green-500")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -188,6 +188,11 @@ const AiChartRenderer = defineAsyncComponent({
|
|||||||
h("span", t("common.loading")),
|
h("span", t("common.loading")),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
const AiHtmlPreview = defineAsyncComponent({
|
||||||
|
// HTML previews are rare (the prompt only allows them on explicit request),
|
||||||
|
// so lazy-load the component just like the chart renderer.
|
||||||
|
loader: () => import("@/components/ai/rich/AiHtmlPreview.vue"),
|
||||||
|
});
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
const savedSqlStore = useSavedSqlStore();
|
const savedSqlStore = useSavedSqlStore();
|
||||||
@@ -4714,6 +4719,7 @@ async function openExternalUrl(url: string) {
|
|||||||
<div v-html="seg.html" />
|
<div v-html="seg.html" />
|
||||||
</div>
|
</div>
|
||||||
<AiChartRenderer v-else-if="seg.type === 'chart'" :spec="seg.spec" :content="seg.content" />
|
<AiChartRenderer v-else-if="seg.type === 'chart'" :spec="seg.spec" :content="seg.content" />
|
||||||
|
<AiHtmlPreview v-else-if="seg.type === 'html'" :content="seg.content" :document="seg.document" />
|
||||||
<div v-else class="my-2 overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-700/50 dark:bg-zinc-900">
|
<div v-else class="my-2 overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-700/50 dark:bg-zinc-900">
|
||||||
<div class="flex items-center border-b border-zinc-200 px-3 py-1.5 text-[10px] font-medium text-zinc-600 dark:border-zinc-700/50 dark:text-zinc-400">
|
<div class="flex items-center border-b border-zinc-200 px-3 py-1.5 text-[10px] font-medium text-zinc-600 dark:border-zinc-700/50 dark:text-zinc-400">
|
||||||
<component :is="seg.isSql ? Database : Terminal" class="h-3 w-3 mr-1.5" />
|
<component :is="seg.isSql ? Database : Terminal" class="h-3 w-3 mr-1.5" />
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import en from "../locales/en";
|
||||||
|
import es from "../locales/es";
|
||||||
|
import it_ from "../locales/it";
|
||||||
|
import ja from "../locales/ja";
|
||||||
|
import ko from "../locales/ko";
|
||||||
|
import ptBR from "../locales/pt-BR";
|
||||||
|
import tr from "../locales/tr";
|
||||||
|
import zhCN from "../locales/zh-CN";
|
||||||
|
import zhTW from "../locales/zh-TW";
|
||||||
|
|
||||||
|
const locales: Array<[string, Record<string, unknown>]> = [
|
||||||
|
["en", en],
|
||||||
|
["es", es],
|
||||||
|
["it", it_],
|
||||||
|
["ja", ja],
|
||||||
|
["ko", ko],
|
||||||
|
["pt-BR", ptBR],
|
||||||
|
["tr", tr],
|
||||||
|
["zh-CN", zhCN],
|
||||||
|
["zh-TW", zhTW],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Keys the safe-HTML preview (PR2 of #6467) adds to the `ai` namespace. Every
|
||||||
|
// locale must expose all of them — the preview UI renders in whatever locale
|
||||||
|
// the user runs, and a missing key would leak raw keys into the toolbar.
|
||||||
|
const AI_HTML_PREVIEW_KEYS = ["htmlPreviewLabel", "htmlExpandPreview", "htmlExpandPreviewHint", "htmlSaveSafe", "htmlSaveFailed", "htmlCopySource", "htmlCopyRiskBody", "htmlCopyRiskAccept", "htmlCopyRiskRemember", "htmlCopyRiskToast"] as const;
|
||||||
|
|
||||||
|
describe("AI rich content locale parity", () => {
|
||||||
|
it.each(locales)("%s exposes the full ai.html* key set with non-empty copy", (_name, locale) => {
|
||||||
|
const ai = (locale as { ai: Record<string, unknown> }).ai;
|
||||||
|
for (const key of AI_HTML_PREVIEW_KEYS) {
|
||||||
|
expect(ai[key], `${_name}: ai.${key}`).toBeTypeOf("string");
|
||||||
|
expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3037,6 +3037,16 @@ export default {
|
|||||||
chartDownloadPng: "Download PNG",
|
chartDownloadPng: "Download PNG",
|
||||||
chartDownloadFailed: "Failed to download chart image",
|
chartDownloadFailed: "Failed to download chart image",
|
||||||
chartData: "Chart data",
|
chartData: "Chart data",
|
||||||
|
htmlPreviewLabel: "AI-generated HTML preview",
|
||||||
|
htmlExpandPreview: "Open larger preview",
|
||||||
|
htmlExpandPreviewHint: "This preview runs fully sandboxed — scripts and network access are blocked.",
|
||||||
|
htmlSaveSafe: "Save Safe HTML",
|
||||||
|
htmlSaveFailed: "Failed to save HTML file",
|
||||||
|
htmlCopySource: "Copy source",
|
||||||
|
htmlCopyRiskBody: "This HTML was AI-generated. Opening it outside DBX may execute scripts and access the network.",
|
||||||
|
htmlCopyRiskAccept: "Copy anyway",
|
||||||
|
htmlCopyRiskRemember: "Don't ask again this session",
|
||||||
|
htmlCopyRiskToast: "Copied. This AI-generated HTML may execute scripts or access the network when opened outside DBX.",
|
||||||
},
|
},
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
openConnection: "Open Connection",
|
openConnection: "Open Connection",
|
||||||
|
|||||||
@@ -2824,6 +2824,16 @@ export default withEnglishFallback({
|
|||||||
chartDownloadPng: "Descargar PNG",
|
chartDownloadPng: "Descargar PNG",
|
||||||
chartDownloadFailed: "No se pudo descargar la imagen del gráfico",
|
chartDownloadFailed: "No se pudo descargar la imagen del gráfico",
|
||||||
chartData: "Datos del gráfico",
|
chartData: "Datos del gráfico",
|
||||||
|
htmlPreviewLabel: "Vista previa de HTML generado por IA",
|
||||||
|
htmlExpandPreview: "Abrir vista previa ampliada",
|
||||||
|
htmlExpandPreviewHint: "Esta vista previa se ejecuta en un sandbox completo: los scripts y el acceso a la red están bloqueados.",
|
||||||
|
htmlSaveSafe: "Guardar HTML seguro",
|
||||||
|
htmlSaveFailed: "No se pudo guardar el archivo HTML",
|
||||||
|
htmlCopySource: "Copiar código fuente",
|
||||||
|
htmlCopyRiskBody: "Este HTML fue generado por IA. Abrirlo fuera de DBX puede ejecutar scripts y acceder a la red.",
|
||||||
|
htmlCopyRiskAccept: "Copiar de todos modos",
|
||||||
|
htmlCopyRiskRemember: "No volver a preguntar en esta sesión",
|
||||||
|
htmlCopyRiskToast: "Copiado. Este HTML generado por IA puede ejecutar scripts o acceder a la red si se abre fuera de DBX.",
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL generado",
|
generated: "SQL generado",
|
||||||
noSql: "No se encontró SQL",
|
noSql: "No se encontró SQL",
|
||||||
|
|||||||
@@ -2718,6 +2718,16 @@ export default withEnglishFallback({
|
|||||||
chartDownloadPng: "Scarica PNG",
|
chartDownloadPng: "Scarica PNG",
|
||||||
chartDownloadFailed: "Impossibile scaricare l'immagine del grafico",
|
chartDownloadFailed: "Impossibile scaricare l'immagine del grafico",
|
||||||
chartData: "Dati del grafico",
|
chartData: "Dati del grafico",
|
||||||
|
htmlPreviewLabel: "Anteprima HTML generata dall'IA",
|
||||||
|
htmlExpandPreview: "Apri anteprima ingrandita",
|
||||||
|
htmlExpandPreviewHint: "Questa anteprima viene eseguita in una sandbox completa: script e accesso alla rete sono bloccati.",
|
||||||
|
htmlSaveSafe: "Salva HTML sicuro",
|
||||||
|
htmlSaveFailed: "Salvataggio del file HTML non riuscito",
|
||||||
|
htmlCopySource: "Copia sorgente",
|
||||||
|
htmlCopyRiskBody: "Questo HTML è stato generato dall'IA. Aprirlo fuori da DBX può eseguire script e accedere alla rete.",
|
||||||
|
htmlCopyRiskAccept: "Copia comunque",
|
||||||
|
htmlCopyRiskRemember: "Non chiedere più in questa sessione",
|
||||||
|
htmlCopyRiskToast: "Copiato. Questo HTML generato dall'IA può eseguire script o accedere alla rete se aperto fuori da DBX.",
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL generato",
|
generated: "SQL generato",
|
||||||
noSql: "Nessun SQL trovato",
|
noSql: "Nessun SQL trovato",
|
||||||
|
|||||||
@@ -2860,6 +2860,16 @@ export default withEnglishFallback({
|
|||||||
chartDownloadPng: "PNGをダウンロード",
|
chartDownloadPng: "PNGをダウンロード",
|
||||||
chartDownloadFailed: "グラフ画像をダウンロードできませんでした",
|
chartDownloadFailed: "グラフ画像をダウンロードできませんでした",
|
||||||
chartData: "グラフのデータ",
|
chartData: "グラフのデータ",
|
||||||
|
htmlPreviewLabel: "AI生成HTMLのプレビュー",
|
||||||
|
htmlExpandPreview: "拡大プレビューを開く",
|
||||||
|
htmlExpandPreviewHint: "このプレビューは完全なサンドボックスで実行され、スクリプトとネットワークアクセスはブロックされます。",
|
||||||
|
htmlSaveSafe: "安全なHTMLを保存",
|
||||||
|
htmlSaveFailed: "HTMLファイルの保存に失敗しました",
|
||||||
|
htmlCopySource: "ソースをコピー",
|
||||||
|
htmlCopyRiskBody: "このHTMLはAIによって生成されました。DBXの外部で開くとスクリプトが実行され、ネットワークへアクセスする可能性があります。",
|
||||||
|
htmlCopyRiskAccept: "それでもコピー",
|
||||||
|
htmlCopyRiskRemember: "このセッションでは今後確認しない",
|
||||||
|
htmlCopyRiskToast: "コピーしました。このAI生成HTMLは、DBXの外部で開くとスクリプトを実行したりネットワークへアクセスしたりする可能性があります。",
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL生成完了",
|
generated: "SQL生成完了",
|
||||||
noSql: "SQLが見つかりません",
|
noSql: "SQLが見つかりません",
|
||||||
|
|||||||
@@ -2830,6 +2830,16 @@ export default withEnglishFallback({
|
|||||||
chartDownloadPng: "PNG 다운로드",
|
chartDownloadPng: "PNG 다운로드",
|
||||||
chartDownloadFailed: "차트 이미지를 다운로드하지 못했습니다",
|
chartDownloadFailed: "차트 이미지를 다운로드하지 못했습니다",
|
||||||
chartData: "차트 데이터",
|
chartData: "차트 데이터",
|
||||||
|
htmlPreviewLabel: "AI 생성 HTML 미리보기",
|
||||||
|
htmlExpandPreview: "큰 화면으로 미리보기",
|
||||||
|
htmlExpandPreviewHint: "이 미리보기는 완전한 샌드박스에서 실행되며 스크립트와 네트워크 액세스가 차단됩니다.",
|
||||||
|
htmlSaveSafe: "안전한 HTML 저장",
|
||||||
|
htmlSaveFailed: "HTML 파일 저장에 실패했습니다",
|
||||||
|
htmlCopySource: "소스 복사",
|
||||||
|
htmlCopyRiskBody: "이 HTML은 AI가 생성했습니다. DBX 외부에서 열면 스크립트가 실행되고 네트워크에 접근할 수 있습니다.",
|
||||||
|
htmlCopyRiskAccept: "그래도 복사",
|
||||||
|
htmlCopyRiskRemember: "이 세션에서 다시 묻지 않기",
|
||||||
|
htmlCopyRiskToast: "복사되었습니다. 이 AI 생성 HTML은 DBX 외부에서 열면 스크립트를 실행하거나 네트워크에 접근할 수 있습니다.",
|
||||||
},
|
},
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
openConnection: "연결 열기",
|
openConnection: "연결 열기",
|
||||||
|
|||||||
@@ -2826,6 +2826,16 @@ export default withEnglishFallback({
|
|||||||
chartDownloadPng: "Baixar PNG",
|
chartDownloadPng: "Baixar PNG",
|
||||||
chartDownloadFailed: "Falha ao baixar a imagem do gráfico",
|
chartDownloadFailed: "Falha ao baixar a imagem do gráfico",
|
||||||
chartData: "Dados do gráfico",
|
chartData: "Dados do gráfico",
|
||||||
|
htmlPreviewLabel: "Pré-visualização de HTML gerado por IA",
|
||||||
|
htmlExpandPreview: "Abrir visualização ampliada",
|
||||||
|
htmlExpandPreviewHint: "Esta visualização é executada em sandbox completo — scripts e acesso à rede são bloqueados.",
|
||||||
|
htmlSaveSafe: "Salvar HTML seguro",
|
||||||
|
htmlSaveFailed: "Falha ao salvar o arquivo HTML",
|
||||||
|
htmlCopySource: "Copiar código-fonte",
|
||||||
|
htmlCopyRiskBody: "Este HTML foi gerado por IA. Abri-lo fora do DBX pode executar scripts e acessar a rede.",
|
||||||
|
htmlCopyRiskAccept: "Copiar mesmo assim",
|
||||||
|
htmlCopyRiskRemember: "Não perguntar novamente nesta sessão",
|
||||||
|
htmlCopyRiskToast: "Copiado. Este HTML gerado por IA pode executar scripts ou acessar a rede quando aberto fora do DBX.",
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL gerado",
|
generated: "SQL gerado",
|
||||||
noSql: "Nenhum SQL encontrado",
|
noSql: "Nenhum SQL encontrado",
|
||||||
|
|||||||
@@ -3011,6 +3011,16 @@ export default withEnglishFallback({
|
|||||||
chartDownloadPng: "PNG İndir",
|
chartDownloadPng: "PNG İndir",
|
||||||
chartDownloadFailed: "Grafik görüntüsü indirilemedi",
|
chartDownloadFailed: "Grafik görüntüsü indirilemedi",
|
||||||
chartData: "Grafik verileri",
|
chartData: "Grafik verileri",
|
||||||
|
htmlPreviewLabel: "AI tarafından oluşturulan HTML önizlemesi",
|
||||||
|
htmlExpandPreview: "Büyük önizleme aç",
|
||||||
|
htmlExpandPreviewHint: "Bu önizleme tamamen korumalı alanda çalışır; betikler ve ağ erişimi engellenir.",
|
||||||
|
htmlSaveSafe: "Güvenli HTML'i kaydet",
|
||||||
|
htmlSaveFailed: "HTML dosyası kaydedilemedi",
|
||||||
|
htmlCopySource: "Kaynağı kopyala",
|
||||||
|
htmlCopyRiskBody: "Bu HTML yapay zeka tarafından oluşturuldu. DBX dışında açmak betik çalıştırabilir ve ağa erişebilir.",
|
||||||
|
htmlCopyRiskAccept: "Yine de kopyala",
|
||||||
|
htmlCopyRiskRemember: "Bu oturumda tekrar sorma",
|
||||||
|
htmlCopyRiskToast: "Kopyalandı. Bu AI tarafından oluşturulan HTML, DBX dışında açıldığında betik çalıştırabilir veya ağa erişebilir.",
|
||||||
},
|
},
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
openConnection: "Bağlantıyı Aç",
|
openConnection: "Bağlantıyı Aç",
|
||||||
|
|||||||
@@ -2960,6 +2960,16 @@ export default withEnglishFallback({
|
|||||||
chartDownloadPng: "下载 PNG",
|
chartDownloadPng: "下载 PNG",
|
||||||
chartDownloadFailed: "图表图片下载失败",
|
chartDownloadFailed: "图表图片下载失败",
|
||||||
chartData: "图表数据",
|
chartData: "图表数据",
|
||||||
|
htmlPreviewLabel: "AI 生成的 HTML 预览",
|
||||||
|
htmlExpandPreview: "在大窗口预览",
|
||||||
|
htmlExpandPreviewHint: "预览在完全沙箱中运行,脚本与网络访问均被阻止。",
|
||||||
|
htmlSaveSafe: "保存安全 HTML",
|
||||||
|
htmlSaveFailed: "保存 HTML 文件失败",
|
||||||
|
htmlCopySource: "复制源码",
|
||||||
|
htmlCopyRiskBody: "此 HTML 由 AI 生成,在 DBX 外打开可能执行脚本并访问网络。",
|
||||||
|
htmlCopyRiskAccept: "仍要复制",
|
||||||
|
htmlCopyRiskRemember: "本会话不再询问",
|
||||||
|
htmlCopyRiskToast: "已复制。此 AI 生成的 HTML 在 DBX 外打开可能执行脚本或访问网络。",
|
||||||
},
|
},
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
openConnection: "打开连接",
|
openConnection: "打开连接",
|
||||||
|
|||||||
@@ -2938,6 +2938,16 @@ export default withEnglishFallback({
|
|||||||
chartDownloadPng: "下載 PNG",
|
chartDownloadPng: "下載 PNG",
|
||||||
chartDownloadFailed: "圖表圖片下載失敗",
|
chartDownloadFailed: "圖表圖片下載失敗",
|
||||||
chartData: "圖表資料",
|
chartData: "圖表資料",
|
||||||
|
htmlPreviewLabel: "AI 產生的 HTML 預覽",
|
||||||
|
htmlExpandPreview: "在大視窗預覽",
|
||||||
|
htmlExpandPreviewHint: "預覽在完全沙箱中執行,指令碼與網路存取均被封鎖。",
|
||||||
|
htmlSaveSafe: "儲存安全 HTML",
|
||||||
|
htmlSaveFailed: "儲存 HTML 檔案失敗",
|
||||||
|
htmlCopySource: "複製原始碼",
|
||||||
|
htmlCopyRiskBody: "此 HTML 由 AI 產生,在 DBX 外開啟可能執行指令碼並存取網路。",
|
||||||
|
htmlCopyRiskAccept: "仍要複製",
|
||||||
|
htmlCopyRiskRemember: "本次工作階段不再詢問",
|
||||||
|
htmlCopyRiskToast: "已複製。此 AI 產生的 HTML 在 DBX 外開啟可能執行指令碼或存取網路。",
|
||||||
},
|
},
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
openConnection: "開啟連線",
|
openConnection: "開啟連線",
|
||||||
|
|||||||
@@ -260,6 +260,56 @@ describe("createAiMessageRenderer rich chart segments", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createAiMessageRenderer rich html segments", () => {
|
||||||
|
const markdown = (text: string) => `<p>${text}</p>`;
|
||||||
|
|
||||||
|
it("routes a closed html fence to an html segment carrying the safe document", () => {
|
||||||
|
const renderer = createAiMessageRenderer({ markdown });
|
||||||
|
const segments = renderer.render("```html\n<p>hi</p>\n```");
|
||||||
|
const seg = segments[0];
|
||||||
|
expect(seg.type).toBe("html");
|
||||||
|
if (seg.type !== "html") return;
|
||||||
|
expect(seg.content).toBe("<p>hi</p>");
|
||||||
|
expect(seg.document).toContain("Content-Security-Policy");
|
||||||
|
expect(seg.document).toContain("<p>hi</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an unfinished html fence as a plain code segment while streaming", () => {
|
||||||
|
const renderer = createAiMessageRenderer({ markdown });
|
||||||
|
const [seg] = renderer.render("```html\n<p>hi", { streaming: true });
|
||||||
|
expect(seg.type).toBe("code");
|
||||||
|
if (seg.type !== "code") return;
|
||||||
|
expect(seg.pending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches an open html fence to an html segment once the closing fence arrives", () => {
|
||||||
|
const renderer = createAiMessageRenderer({ markdown });
|
||||||
|
expect(renderer.render("```html\n<p>hi", { streaming: true })[0].type).toBe("code");
|
||||||
|
expect(renderer.render("```html\n<p>hi</p>\n```", { streaming: true })[0].type).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a code segment when the html body exceeds the preview budget", () => {
|
||||||
|
const renderer = createAiMessageRenderer({ markdown });
|
||||||
|
const oversize = "x".repeat(512 * 1024 + 1);
|
||||||
|
const [seg] = renderer.render(`\`\`\`html\n${oversize}\n\`\`\``);
|
||||||
|
expect(seg.type).toBe("code");
|
||||||
|
if (seg.type !== "code") return;
|
||||||
|
expect(seg.lang).toBe("HTML");
|
||||||
|
expect(seg.isSql).toBe(false);
|
||||||
|
expect(seg.pending).toBe(false);
|
||||||
|
expect(seg.content).toBe(oversize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never routes html fences into the sql code path that offers run/apply buttons", () => {
|
||||||
|
const renderer = createAiMessageRenderer({ markdown });
|
||||||
|
const [seg] = renderer.render("```html\n<p>hi</p>\n```");
|
||||||
|
// The template only offers run/apply on `type === 'code'` segments with
|
||||||
|
// `isSql`; an html segment must never wear that shape.
|
||||||
|
expect(seg.type).not.toBe("code");
|
||||||
|
expect(seg.type).toBe("html");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("splitStreamingTextBlocks", () => {
|
describe("splitStreamingTextBlocks", () => {
|
||||||
const long = "内容".repeat(200);
|
const long = "内容".repeat(200);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AI_RICH_BLOCK_HANDLERS, type AiMessageChartSegment, type AiRichBlockHandler } from "@/lib/ai/richContent/aiRichContent";
|
import { AI_RICH_BLOCK_HANDLERS, type AiMessageChartSegment, type AiMessageHtmlSegment, type AiRichBlockHandler } from "@/lib/ai/richContent/aiRichContent";
|
||||||
|
|
||||||
export interface AiMessageTextSegment {
|
export interface AiMessageTextSegment {
|
||||||
type: "text";
|
type: "text";
|
||||||
@@ -16,7 +16,7 @@ export interface AiMessageCodeSegment {
|
|||||||
pending: boolean;
|
pending: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AiMessageRenderSegment = AiMessageTextSegment | AiMessageCodeSegment | AiMessageChartSegment;
|
export type AiMessageRenderSegment = AiMessageTextSegment | AiMessageCodeSegment | AiMessageChartSegment | AiMessageHtmlSegment;
|
||||||
|
|
||||||
interface MessageSegment {
|
interface MessageSegment {
|
||||||
type: "text" | "code";
|
type: "text" | "code";
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AI_HTML_MAX_CONTENT_CHARS, AI_HTML_PREVIEW_CSP, buildSafeHtmlPreview, isAiHtmlPreviewEligible } from "@/lib/ai/richContent/aiHtmlPreview";
|
||||||
|
|
||||||
|
describe("AI_HTML_PREVIEW_CSP", () => {
|
||||||
|
it("declares the deny-by-default fetch directives", () => {
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("default-src 'none'");
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("img-src data: blob:");
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("style-src 'unsafe-inline'");
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("font-src data:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicitly declares the directives that do not inherit default-src", () => {
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("base-uri 'none'");
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("form-action 'none'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants no script execution whatsoever", () => {
|
||||||
|
// No script-src: scripts fall back to default-src 'none' and are blocked.
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).not.toContain("script-src");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens no network direction beyond inline data/blob payloads", () => {
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).not.toContain("http");
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).not.toContain("connect-src");
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).not.toContain("frame-src");
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).not.toContain("child-src");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSafeHtmlPreview", () => {
|
||||||
|
it("wraps the raw content in a standalone document with an inline CSP", () => {
|
||||||
|
const wrapped = buildSafeHtmlPreview("<p>hi</p>");
|
||||||
|
expect(wrapped.startsWith("<!doctype html>")).toBe(true);
|
||||||
|
expect(wrapped).toContain('<meta charset="utf-8">');
|
||||||
|
expect(wrapped).toContain(`<meta http-equiv="Content-Security-Policy" content="${AI_HTML_PREVIEW_CSP}">`);
|
||||||
|
// The CSP meta must appear before the content: it is the outer shell's
|
||||||
|
// document-wide policy, not something the payload could place after.
|
||||||
|
expect(wrapped.indexOf("Content-Security-Policy")).toBeLessThan(wrapped.indexOf("<p>hi</p>"));
|
||||||
|
expect(wrapped.endsWith("</body>\n</html>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embeds the raw content verbatim (unescaped, sandboxing is the runtime guard)", () => {
|
||||||
|
const hostile = `<p onclick="x()">a</p><script>fetch('https://ex.example')</script><img src="https://ex.example/i.png" onerror="x()">`;
|
||||||
|
const wrapped = buildSafeHtmlPreview(hostile);
|
||||||
|
expect(wrapped).toContain(hostile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defuses <meta http-equiv=refresh> in any case, quoting style, or attribute order", () => {
|
||||||
|
const vectors = [
|
||||||
|
'<meta http-equiv="refresh" content="0;url=https://attacker/">',
|
||||||
|
"<meta http-equiv='refresh' content='0;url=https://attacker/'>",
|
||||||
|
"<meta http-equiv=refresh content='0;url=https://attacker/'>",
|
||||||
|
'<meta content="0;url=https://attacker/" http-equiv="refresh">',
|
||||||
|
'<META HTTP-EQUIV="Refresh" CONTENT="0;url=https://attacker/">',
|
||||||
|
'<meta name="x" http-equiv="refresh" charset="utf-8" content="0;url=https://attacker/">',
|
||||||
|
'<meta http-equiv="refresh" content="0;url=https://attacker/"/>',
|
||||||
|
];
|
||||||
|
for (const vector of vectors) {
|
||||||
|
const wrapped = buildSafeHtmlPreview(vector);
|
||||||
|
expect(wrapped).toContain("<!-- meta refresh removed -->");
|
||||||
|
// No live refresh meta may survive in any spelling.
|
||||||
|
expect(wrapped).not.toMatch(/<meta\b[^>]*\bhttp-equiv\s*=\s*["']?\s*refresh\b/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the body around a defused refresh meta and leaves other metas untouched", () => {
|
||||||
|
const body = '<p>before</p><meta http-equiv="refresh" content="0;url=https://attacker/"><p>after</p><meta charset="utf-8">';
|
||||||
|
const wrapped = buildSafeHtmlPreview(body);
|
||||||
|
expect(wrapped).toContain('<p>before</p><!-- meta refresh removed --><p>after</p><meta charset="utf-8">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays safe when the AI content is a complete HTML document", () => {
|
||||||
|
const completeDocument = "<!DOCTYPE html><html><head><title>p</title></head><body>own</body></html>";
|
||||||
|
const wrapped = buildSafeHtmlPreview(completeDocument);
|
||||||
|
// Error recovery: the outer doctype/CSP meta still precede everything.
|
||||||
|
expect(wrapped.indexOf("Content-Security-Policy")).toBeLessThan(wrapped.indexOf(completeDocument));
|
||||||
|
expect(wrapped.startsWith("<!doctype html>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic so preview and saved file are byte-identical", () => {
|
||||||
|
expect(buildSafeHtmlPreview("<b>x</b>")).toBe(buildSafeHtmlPreview("<b>x</b>"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAiHtmlPreviewEligible", () => {
|
||||||
|
it("accepts bodies within the 512KB budget", () => {
|
||||||
|
expect(isAiHtmlPreviewEligible("x")).toBe(true);
|
||||||
|
expect(isAiHtmlPreviewEligible("x".repeat(AI_HTML_MAX_CONTENT_CHARS))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty and over-budget bodies", () => {
|
||||||
|
expect(isAiHtmlPreviewEligible("")).toBe(false);
|
||||||
|
expect(isAiHtmlPreviewEligible("x".repeat(AI_HTML_MAX_CONTENT_CHARS + 1))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("copy-risk session state", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a confirmation for the first copy of a session", async () => {
|
||||||
|
const mod = await import("@/lib/ai/richContent/aiHtmlPreview");
|
||||||
|
expect(mod.isAiHtmlCopyRiskAcknowledged()).toBe(false);
|
||||||
|
mod.acknowledgeAiHtmlCopyRisk();
|
||||||
|
expect(mod.isAiHtmlCopyRiskAcknowledged()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets when the app session restarts (fresh module graph)", async () => {
|
||||||
|
const first = await import("@/lib/ai/richContent/aiHtmlPreview");
|
||||||
|
first.acknowledgeAiHtmlCopyRisk();
|
||||||
|
vi.resetModules();
|
||||||
|
const second = await import("@/lib/ai/richContent/aiHtmlPreview");
|
||||||
|
expect(second.isAiHtmlCopyRiskAcknowledged()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AI_HTML_PREVIEW_CSP, buildSafeHtmlPreview } from "@/lib/ai/richContent/aiHtmlPreview";
|
||||||
|
|
||||||
|
// The canary payload tries every escape the PRD requires evidence for: inline
|
||||||
|
// script, remote image, fetch, form submit, and <base> rewriting. It must
|
||||||
|
// survive verbatim into the wrapped document (a canary that was stripped proves
|
||||||
|
// nothing) — the CSP shell, not content filtering, is what neutralizes it.
|
||||||
|
const canaryVectors = [
|
||||||
|
'<script>document.title = "SCRIPT-RAN"; document.body.style.background = "red"; fetch("https://canary.invalid/fetch");</script>',
|
||||||
|
'<img src="https://canary.invalid/remote.png" alt="remote">',
|
||||||
|
'<img src="https://canary.invalid/onerror.png" onerror="document.title = \'ONERROR-RAN\'" alt="onerror">',
|
||||||
|
'<form action="https://canary.invalid/submit"><input type="submit" value="submit"></form>',
|
||||||
|
'<base href="https://canary.invalid/">',
|
||||||
|
'<a href="relative.png">relative link</a>',
|
||||||
|
// The relative <img> fires an auto-load with no click required: if `base-uri
|
||||||
|
// 'none'` failed, <base href> would rewrite it to
|
||||||
|
// https://canary.invalid/relative.png and the netlog would expose the leak
|
||||||
|
// without manual interaction.
|
||||||
|
'<img src="relative.png" alt="relative">',
|
||||||
|
// Visible only when scripting is DISABLED (context A): a green PASS marker.
|
||||||
|
'<noscript><p style="color:green;margin:0;font:600 13px system-ui">scripts are blocked → PASS</p></noscript>',
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Navigation canary — deliberately NOT in canaryVectors: every vector above
|
||||||
|
// must survive verbatim (a stripped canary proves nothing), but no CSP fetch
|
||||||
|
// directive governs navigation and `sandbox=""` only gates top-navigation and
|
||||||
|
// popups, so a sandboxed iframe can still navigate itself. The refresh meta is
|
||||||
|
// the one vector the wrapper itself must defuse before it reaches either
|
||||||
|
// context; a zero-click `<meta refresh>` is an automatic outbound beacon.
|
||||||
|
const metaRefreshCanary = '<meta http-equiv="refresh" content="0;url=https://canary.invalid/">';
|
||||||
|
|
||||||
|
const componentSource = readFileSync(new URL("../../../../components/ai/rich/AiHtmlPreview.vue", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
describe("AiHtmlPreview sandbox hardening (static canary)", () => {
|
||||||
|
it("sandboxes every preview iframe without any escape hatch", () => {
|
||||||
|
// Inspect every actual iframe tag, not the whole source (comments mention the
|
||||||
|
// sandbox tokens by name when documenting why they are absent). The inline
|
||||||
|
// card and the expanded dialog iframes must carry identical boundaries.
|
||||||
|
const iframeTags = componentSource.match(/<iframe[^>]*>/g) ?? [];
|
||||||
|
expect(iframeTags.length).toBeGreaterThanOrEqual(2);
|
||||||
|
for (const iframeTag of iframeTags) {
|
||||||
|
expect(iframeTag).toContain('sandbox=""');
|
||||||
|
for (const forbidden of ["allow-scripts", "allow-same-origin", "allow-forms", "allow-popups", "allow-modals", "allow-top-navigation"]) {
|
||||||
|
expect(iframeTag).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the wrapped shell, never the raw source, as the iframe payload", () => {
|
||||||
|
expect(componentSource).toContain(':srcdoc="document"');
|
||||||
|
// The component has no path to inject `content` into the DOM itself.
|
||||||
|
expect(componentSource).not.toContain('v-html="content"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the canary vectors present in the wrapped document while the CSP neutralizes each", () => {
|
||||||
|
const wrapped = buildSafeHtmlPreview(canaryVectors);
|
||||||
|
// Every vector is still in the payload — nothing was stripped or rewritten.
|
||||||
|
for (const vector of canaryVectors.split("\n")) {
|
||||||
|
expect(wrapped).toContain(vector);
|
||||||
|
}
|
||||||
|
const position = (needle: string) => wrapped.indexOf(needle);
|
||||||
|
// The CSP meta precedes every vector: the policy applies document-wide.
|
||||||
|
for (const vector of canaryVectors.split("\n")) {
|
||||||
|
expect(position("Content-Security-Policy")).toBeLessThan(position(vector));
|
||||||
|
}
|
||||||
|
// Vector → neutralizer mapping (per the PRD's canary matrix):
|
||||||
|
// inline/`onerror=` scripts and `fetch()` → no script-src grant, blocked by default-src 'none'
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("default-src 'none'");
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).not.toContain("script-src");
|
||||||
|
// remote img → img-src limited to data:/blob:
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("img-src data: blob:");
|
||||||
|
// form submit target → form-action 'none' (does NOT inherit default-src)
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("form-action 'none'");
|
||||||
|
// <base> rewrite → base-uri 'none' (does NOT inherit default-src)
|
||||||
|
expect(AI_HTML_PREVIEW_CSP).toContain("base-uri 'none'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defuses the meta-refresh navigation canary (navigation is outside the CSP fetch model)", () => {
|
||||||
|
const wrapped = buildSafeHtmlPreview(`${canaryVectors}\n${metaRefreshCanary}`);
|
||||||
|
// The verbatim-survival canaries are untouched by the defusing pass...
|
||||||
|
for (const vector of canaryVectors.split("\n")) {
|
||||||
|
expect(wrapped).toContain(vector);
|
||||||
|
}
|
||||||
|
// ...while the refresh meta is swapped for an inert comment: the wrapped
|
||||||
|
// document holds no live http-equiv=refresh in any spelling.
|
||||||
|
expect(wrapped).not.toContain(metaRefreshCanary);
|
||||||
|
expect(wrapped).toContain("<!-- meta refresh removed -->");
|
||||||
|
expect(wrapped).not.toMatch(/<meta\b[^>]*\bhttp-equiv\s*=\s*["']?\s*refresh\b/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a manual WebView canary harness when AI_HTML_CANARY_OUT is set", () => {
|
||||||
|
const outPath = process.env.AI_HTML_CANARY_OUT;
|
||||||
|
if (!outPath) {
|
||||||
|
// Regenerate on demand: AI_HTML_CANARY_OUT=<path> pnpm vitest run <this file>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wrapped = buildSafeHtmlPreview(`${canaryVectors}\n${metaRefreshCanary}`);
|
||||||
|
// The visible PASS marker inside the sandbox: `noscript` renders only when
|
||||||
|
// scripting is disabled — i.e. exactly when the sandbox held.
|
||||||
|
const harness = [
|
||||||
|
"<!doctype html>",
|
||||||
|
'<html lang="en"><head><meta charset="utf-8"><title>AI HTML preview runtime canary</title>',
|
||||||
|
"<style>body{font-family:system-ui;max-width:60rem;margin:2rem auto;padding:0 1rem;line-height:1.5}",
|
||||||
|
"iframe{width:100%;height:16rem;border:2px solid #888;background:#fff}",
|
||||||
|
"li{margin:.25rem 0}code{background:#f2f2f2;padding:0 .3rem}</style></head><body>",
|
||||||
|
"<h1>AI HTML preview runtime canary</h1>",
|
||||||
|
"<p>This harness embeds the exact bytes <code>buildSafeHtmlPreview</code> produces (the same payload “Save Safe HTML” writes). Open it in the app WebView (dev build) and in a regular browser. All checks must show <b>PASS</b> and the network panel must stay empty.</p>",
|
||||||
|
"<h2>Context A — iframe preview (sandbox="")</h2>",
|
||||||
|
`<iframe sandbox="" srcdoc="${wrapped.replace(/"/g, """)}"></iframe>`,
|
||||||
|
"<h2>Context B — saved standalone file</h2>",
|
||||||
|
"<p>Click “Download wrapped file”, then open the downloaded copy directly in the WebView/browser: outside the sandbox, the CSP meta alone must hold.</p>",
|
||||||
|
// `<\/` keeps the canary's own `</script>` from terminating this harness script.
|
||||||
|
`<script>const blob = new Blob([${JSON.stringify(wrapped).replace(/<\//g, "<\\/")}], {type: "text/html"});const a = document.createElement("a");a.href = URL.createObjectURL(blob);a.download = "ai-html-canary-wrapped.html";a.textContent = "Download wrapped file";document.body.appendChild(a);</script>`,
|
||||||
|
"<h2>Checklist (both contexts)</h2><ul>",
|
||||||
|
"<li><b>Script:</b> the area stays WHITE — a red background or <code>SCRIPT-RAN</code> title means FAIL. Inside context A a green “scripts are blocked → PASS” note is visible (that is <code><noscript></code> rendering).</li>",
|
||||||
|
"<li><b>Remote image:</b> both canary <code><img></code> elements render as broken-image placeholders, never loaded (DevTools network: no request to <code>canary.invalid</code>).</li>",
|
||||||
|
"<li><b>fetch:</b> no network request to <code>canary.invalid</code>.</li>",
|
||||||
|
"<li><b>Form:</b> submitting the canary form does nothing — no navigation to <code>canary.invalid</code>.</li>",
|
||||||
|
"<li><b>Base rewrite:</b> the “relative link” does not resolve to <code>canary.invalid/relative.png</code>.</li>",
|
||||||
|
"<li><b>Meta refresh:</b> the document does not navigate away on its own — the canary refresh meta must appear only as the inert <code><!-- meta refresh removed --></code> comment.</li>",
|
||||||
|
"</ul></body></html>",
|
||||||
|
].join("\n");
|
||||||
|
writeFileSync(outPath, harness, "utf8");
|
||||||
|
expect(readFileSync(outPath, "utf8")).toContain('sandbox=""');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { AI_RICH_BLOCK_HANDLERS, isAiRichBlockLanguage } from "@/lib/ai/richContent/aiRichContent";
|
import { AI_RICH_BLOCK_HANDLERS, isAiRichBlockLanguage } from "@/lib/ai/richContent/aiRichContent";
|
||||||
import { parseAiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
|
import { parseAiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
|
||||||
|
import { AI_HTML_MAX_CONTENT_CHARS, AI_HTML_PREVIEW_CSP, buildSafeHtmlPreview } from "@/lib/ai/richContent/aiHtmlPreview";
|
||||||
|
|
||||||
const validBar = JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["Jan", "Feb"] }, series: [{ name: "Revenue", data: [120, 200] }] });
|
const validBar = JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["Jan", "Feb"] }, series: [{ name: "Revenue", data: [120, 200] }] });
|
||||||
const invalidBar = "{ not json";
|
const invalidBar = "{ not json";
|
||||||
|
const validHtml = "<p>Hello <strong>world</strong></p>";
|
||||||
|
|
||||||
describe("AI_RICH_BLOCK_HANDLERS", () => {
|
describe("AI_RICH_BLOCK_HANDLERS", () => {
|
||||||
it("registers exactly the chart-json handler in V1", () => {
|
it("registers exactly the chart-json and html handlers in V1", () => {
|
||||||
expect(Object.keys(AI_RICH_BLOCK_HANDLERS)).toEqual(["chart-json"]);
|
expect(Object.keys(AI_RICH_BLOCK_HANDLERS)).toEqual(["chart-json", "html"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for an unfinished fence (closed=false) without parsing", () => {
|
it("returns null for an unfinished fence (closed=false) without parsing", () => {
|
||||||
@@ -32,14 +34,43 @@ describe("AI_RICH_BLOCK_HANDLERS", () => {
|
|||||||
expect(handler.parse(JSON.stringify({ version: 1, type: "pie", data: [{ name: "A", value: -1 }] }), { closed: true })).toBeNull();
|
expect(handler.parse(JSON.stringify({ version: 1, type: "pie", data: [{ name: "A", value: -1 }] }), { closed: true })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps an unfinished html fence unpreviewed", () => {
|
||||||
|
const handler = AI_RICH_BLOCK_HANDLERS.html;
|
||||||
|
expect(handler.language).toBe("html");
|
||||||
|
expect(handler.parse(validHtml, { closed: false })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a closed html block into a segment carrying the safe document", () => {
|
||||||
|
const handler = AI_RICH_BLOCK_HANDLERS.html;
|
||||||
|
const segment = handler.parse(validHtml, { closed: true });
|
||||||
|
expect(segment?.type).toBe("html");
|
||||||
|
if (segment?.type !== "html") return;
|
||||||
|
expect(segment.content).toBe(validHtml);
|
||||||
|
// The document is the wrapped shell, never the raw source, and it is the
|
||||||
|
// exact bytes "Save Safe HTML" writes.
|
||||||
|
expect(segment.document).toBe(buildSafeHtmlPreview(validHtml));
|
||||||
|
expect(segment.document).toContain(AI_HTML_PREVIEW_CSP);
|
||||||
|
expect(segment.document).toContain(validHtml);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an over-budget html body deterministically", () => {
|
||||||
|
const handler = AI_RICH_BLOCK_HANDLERS.html;
|
||||||
|
const oversize = "x".repeat(AI_HTML_MAX_CONTENT_CHARS + 1);
|
||||||
|
expect(handler.parse(oversize, { closed: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not treat sql/bash/json as rich block languages", () => {
|
it("does not treat sql/bash/json as rich block languages", () => {
|
||||||
for (const lang of ["sql", "bash", "json", "SQL", "BASH", "chart-json "]) {
|
for (const lang of ["sql", "bash", "json", "SQL", "BASH"]) {
|
||||||
expect(isAiRichBlockLanguage(lang)).toBe(lang.trim().toLowerCase() === "chart-json");
|
expect(isAiRichBlockLanguage(lang)).toBe(false);
|
||||||
}
|
}
|
||||||
|
expect(isAiRichBlockLanguage("chart-json ")).toBe(true);
|
||||||
|
expect(isAiRichBlockLanguage("html")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches the raw language tag case-insensitively", () => {
|
it("matches the raw language tag case-insensitively", () => {
|
||||||
expect(isAiRichBlockLanguage("CHART-JSON")).toBe(true);
|
expect(isAiRichBlockLanguage("CHART-JSON")).toBe(true);
|
||||||
expect(isAiRichBlockLanguage(" Chart-Json ")).toBe(true);
|
expect(isAiRichBlockLanguage(" Chart-Json ")).toBe(true);
|
||||||
|
expect(isAiRichBlockLanguage("HTML")).toBe(true);
|
||||||
|
expect(isAiRichBlockLanguage(" Html ")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Safe HTML preview shell (V1).
|
||||||
|
*
|
||||||
|
* The AI may emit a fenced ```html block. It renders inside a fully sandboxed
|
||||||
|
* iframe (`sandbox=""` — no allow-scripts, no allow-same-origin) whose `srcdoc`
|
||||||
|
* is wrapped by {@link buildSafeHtmlPreview}. The wrapper inlines a CSP meta
|
||||||
|
* that is the single safety boundary in BOTH contexts the content can reach:
|
||||||
|
*
|
||||||
|
* - the iframe preview (`sandbox=""` already blocks script execution at the
|
||||||
|
* browser level; the CSP additionally blocks network fetches);
|
||||||
|
* - the standalone file written by "Save Safe HTML" — out of the sandbox, so
|
||||||
|
* the CSP meta alone must block scripts and network. The saved file is the
|
||||||
|
* wrapped document, never the raw source: once the content leaves the
|
||||||
|
* sandbox its protection is only as strong as this shell.
|
||||||
|
*
|
||||||
|
* `default-src 'none'` is the fallback for the fetch directives, but
|
||||||
|
* `form-action` and `base-uri` do NOT inherit from `default-src`, so both are
|
||||||
|
* declared explicitly: `base-uri 'none'` stops `<base>` from rewriting
|
||||||
|
* relative URLs, `form-action 'none'` stops `<form action=…>` from submitting
|
||||||
|
* to a remote target. There is deliberately no `script-src`: scripts fall back
|
||||||
|
* to `default-src 'none'` and are blocked outright.
|
||||||
|
*
|
||||||
|
* Navigation is the one direction no CSP fetch directive governs, and
|
||||||
|
* `sandbox=""` only gates top-navigation/popups — a sandboxed iframe may still
|
||||||
|
* navigate itself. So `<meta http-equiv="refresh">` (a zero-click outbound
|
||||||
|
* request beacon) is the single construct the wrapper defuses outright instead
|
||||||
|
* of passing it through to the CSP shell.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Max raw ```html fence body length (characters) that may enter the iframe `srcdoc`. */
|
||||||
|
export const AI_HTML_MAX_CONTENT_CHARS = 512 * 1024;
|
||||||
|
|
||||||
|
export const AI_HTML_PREVIEW_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:";
|
||||||
|
|
||||||
|
/** True when the raw fence body may be previewed / saved. Oversize stays a plain code segment. */
|
||||||
|
export function isAiHtmlPreviewEligible(content: string): boolean {
|
||||||
|
return content.length > 0 && content.length <= AI_HTML_MAX_CONTENT_CHARS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches any `<meta>` carrying `http-equiv=refresh`: case-insensitive on the
|
||||||
|
* tag name and the `http-equiv` key/value, tolerant of unquoted, single- or
|
||||||
|
* double-quoted values in any attribute order with extra attributes present.
|
||||||
|
*/
|
||||||
|
const AI_HTML_META_REFRESH_PATTERN = /<meta\b[^>]*?\bhttp-equiv\s*=\s*(?:"\s*refresh\s*"|'\s*refresh\s*'|refresh\b)[^>]*>/gi;
|
||||||
|
|
||||||
|
/** Inert replacement for a defused refresh meta: a comment navigates nowhere. */
|
||||||
|
const AI_HTML_META_REFRESH_REPLACEMENT = "<!-- meta refresh removed -->";
|
||||||
|
|
||||||
|
/** Strip every `http-equiv=refresh` meta; everything else passes through byte-identical. */
|
||||||
|
function defuseMetaRefresh(content: string): string {
|
||||||
|
return content.replace(AI_HTML_META_REFRESH_PATTERN, AI_HTML_META_REFRESH_REPLACEMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap raw AI HTML in the safe standalone document. The same bytes back BOTH
|
||||||
|
* the iframe preview and the "Save Safe HTML" payload, so what is previewed is
|
||||||
|
* exactly what gets saved. If the AI content is itself a complete document,
|
||||||
|
* the browser's error recovery nests it inside the body while the outer CSP
|
||||||
|
* meta still applies document-wide. The only rewrite is the refresh meta
|
||||||
|
* defused above — navigation sits outside the CSP fetch model.
|
||||||
|
*/
|
||||||
|
export function buildSafeHtmlPreview(content: string): string {
|
||||||
|
return ["<!doctype html>", "<html>", "<head>", '<meta charset="utf-8">', `<meta http-equiv="Content-Security-Policy" content="${AI_HTML_PREVIEW_CSP}">`, "</head>", "<body>", defuseMetaRefresh(content), "</body>", "</html>"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Copy source" puts the raw AI HTML on the clipboard where it may later be
|
||||||
|
* opened outside DBX. The first copy of a session requires an explicit risk
|
||||||
|
* confirmation; the choice is remembered for the session only (it resets when
|
||||||
|
* the app restarts), and later copies degrade to a risk toast instead of
|
||||||
|
* repeating the confirmation.
|
||||||
|
*/
|
||||||
|
let htmlCopyRiskAcknowledged = false;
|
||||||
|
|
||||||
|
export function isAiHtmlCopyRiskAcknowledged(): boolean {
|
||||||
|
return htmlCopyRiskAcknowledged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acknowledgeAiHtmlCopyRisk(): void {
|
||||||
|
htmlCopyRiskAcknowledged = true;
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Rich Content Registry (V1 — charts only).
|
* Rich Content Registry (V1 — charts and safe HTML preview).
|
||||||
*
|
*
|
||||||
* A thin lookup that routes a fenced code block to a typed renderer by its RAW
|
* A thin lookup that routes a fenced code block to a typed renderer by its RAW
|
||||||
* fenced language tag. The registry key must match before
|
* fenced language tag. The registry key must match before
|
||||||
* `normalizeAiCodeLanguage`'s SQL/SHELL label map runs: SQL/SHELL families
|
* `normalizeAiCodeLanguage`'s SQL/SHELL label map runs: SQL/SHELL families
|
||||||
* never enter the registry and keep the existing proposal/confirm/execute code
|
* never enter the registry and keep the existing proposal/confirm/execute code
|
||||||
* path. V1 registers only `chart-json`; later PRs add `html` (safe preview) and
|
* path. V1 registers `chart-json` and `html`; future artifact types (mermaid,
|
||||||
* future artifact types as new handlers without touching the parser core.
|
* tables, files) join as new handlers without touching the parser core.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseAiChartSpec, type AiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
|
import { parseAiChartSpec, type AiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
|
||||||
|
import { buildSafeHtmlPreview, isAiHtmlPreviewEligible } from "@/lib/ai/richContent/aiHtmlPreview";
|
||||||
|
|
||||||
/** Segment produced for a successfully parsed rich block (PR1: charts only). */
|
/** Segment produced for a successfully parsed chart-json block. */
|
||||||
export interface AiMessageChartSegment {
|
export interface AiMessageChartSegment {
|
||||||
type: "chart";
|
type: "chart";
|
||||||
/** The raw chart-json fence body (preserved for copy / export / debugging). */
|
/** The raw chart-json fence body (preserved for copy / export / debugging). */
|
||||||
@@ -20,8 +21,21 @@ export interface AiMessageChartSegment {
|
|||||||
spec: AiChartSpec;
|
spec: AiChartSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Union of rich segments; V1 has only the chart member. */
|
/**
|
||||||
export type AiMessageRichSegment = AiMessageChartSegment;
|
* Segment produced for an eligible ```html block. `document` is the
|
||||||
|
* CSP-wrapped standalone shell used for BOTH the iframe preview and the
|
||||||
|
* "Save Safe HTML" payload, so the preview and the saved file never drift.
|
||||||
|
*/
|
||||||
|
export interface AiMessageHtmlSegment {
|
||||||
|
type: "html";
|
||||||
|
/** The raw html fence body (what "copy source" puts on the clipboard). */
|
||||||
|
content: string;
|
||||||
|
/** `buildSafeHtmlPreview(content)` — rendered in the sandboxed iframe. */
|
||||||
|
document: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union of rich segments. */
|
||||||
|
export type AiMessageRichSegment = AiMessageChartSegment | AiMessageHtmlSegment;
|
||||||
|
|
||||||
export interface AiRichBlockFlags {
|
export interface AiRichBlockFlags {
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +67,19 @@ function chartHandler(): AiRichBlockHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function htmlHandler(): AiRichBlockHandler {
|
||||||
|
return {
|
||||||
|
language: "html",
|
||||||
|
parse(content, flags) {
|
||||||
|
if (!flags.closed) return null;
|
||||||
|
// Over-budget (or empty) bodies stay a plain code segment: still fully
|
||||||
|
// visible and copyable, just never handed to the preview pipeline.
|
||||||
|
if (!isAiHtmlPreviewEligible(content)) return null;
|
||||||
|
return { type: "html", content, document: buildSafeHtmlPreview(content) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry keyed by the raw fenced lang (lowercased, trimmed). The caller in
|
* Registry keyed by the raw fenced lang (lowercased, trimmed). The caller in
|
||||||
* `aiMessageRender.ts` performs the lookup BEFORE `normalizeAiCodeLanguage`, so
|
* `aiMessageRender.ts` performs the lookup BEFORE `normalizeAiCodeLanguage`, so
|
||||||
@@ -60,9 +87,10 @@ function chartHandler(): AiRichBlockHandler {
|
|||||||
*/
|
*/
|
||||||
export const AI_RICH_BLOCK_HANDLERS: Record<string, AiRichBlockHandler> = {
|
export const AI_RICH_BLOCK_HANDLERS: Record<string, AiRichBlockHandler> = {
|
||||||
"chart-json": chartHandler(),
|
"chart-json": chartHandler(),
|
||||||
|
html: htmlHandler(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Language tags the rich registry owns (V1: `chart-json`). */
|
/** Language tags the rich registry owns (V1: `chart-json`, `html`). */
|
||||||
export function isAiRichBlockLanguage(rawLang: string): boolean {
|
export function isAiRichBlockLanguage(rawLang: string): boolean {
|
||||||
return Object.prototype.hasOwnProperty.call(AI_RICH_BLOCK_HANDLERS, rawLang.trim().toLowerCase());
|
return Object.prototype.hasOwnProperty.call(AI_RICH_BLOCK_HANDLERS, rawLang.trim().toLowerCase());
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"check": "node scripts/run-check.mjs",
|
"check": "node scripts/run-check.mjs",
|
||||||
"db:env": "node scripts/database-env.mjs",
|
"db:env": "node scripts/database-env.mjs",
|
||||||
"test:db-env": "node --test scripts/database-env.test.mjs",
|
"test:db-env": "node --test scripts/database-env.test.mjs",
|
||||||
|
"test:ai-html-canary": "node scripts/ai-html-runtime-canary.mjs",
|
||||||
"bench:redis-key-search": "node scripts/bench/redis-key-search.mjs",
|
"bench:redis-key-search": "node scripts/bench/redis-key-search.mjs",
|
||||||
"bench:mongodb-count": "node scripts/bench/mongodb-count.mjs",
|
"bench:mongodb-count": "node scripts/bench/mongodb-count.mjs",
|
||||||
"bench:table-import": "cargo run -p dbx-core --no-default-features --example table_import_bench --release --",
|
"bench:table-import": "cargo run -p dbx-core --no-default-features --example table_import_bench --release --",
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI HTML preview runtime canary — repeatable E2E runner (PRD R8).
|
||||||
|
*
|
||||||
|
* PRD requires runtime evidence in TWO contexts for the safe-HTML boundary, not
|
||||||
|
* just a CSP string assertion:
|
||||||
|
*
|
||||||
|
* Context A — the in-app iframe preview: the sandboxed iframe (`sandbox=""`)
|
||||||
|
* inside the manually generated harness.
|
||||||
|
* Context B — the standalone wrapped file that "Save Safe HTML" writes:
|
||||||
|
* opened directly in the browser, OUTSIDE the sandbox, where the
|
||||||
|
* inlined CSP meta alone must hold. This is exactly the scene in
|
||||||
|
* which `form-action 'none'` / `base-uri 'none'` must be proven
|
||||||
|
* (they do not inherit `default-src`).
|
||||||
|
*
|
||||||
|
* For each context a headless Chromium run captures a `--log-net-log` trace and
|
||||||
|
* a `--dump-dom`. PASS means: both netlogs are non-empty and contain URL_REQUEST
|
||||||
|
* events (guards against a broken capture producing a vacuous pass), neither
|
||||||
|
* netlog contains a request to `canary.invalid`, and neither DOM dump shows a
|
||||||
|
* script-execution trace — the SCRIPT-RAN / ONERROR-RAN title markers (raw
|
||||||
|
* strings in the source text do not count: the regexes match element serialized
|
||||||
|
* output only) or a red document background.
|
||||||
|
*
|
||||||
|
* Browser discovery: AI_HTML_CANARY_BROWSER overrides; otherwise standard
|
||||||
|
* install paths for Microsoft Edge / Google Chrome are probed (win32, darwin,
|
||||||
|
* linux). A failure to find a browser prints guidance and exits non-zero.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..");
|
||||||
|
const CANARY_SPEC = "apps/desktop/src/lib/ai/richContent/__tests__/aiHtmlPreviewCanary.spec.ts";
|
||||||
|
const HEADLESS_ARGS = [
|
||||||
|
"--headless=new",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BROWSER_CANDIDATES = process.platform === "win32"
|
||||||
|
? [
|
||||||
|
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
||||||
|
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
||||||
|
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||||
|
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||||
|
]
|
||||||
|
: process.platform === "darwin"
|
||||||
|
? [
|
||||||
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
]
|
||||||
|
: ["msedge", "google-chrome", "chromium", "chromium-browser"];
|
||||||
|
|
||||||
|
function browserCandidates() {
|
||||||
|
const override = process.env.AI_HTML_CANARY_BROWSER;
|
||||||
|
if (override) return [override];
|
||||||
|
return BROWSER_CANDIDATES.filter((candidate) => {
|
||||||
|
if (candidate.includes("/") || candidate.includes("\\")) return existsSync(candidate);
|
||||||
|
return spawnSync(candidate, ["--version"], { stdio: "ignore" }).status === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBrowser() {
|
||||||
|
const candidates = browserCandidates();
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error(`No Chromium-based browser found for this platform. Set AI_HTML_CANARY_BROWSER to the msedge/chrome executable.`);
|
||||||
|
}
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoke vitest from the repository root so the `@` alias and vitest config resolve. */
|
||||||
|
function runVitestHarness(harnessOutPath) {
|
||||||
|
const vitestBin = resolve(REPO_ROOT, "node_modules/vitest/vitest.mjs");
|
||||||
|
if (!existsSync(vitestBin)) throw new Error(`vitest entry not found at ${vitestBin}; run pnpm install first`);
|
||||||
|
const args = ["--no-warnings", vitestBin, "run", CANARY_SPEC];
|
||||||
|
const result = spawnSync(process.execPath, args, {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, AI_HTML_CANARY_OUT: harnessOutPath },
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`vitest harness generation failed (exit ${result.status})\n${result.stdout}${result.stderr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the wrapped payload the harness embeds verbatim: the spec writes the
|
||||||
|
* exact bytes `buildSafeHtmlPreview` produces into a Blob constructor. Parse the
|
||||||
|
* JSON literal back out and verify it round-trips (a second parse returns the
|
||||||
|
* identical string) before it stands in for the "Save Safe HTML" payload.
|
||||||
|
*/
|
||||||
|
function extractWrappedPayload(harness) {
|
||||||
|
const match = harness.match(/new Blob\(\[([\s\S]*?)\], \{type: "text\/html"\}\)/);
|
||||||
|
if (!match) throw new Error(`Could not extract wrapped payload from harness ${harness}`);
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(match[1]);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Wrapped payload in harness is not valid JSON");
|
||||||
|
}
|
||||||
|
if (JSON.parse(match[1]) !== payload) {
|
||||||
|
throw new Error("Wrapped payload did not round-trip through JSON.stringify");
|
||||||
|
}
|
||||||
|
if (!payload.includes("Content-Security-Policy")) {
|
||||||
|
throw new Error("Wrapped payload has no CSP meta; cannot serve as the safe standalone file");
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run one context: fileUrl is the harness (A) or the wrapped file (B). */
|
||||||
|
function runHeadless(browser, filePath, userDataDir, netlogPath) {
|
||||||
|
const fileUrl = pathToFileURL(filePath).href;
|
||||||
|
const dumpPath = `${netlogPath}.dom.html`;
|
||||||
|
const args = [
|
||||||
|
...HEADLESS_ARGS,
|
||||||
|
`--user-data-dir=${userDataDir}`,
|
||||||
|
`--log-net-log=${netlogPath}`,
|
||||||
|
"--virtual-time-budget=8000",
|
||||||
|
"--dump-dom",
|
||||||
|
fileUrl,
|
||||||
|
];
|
||||||
|
let dom = "";
|
||||||
|
try {
|
||||||
|
dom = execFileSync(browser, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
} catch (error) {
|
||||||
|
// --dump-dom can still exit non-zero on some versions even after writing output.
|
||||||
|
dom = error.stdout ? String(error.stdout) : "";
|
||||||
|
}
|
||||||
|
writeFileSync(dumpPath, dom, "utf8");
|
||||||
|
return { dom, dumpPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeNetlog(netlogPath) {
|
||||||
|
if (!existsSync(netlogPath)) return { urlRequestCount: 0, canaryHits: 0, error: "netlog missing" };
|
||||||
|
let log;
|
||||||
|
try {
|
||||||
|
log = JSON.parse(readFileSync(netlogPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return { urlRequestCount: 0, canaryHits: 0, error: "netlog is not valid JSON" };
|
||||||
|
}
|
||||||
|
const constants = log.constants ?? {};
|
||||||
|
const eventTypes = constants.logEventTypes ?? {};
|
||||||
|
const byId = {};
|
||||||
|
for (const name of Object.keys(eventTypes)) byId[eventTypes[name]] = name;
|
||||||
|
const events = Array.isArray(log.events) ? log.events : [];
|
||||||
|
const urlRequests = events.filter((event) => {
|
||||||
|
const name = byId[event.type];
|
||||||
|
return typeof name === "string" && name.includes("URL_REQUEST");
|
||||||
|
});
|
||||||
|
const canaryHits = events.filter((event) => JSON.stringify(event.params ?? {}).includes("canary.invalid")).length;
|
||||||
|
return { urlRequestCount: urlRequests.length, canaryHits, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Element-serialized evidence only: the raw SCRIPT-RAN / ONERROR-RAN strings
|
||||||
|
* also live in the source text of the wrapped body, so match the serialized
|
||||||
|
* <title> element and the <body> background style, never plain substrings. */
|
||||||
|
function analyzeDom(dom) {
|
||||||
|
return {
|
||||||
|
scriptRan: /<title>\s*SCRIPT-RAN/.test(dom),
|
||||||
|
onerrorRan: /<title>\s*ONERROR-RAN/.test(dom),
|
||||||
|
redBackground: /<body[^>]*style="[^"]*background[^"]*red/i.test(dom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtStats(name, stats) {
|
||||||
|
return ` ${name}: URL_REQUEST events=${stats.urlRequestCount} canary.invalid hits=${stats.canaryHits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "ai-html-canary-"));
|
||||||
|
let pass = true;
|
||||||
|
const failures = [];
|
||||||
|
const contexts = [];
|
||||||
|
try {
|
||||||
|
const browser = resolveBrowser();
|
||||||
|
console.log(`browser: ${browser}`);
|
||||||
|
const harnessPath = join(workDir, "ai-html-canary.html");
|
||||||
|
const wrappedPath = join(workDir, "ai-html-canary-wrapped.html");
|
||||||
|
|
||||||
|
// 1. Regenerate the harness through vitest so the payload cannot drift from
|
||||||
|
// the buildSafeHtmlPreview implementation.
|
||||||
|
console.log("regenerating harness via vitest …");
|
||||||
|
runVitestHarness(harnessPath);
|
||||||
|
const harness = readFileSync(harnessPath, "utf8");
|
||||||
|
const wrappedPayload = extractWrappedPayload(harness);
|
||||||
|
writeFileSync(wrappedPath, wrappedPayload, "utf8");
|
||||||
|
console.log(` harness: ${harnessPath}`);
|
||||||
|
console.log(` wrapped payload: ${wrappedPath} (${wrappedPayload.length} bytes)`);
|
||||||
|
|
||||||
|
// 2. Two independent headless runs, one per context, each with its own
|
||||||
|
// profile (the previous context's origin must not leak into the next).
|
||||||
|
const runs = [
|
||||||
|
{ key: "A", file: harnessPath },
|
||||||
|
{ key: "B", file: wrappedPath },
|
||||||
|
];
|
||||||
|
for (const run of runs) {
|
||||||
|
const userDataDir = join(workDir, `profile-${run.key}`);
|
||||||
|
const netlogPath = join(workDir, `netlog-${run.key}.json`);
|
||||||
|
console.log(`running context ${run.key} (${run.file}) …`);
|
||||||
|
const { dom } = runHeadless(browser, run.file, userDataDir, netlogPath);
|
||||||
|
const netlogStats = analyzeNetlog(netlogPath);
|
||||||
|
contexts.push({ key: run.key, netlogPath, dumpPath: `${netlogPath}.dom.html`, netlogStats, domStats: analyzeDom(dom), dom });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Assert every context independently.
|
||||||
|
for (const ctx of contexts) {
|
||||||
|
console.log(fmtStats(`context ${ctx.key}`, ctx.netlogStats));
|
||||||
|
const label = `context ${ctx.key}`;
|
||||||
|
if (ctx.netlogStats.error) {
|
||||||
|
pass = false;
|
||||||
|
failures.push(`${label}: ${ctx.netlogStats.error}`);
|
||||||
|
} else {
|
||||||
|
if (ctx.netlogStats.urlRequestCount === 0) {
|
||||||
|
pass = false;
|
||||||
|
failures.push(`${label}: netlog has no URL_REQUEST events (capture failed; cannot prove absence)`);
|
||||||
|
}
|
||||||
|
if (ctx.netlogStats.canaryHits > 0) {
|
||||||
|
pass = false;
|
||||||
|
failures.push(`${label}: netlog contains ${ctx.netlogStats.canaryHits} request(s) to canary.invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ctx.domStats.scriptRan) {
|
||||||
|
pass = false;
|
||||||
|
failures.push(`${label}: script executed — <title>SCRIPT-RAN</title> present in DOM`);
|
||||||
|
}
|
||||||
|
if (ctx.domStats.onerrorRan) {
|
||||||
|
pass = false;
|
||||||
|
failures.push(`${label}: onerror script executed — <title>ONERROR-RAN</title> present in DOM`);
|
||||||
|
}
|
||||||
|
if (ctx.domStats.redBackground) {
|
||||||
|
pass = false;
|
||||||
|
failures.push(`${label}: script executed — red body background in DOM`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Context A additionally proves the iframe carries the sandbox attribute.
|
||||||
|
const contextA = contexts.find((ctx) => ctx.key === "A");
|
||||||
|
if (contextA && !/<iframe[^>]*\ssandbox(\s|$|=)/i.test(contextA.dom)) {
|
||||||
|
pass = false;
|
||||||
|
failures.push("context A: preview iframe is missing the sandbox attribute in the DOM");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!process.env.AI_HTML_CANARY_KEEP_TMP) rmSync(workDir, { recursive: true, force: true });
|
||||||
|
else console.log(`artifacts kept in ${workDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(pass ? "PASS" : "FAIL");
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(failures.map((failure) => `- ${failure}`).join("\n"));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in new issue
Block a user