diff --git a/api/routes.py b/api/routes.py
index 32ba020a..edef7ed5 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -6908,6 +6908,51 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
return True
+def _html_preview_with_blank_base(raw: bytes) -> bytes:
+ base = ''
+ text = raw.decode("utf-8", errors="replace")
+ if re.search(r"
]*)?>", text, flags=re.IGNORECASE):
+ text = re.sub(r"(]*>)", r"\1" + base, text, count=1, flags=re.IGNORECASE)
+ elif re.search(r"]*>", text, flags=re.IGNORECASE):
+ text = re.sub(
+ r"(]*>)",
+ r"\1" + base + "",
+ text,
+ count=1,
+ flags=re.IGNORECASE,
+ )
+ else:
+ text = "" + base + "" + text
+ return text.encode("utf-8")
+
+
+def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp: str):
+ """Serve sandboxed workspace HTML preview with links targeting a new tab."""
+ try:
+ body = _html_preview_with_blank_base(target.read_bytes())
+ except PermissionError:
+ return bad(handler, "Permission denied", 403)
+ except Exception:
+ return bad(handler, "Could not read file", 500)
+
+ handler.send_response(200)
+ handler.send_header("Content-Type", "text/html; charset=utf-8")
+ handler.send_header("Content-Length", str(len(body)))
+ handler.send_header("Accept-Ranges", "none")
+ handler.send_header("Cache-Control", cache_control)
+ handler.send_header("Content-Disposition", _content_disposition_value("inline", target.name))
+ handler.send_header("Content-Security-Policy", csp)
+ handler.send_header("X-Content-Type-Options", "nosniff")
+ handler.send_header("Referrer-Policy", "same-origin")
+ handler.send_header(
+ "Permissions-Policy",
+ "camera=(), microphone=(self), geolocation=(), clipboard-write=(self)",
+ )
+ handler.end_headers()
+ handler.wfile.write(body)
+ return True
+
+
def _handle_media(handler, parsed):
"""Serve a local file by absolute path for inline display in the chat.
@@ -7213,8 +7258,10 @@ def _handle_file_raw(handler, parsed):
# CSP sandbox directive applies the same isolation server-side: without
# allow-same-origin, the document is treated as a unique opaque origin and
# cannot read WebUI cookies, localStorage, or postMessage to the parent.
- csp = "sandbox allow-scripts" if html_inline_ok else None
+ csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" if html_inline_ok else None
# _serve_file_bytes sends Content-Security-Policy when csp is set.
+ if html_inline_ok:
+ return _serve_inline_html_preview(handler, target, "no-store", csp=csp)
return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp)
diff --git a/static/index.html b/static/index.html
index fe54a92c..e5518943 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1327,7 +1327,7 @@
-
+
diff --git a/static/ui.js b/static/ui.js
index c6a5cd2a..6679c87a 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -2889,7 +2889,7 @@ function renderMd(raw){
t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]);
// Stash [label](url) links before autolink so the URL in href= is not re-linked
const _link_stash=[];
- t=t.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;});
+ t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;});
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;});
t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]);
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
@@ -2982,7 +2982,7 @@ function renderMd(raw){
// Stash existing tags first to avoid re-linking already-linked URLs.
const _a_stash=[];
s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
- s=s.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`);
+ s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`${esc(label)}`);
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
// Restore raw only after markdown rewrites so literal preformatted
// content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -3016,6 +3016,7 @@ function renderMd(raw){
if(!compact) return false;
if(/^(javascript|data|vbscript):/i.test(compact)) return false;
if(/^https?:\/\//i.test(raw)) return true;
+ if(/^(mailto:|tel:)/i.test(raw)) return true;
if(img && /^api\//i.test(raw)) return true;
if(!img && (/^api\//i.test(raw) || /^#/.test(raw))) return true;
return false;
diff --git a/tests/test_issue2768_workspace_links.py b/tests/test_issue2768_workspace_links.py
new file mode 100644
index 00000000..0733e200
--- /dev/null
+++ b/tests/test_issue2768_workspace_links.py
@@ -0,0 +1,88 @@
+import json
+import pathlib
+import re
+import subprocess
+import textwrap
+
+
+REPO = pathlib.Path(__file__).resolve().parents[1]
+UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
+INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
+ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
+
+
+def _extract_function(src: str, name: str) -> str:
+ marker = f"function {name}("
+ start = src.index(marker)
+ brace = src.index("{", start)
+ depth = 1
+ pos = brace + 1
+ while depth and pos < len(src):
+ ch = src[pos]
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ pos += 1
+ assert depth == 0, f"could not extract {name}()"
+ return src[start:pos]
+
+
+def _render(markdown: str) -> str:
+ js = textwrap.dedent(
+ r'''
+ const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
+ const _PDF_EXTS=/\.pdf$/i;
+ const _SVG_EXTS=/\.svg$/i;
+ const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
+ const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
+ function t(k){ return k; }
+ function _mediaPlayerHtml(){ return ''; }
+ global.document={baseURI:'http://example.test/'};
+ '''
+ )
+ js += "\n" + _extract_function(UI_JS, "_matchBacktickFenceLine")
+ js += "\n" + _extract_function(UI_JS, "_isBacktickFenceClose")
+ js += "\n" + _extract_function(UI_JS, "renderMd")
+ js += textwrap.dedent(
+ r'''
+ const input=process.argv[1];
+ process.stdout.write(JSON.stringify(renderMd(input)));
+ '''
+ )
+ proc = subprocess.run(
+ ["node", "-e", js, markdown],
+ cwd=REPO,
+ text=True,
+ capture_output=True,
+ timeout=30,
+ check=True,
+ )
+ return json.loads(proc.stdout)
+
+
+def test_workspace_markdown_renders_mailto_and_tel_links():
+ html = _render("[email](mailto:foo@example.test) and [phone](tel:+15551212)")
+
+ assert 'email' in html
+ assert 'phone' in html
+
+
+def test_workspace_html_iframe_allows_links_to_escape_sandbox():
+ iframe = re.search(r'