mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(tui): harden OSC8 URL boundaries (#100780)
* test(tui): cover parenthetical link rendering * fix(tui): trim unmatched URL parentheses * fix(tui): reject incomplete URL ranges * fix(tui): harden wrapped URL boundaries
This commit is contained in:
committed by
GitHub
parent
5000f324cb
commit
661cc82c6e
@@ -34,4 +34,13 @@ describe("HyperlinkMarkdown", () => {
|
||||
expect(normalized.some((line) => line.includes("is_palindrome"))).toBe(true);
|
||||
expect(normalized.some((line) => line.includes("__init__"))).toBe(true);
|
||||
});
|
||||
|
||||
it("links the complete parenthetical URL rendered by pi-tui", () => {
|
||||
const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)";
|
||||
const markdown = new HyperlinkMarkdown(`[Wikipedia](${url})`, 0, 0, markdownTheme);
|
||||
|
||||
const rendered = markdown.render(120).join("\n");
|
||||
|
||||
expect(rendered).toContain(`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,16 +48,37 @@ describe("extractUrls", () => {
|
||||
expect(urls).toEqual(["https://example.com/path?q=1&r=2#section"]);
|
||||
});
|
||||
|
||||
it("extracts a bare URL with a bracketed IPv6 authority", () => {
|
||||
const url = "http://[::1]:8080/path";
|
||||
expect(extractUrls(url)).toEqual([url]);
|
||||
});
|
||||
|
||||
it("extracts markdown link hrefs with parentheses in the URL", () => {
|
||||
const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)";
|
||||
expect(extractUrls(`[Wikipedia](${url})`)).toEqual([url]);
|
||||
});
|
||||
|
||||
it("does not extract an incomplete markdown link destination", () => {
|
||||
expect(extractUrls("[broken](https://)")).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["[broken](https://.)", "https://.", "https:///path"])(
|
||||
"does not extract a URL without a real authority from %s",
|
||||
(text) => {
|
||||
expect(extractUrls(text)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("handles bare URLs with trailing closing paren as punctuation", () => {
|
||||
const urls = extractUrls("(see https://example.com/path)");
|
||||
expect(urls).toEqual(["https://example.com/path"]);
|
||||
});
|
||||
|
||||
it("drops punctuation after an unmatched closing paren", () => {
|
||||
const urls = extractUrls("(see https://example.com/path).");
|
||||
expect(urls).toEqual(["https://example.com/path"]);
|
||||
});
|
||||
|
||||
it("handles markdown link with angle brackets and parenthetical URL", () => {
|
||||
const url = "https://en.wikipedia.org/wiki/Special_(film)";
|
||||
expect(extractUrls(`[link](<${url}>)`)).toEqual([url]);
|
||||
@@ -92,6 +113,44 @@ describe("addOsc8Hyperlinks", () => {
|
||||
expect(result[1]).toContain(`\x1b]8;;${fullUrl}\x07`);
|
||||
});
|
||||
|
||||
it("wraps a URL with a bracketed IPv6 authority", () => {
|
||||
const url = "http://[::1]:8080/path";
|
||||
expect(addOsc8Hyperlinks([url], [url])).toEqual([`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`]);
|
||||
});
|
||||
|
||||
it("wraps a URL broken immediately after its scheme", () => {
|
||||
const fullUrl = "https://example.com/path";
|
||||
const result = addOsc8Hyperlinks(["https://", "example.com/path"], [fullUrl]);
|
||||
|
||||
expect(result[0]).toBe(`\x1b]8;;${fullUrl}\x07https://\x1b]8;;\x07`);
|
||||
expect(result[1]).toBe(`\x1b]8;;${fullUrl}\x07example.com/path\x1b]8;;\x07`);
|
||||
});
|
||||
|
||||
it("does not cross-link a scheme-only fragment to a partial domain match", () => {
|
||||
const result = addOsc8Hyperlinks(["https://", "example.org"], ["https://example.com"]);
|
||||
|
||||
expect(result).toEqual(["https://", "example.org"]);
|
||||
});
|
||||
|
||||
it("does not cross-link a scheme-only fragment to a longer URL token", () => {
|
||||
const result = addOsc8Hyperlinks(
|
||||
["https://", "example.com/pathology"],
|
||||
["https://example.com/path"],
|
||||
);
|
||||
|
||||
expect(result).toEqual(["https://", "example.com/pathology"]);
|
||||
});
|
||||
|
||||
it("does not recover a punctuated incomplete URL as a wrapped URL", () => {
|
||||
const result = addOsc8Hyperlinks(["broken (https://)", "example.com"], ["https://example.com"]);
|
||||
|
||||
expect(result).toEqual(["broken (https://)", "example.com"]);
|
||||
});
|
||||
|
||||
it("does not wrap a punctuation-only URL body", () => {
|
||||
expect(addOsc8Hyperlinks(["https://."], ["https://."])).toEqual(["https://."]);
|
||||
});
|
||||
|
||||
it("handles URL with ANSI styling codes", () => {
|
||||
const url = "https://example.com";
|
||||
// Simulate styled text: green URL
|
||||
@@ -148,6 +207,13 @@ describe("addOsc8Hyperlinks", () => {
|
||||
expect(result[0]).toBe(`Wikipedia (\x1b]8;;${url}\x07${url}\x1b]8;;\x07)`);
|
||||
});
|
||||
|
||||
it("does not resolve an incomplete URL to another known URL", () => {
|
||||
const url = "https://example.com";
|
||||
const result = addOsc8Hyperlinks(["broken (https://)", url], [url]);
|
||||
expect(result[0]).toBe("broken (https://)");
|
||||
expect(result[1]).toContain(`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`);
|
||||
});
|
||||
|
||||
it("handles URL split across three lines", () => {
|
||||
const fullUrl = "https://example.com/a/very/long/path/that/keeps/going/and/going";
|
||||
const lines = ["https://example.com/a/very/lon", "g/path/that/keeps/going/and/g", "oing"];
|
||||
|
||||
+61
-27
@@ -9,32 +9,31 @@ const OSC8_START_RE = new RegExp(`^${OSC8_PATTERN}`);
|
||||
/** Allow one level of balanced parentheses inside a URL so markdown link
|
||||
* targets like `https://en.wikipedia.org/wiki/URL_(disambiguation)` are
|
||||
* fully captured instead of truncated at the first `)`. */
|
||||
const URL_PATH_WITH_PARENS = /https?:\/\/[^()\s<>]*(?:\([^()\s<>]*\)[^()\s<>]*)*/g;
|
||||
const URL_PATH_WITH_PARENS = /https?:\/\/[^()\s<>]+(?:\([^()\s<>]*\)[^()\s<>]*)*/g;
|
||||
|
||||
/** Strip trailing `)` characters that don't have a matching `(` in the URL.
|
||||
/** Strip the suffix starting at a `)` without a matching `(` in the URL.
|
||||
* Bare URLs in prose can pick up a trailing `)` that belongs to surrounding
|
||||
* punctuation, e.g. `(see https://example.com/path)` — the `)` after `path`
|
||||
* is sentence punctuation, not part of the URL. */
|
||||
* and anything after it are sentence punctuation, not part of the URL. */
|
||||
function trimUnbalancedTrailingParens(url: string): string {
|
||||
let open = 0;
|
||||
for (const ch of url) {
|
||||
for (let index = 0; index < url.length; index++) {
|
||||
const ch = url[index];
|
||||
if (ch === "(") {
|
||||
open++;
|
||||
} else if (ch === ")") {
|
||||
if (open === 0) {
|
||||
return url.slice(0, index);
|
||||
}
|
||||
open--;
|
||||
}
|
||||
}
|
||||
// If close parens outnumber open parens, the trailing ones are not
|
||||
// part of the URL — strip them.
|
||||
if (open >= 0) {
|
||||
return url;
|
||||
}
|
||||
let trimmed = url;
|
||||
while (trimmed.endsWith(")") && open < 0) {
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
open++;
|
||||
}
|
||||
return trimmed;
|
||||
return url;
|
||||
}
|
||||
|
||||
function hasUrlContent(url: string): boolean {
|
||||
const authority = url.slice(url.indexOf("://") + 3).split(/[/?#]/, 1)[0];
|
||||
return /[\p{L}\p{N}]/u.test(authority) || /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,14 +50,19 @@ export function extractUrls(markdown: string): string[] {
|
||||
);
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = mdLinkRe.exec(markdown)) !== null) {
|
||||
urls.add(m[1]);
|
||||
if (hasUrlContent(m[1])) {
|
||||
urls.add(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Bare URLs (remove markdown links first to avoid double-matching)
|
||||
const stripped = markdown.replace(mdLinkRe, "");
|
||||
const bareRe = /https?:\/\/[^\s\]>]+/g;
|
||||
const bareRe = /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>]*|[^\s[\]>]+)/gi;
|
||||
while ((m = bareRe.exec(stripped)) !== null) {
|
||||
urls.add(trimUnbalancedTrailingParens(m[0]));
|
||||
const url = trimUnbalancedTrailingParens(m[0]);
|
||||
if (hasUrlContent(url)) {
|
||||
urls.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
return [...urls];
|
||||
@@ -82,6 +86,7 @@ function findUrlRanges(
|
||||
visibleText: string,
|
||||
knownUrls: string[],
|
||||
pending: { url: string; consumed: number } | null,
|
||||
nextVisibleText?: string,
|
||||
): { ranges: UrlRange[]; pending: { url: string; consumed: number } | null } {
|
||||
const ranges: UrlRange[] = [];
|
||||
let newPending: { url: string; consumed: number } | null = null;
|
||||
@@ -117,7 +122,7 @@ function findUrlRanges(
|
||||
}
|
||||
|
||||
// Find new URL starts in visible text
|
||||
const urlRe = /https?:\/\/[^\s\]>]+/g;
|
||||
const urlRe = /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>]*|[^\s[\]>]*)/gi;
|
||||
urlRe.lastIndex = searchFrom;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -129,11 +134,40 @@ function findUrlRanges(
|
||||
let resolvedUrl = fragment;
|
||||
let found = false;
|
||||
|
||||
for (const known of knownUrls) {
|
||||
if (known === fragment) {
|
||||
resolvedUrl = known;
|
||||
found = true;
|
||||
break;
|
||||
// A wrap may split immediately after the scheme. Only accept that fragment
|
||||
// when the next line actually continues a known URL; otherwise a stray
|
||||
// `https://` could inherit an unrelated target from the URL list.
|
||||
if (!hasUrlContent(fragment)) {
|
||||
const hasUnpunctuatedSchemeAtLineEnd =
|
||||
fragment === match[0] && visibleText.slice(start + match[0].length).trim().length === 0;
|
||||
if (!hasUnpunctuatedSchemeAtLineEnd) {
|
||||
continue;
|
||||
}
|
||||
const nextToken = nextVisibleText?.trimStart().match(/^[^\s\]>]+/)?.[0] ?? "";
|
||||
const nextFragment = trimUnbalancedTrailingParens(nextToken);
|
||||
for (const known of knownUrls) {
|
||||
if (!known.startsWith(fragment)) {
|
||||
continue;
|
||||
}
|
||||
const remaining = known.slice(fragment.length);
|
||||
const continuesKnownUrl = nextFragment.length > 0 && remaining.startsWith(nextFragment);
|
||||
if (continuesKnownUrl && known.length > resolvedUrl.length) {
|
||||
resolvedUrl = known;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
for (const known of knownUrls) {
|
||||
if (known === fragment) {
|
||||
resolvedUrl = known;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
@@ -247,10 +281,10 @@ export function addOsc8Hyperlinks(lines: string[], urls: string[]): string[] {
|
||||
}
|
||||
|
||||
let pending: { url: string; consumed: number } | null = null;
|
||||
const visibleLines = lines.map(stripAnsi);
|
||||
|
||||
return lines.map((line) => {
|
||||
const visible = stripAnsi(line);
|
||||
const result = findUrlRanges(visible, urls, pending);
|
||||
return lines.map((line, index) => {
|
||||
const result = findUrlRanges(visibleLines[index], urls, pending, visibleLines[index + 1]);
|
||||
pending = result.pending;
|
||||
return applyOsc8Ranges(line, result.ranges);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user