From 53501dfa268d9857cce78128f765ea864a2a3697 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Sat, 25 Jul 2026 23:48:38 +0900 Subject: [PATCH 1/2] fix(terminal): terminate HTTP links on CJK brackets and whitespace URL scanning only treated ASCII whitespace/punctuation as boundaries, so Japanese/Chinese annotations glued after a URL (fullwidth parentheses, corner brackets, ideographic space) were swallowed into the link and opened as percent-encoded garbage (fixes #10571). --- .../terminal-http-url-boundary.ts | 108 ++++++++++++++++++ .../terminal-http-url-extraction.ts | 51 +-------- .../terminal-url-link-hit-testing.test.ts | 41 +++++++ 3 files changed, 150 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts b/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts new file mode 100644 index 000000000000..9db50f5de8b5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts @@ -0,0 +1,108 @@ +/** + * Code-point boundary checks for terminal HTTP link scanning. + * Why extracted: keeps hit-testing under max-lines while hosting CJK-aware rules (#10571). + */ + +export function isHttpUrlBodyTerminator(code: number): boolean { + return ( + isUrlWhitespace(code) || + isAsciiUrlBodyDelimiter(code) || + // Why (#10571): CJK agents often glue fullwidth brackets/quotes to URLs + // with no ASCII space; treat those as terminators like (){}<> ASCII peers. + isCjkUrlBracketOrQuote(code) + ) +} + +export function isHttpUrlTrailingPunctuation(code: number): boolean { + return ( + isUrlWhitespace(code) || + isAsciiUrlTrailingPunctuation(code) || + // Why (#10571): strip trailing CJK punctuation the same way we strip .,?! + isCjkUrlTrailingPunctuation(code) + ) +} + +function isUrlWhitespace(code: number): boolean { + // ASCII whitespace + U+3000 IDEOGRAPHIC SPACE (common after Japanese URLs). + return ( + code === 9 || + code === 10 || + code === 11 || + code === 12 || + code === 13 || + code === 32 || + code === 0x3000 + ) +} + +function isAsciiUrlBodyDelimiter(code: number): boolean { + return ( + code === 0x22 || // " + code === 0x27 || // ' + code === 0x21 || // ! + code === 0x2a || // * + code === 0x28 || // ( + code === 0x29 || // ) + code === 0x7b || // { + code === 0x7d || // } + code === 0x7c || // | + code === 0x5c || // \ + code === 0x5e || // ^ + code === 0x3c || // < + code === 0x3e || // > + code === 0x60 // ` + ) +} + +function isAsciiUrlTrailingPunctuation(code: number): boolean { + return ( + isAsciiUrlBodyDelimiter(code) || + code === 0x3a || // : + code === 0x2c || // , + code === 0x2e || // . + code === 0x3f || // ? + code === 0x7e || // ~ + code === 0x5b || // [ + code === 0x5d // ] + ) +} + +/** Fullwidth and CJK bracket/quote code points that mirror ASCII URL delimiters. */ +function isCjkUrlBracketOrQuote(code: number): boolean { + return ( + code === 0xff08 || // ( + code === 0xff09 || // ) + code === 0xff3b || // [ + code === 0xff3d || // ] + code === 0xff5b || // { + code === 0xff5d || // } + code === 0x3008 || // 〈 + code === 0x3009 || // 〉 + code === 0x300a || // 《 + code === 0x300b || // 》 + code === 0x300c || // 「 + code === 0x300d || // 」 + code === 0x300e || // 『 + code === 0x300f || // 』 + code === 0x3010 || // 【 + code === 0x3011 || // 】 + code === 0x3014 || // 〔 + code === 0x3015 // 〕 + ) +} + +function isCjkUrlTrailingPunctuation(code: number): boolean { + return ( + isCjkUrlBracketOrQuote(code) || + code === 0x3001 || // 、 + code === 0x3002 || // 。 + code === 0x30fb || // ・ + code === 0x2026 || // … + code === 0xff0c || // , + code === 0xff0e || // . + code === 0xff01 || // ! + code === 0xff1f || // ? + code === 0xff1a || // : + code === 0xff1b // ; + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts index 192c4db15e1b..9c22e2d1fe9b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts @@ -1,4 +1,5 @@ import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' +import { isHttpUrlBodyTerminator, isHttpUrlTrailingPunctuation } from './terminal-http-url-boundary' type ParsedTerminalHttpLink = { url: string @@ -96,56 +97,6 @@ function trimHttpUrlTrailingPunctuation( return endIndex } -function isHttpUrlBodyTerminator(code: number): boolean { - return ( - isAsciiWhitespace(code) || - code === 0x22 || - code === 0x27 || - code === 0x21 || - code === 0x2a || - code === 0x28 || - code === 0x29 || - code === 0x7b || - code === 0x7d || - code === 0x7c || - code === 0x5c || - code === 0x5e || - code === 0x3c || - code === 0x3e || - code === 0x60 - ) -} - -function isHttpUrlTrailingPunctuation(code: number): boolean { - return ( - isAsciiWhitespace(code) || - code === 0x22 || - code === 0x27 || - code === 0x3a || - code === 0x2c || - code === 0x2e || - code === 0x21 || - code === 0x3f || - code === 0x7b || - code === 0x7d || - code === 0x7c || - code === 0x5c || - code === 0x5e || - code === 0x7e || - code === 0x5b || - code === 0x5d || - code === 0x28 || - code === 0x29 || - code === 0x3c || - code === 0x3e || - code === 0x60 - ) -} - -function isAsciiWhitespace(code: number): boolean { - return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32 -} - function isAsciiWordCode(code: number): boolean { return ( (code >= 48 && code <= 57) || diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts index 87d03c748b75..4cf47a16be31 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts @@ -46,4 +46,45 @@ describe('extractTerminalHttpLinks', () => { ]) expect(matchAllSpy).not.toHaveBeenCalled() }) + + it('terminates URLs on fullwidth parentheses without swallowing CJK annotation text', () => { + // Why (#10571): Claude Code often prints Japanese annotations glued to PRs. + const line = 'PR: https://github.com/stablyai/orca/pull/12345(作成済み・マージ待ち)' + const url = 'https://github.com/stablyai/orca/pull/12345' + expect(extractTerminalHttpLinks(line)).toEqual([ + { + url, + startIndex: 'PR: '.length, + endIndex: 'PR: '.length + url.length + } + ]) + }) + + it('terminates URLs on CJK corner brackets and ideographic space', () => { + expect(extractTerminalHttpLinks('詳細は「https://example.com/docs」を参照')).toEqual([ + { + url: 'https://example.com/docs', + startIndex: '詳細は「'.length, + endIndex: '詳細は「https://example.com/docs'.length + } + ]) + + const ideographicSpaceLine = 'PR: https://example.com/pull/2 (全角スペース区切り)' + const url = 'https://example.com/pull/2' + expect(extractTerminalHttpLinks(ideographicSpaceLine)).toEqual([ + { + url, + startIndex: 'PR: '.length, + endIndex: 'PR: '.length + url.length + } + ]) + }) + + it('still terminates on half-width spaces and parentheses next to CJK text', () => { + const spaced = 'PR: https://example.com/pull/2 (作成済み)' + const paren = 'PR: https://example.com/pull/2 (作成済み)' + const url = 'https://example.com/pull/2' + expect(extractTerminalHttpLinks(spaced)[0]?.url).toBe(url) + expect(extractTerminalHttpLinks(paren)[0]?.url).toBe(url) + }) }) From 3c294e5f0e11ca80942e5e1c6b0cf992b73ab69c Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Sun, 26 Jul 2026 12:08:18 +0900 Subject: [PATCH 2/2] fix(terminal): property-based CJK URL terminators and hostname guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Amethyst review: non-ASCII body terminators via Unicode White_Space/P/Sm properties; authority rejects non-ASCII so CJK cannot IDNA-fold into the host; cover 、。・ and broader Unicode whitespace. --- .../terminal-http-url-boundary.ts | 91 ++++++------------- .../terminal-http-url-extraction.ts | 37 +++++++- .../terminal-url-link-hit-testing.test.ts | 59 ++++++++++++ 3 files changed, 120 insertions(+), 67 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts b/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts index 9db50f5de8b5..143d1b6fb4b9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-url-boundary.ts @@ -3,36 +3,39 @@ * Why extracted: keeps hit-testing under max-lines while hosting CJK-aware rules (#10571). */ +// Why (#10571 review): enumerate glyphs forever misses 〜/〖〗/NBSP; property classes scale. +const NON_ASCII_BODY_TERMINATOR_RE = /[\p{White_Space}\p{P}\p{Sm}]/u + export function isHttpUrlBodyTerminator(code: number): boolean { - return ( - isUrlWhitespace(code) || - isAsciiUrlBodyDelimiter(code) || - // Why (#10571): CJK agents often glue fullwidth brackets/quotes to URLs - // with no ASCII space; treat those as terminators like (){}<> ASCII peers. - isCjkUrlBracketOrQuote(code) - ) + if (code <= 0x7f) { + return isAsciiUrlWhitespace(code) || isAsciiUrlBodyDelimiter(code) + } + return isNonAsciiUrlBodyTerminator(code) } export function isHttpUrlTrailingPunctuation(code: number): boolean { - return ( - isUrlWhitespace(code) || - isAsciiUrlTrailingPunctuation(code) || - // Why (#10571): strip trailing CJK punctuation the same way we strip .,?! - isCjkUrlTrailingPunctuation(code) - ) + if (code <= 0x7f) { + return isAsciiUrlWhitespace(code) || isAsciiUrlTrailingPunctuation(code) + } + // Why: max-length cuts can leave non-ASCII punct at the candidate tail. + return isNonAsciiUrlBodyTerminator(code) } -function isUrlWhitespace(code: number): boolean { - // ASCII whitespace + U+3000 IDEOGRAPHIC SPACE (common after Japanese URLs). - return ( - code === 9 || - code === 10 || - code === 11 || - code === 12 || - code === 13 || - code === 32 || - code === 0x3000 - ) +/** + * Why (#10571 review): if a terminator is missed, CJK must not fold into the + * authority — WHATWG IDNA would silently retarget the link (spoof surface). + * Paths/queries still accept non-ASCII after `/` `?` `#`. + */ +export function isHttpUrlAuthorityCodeAllowed(code: number): boolean { + return code <= 0x7f +} + +function isNonAsciiUrlBodyTerminator(code: number): boolean { + return NON_ASCII_BODY_TERMINATOR_RE.test(String.fromCodePoint(code)) +} + +function isAsciiUrlWhitespace(code: number): boolean { + return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32 } function isAsciiUrlBodyDelimiter(code: number): boolean { @@ -66,43 +69,3 @@ function isAsciiUrlTrailingPunctuation(code: number): boolean { code === 0x5d // ] ) } - -/** Fullwidth and CJK bracket/quote code points that mirror ASCII URL delimiters. */ -function isCjkUrlBracketOrQuote(code: number): boolean { - return ( - code === 0xff08 || // ( - code === 0xff09 || // ) - code === 0xff3b || // [ - code === 0xff3d || // ] - code === 0xff5b || // { - code === 0xff5d || // } - code === 0x3008 || // 〈 - code === 0x3009 || // 〉 - code === 0x300a || // 《 - code === 0x300b || // 》 - code === 0x300c || // 「 - code === 0x300d || // 」 - code === 0x300e || // 『 - code === 0x300f || // 』 - code === 0x3010 || // 【 - code === 0x3011 || // 】 - code === 0x3014 || // 〔 - code === 0x3015 // 〕 - ) -} - -function isCjkUrlTrailingPunctuation(code: number): boolean { - return ( - isCjkUrlBracketOrQuote(code) || - code === 0x3001 || // 、 - code === 0x3002 || // 。 - code === 0x30fb || // ・ - code === 0x2026 || // … - code === 0xff0c || // , - code === 0xff0e || // . - code === 0xff01 || // ! - code === 0xff1f || // ? - code === 0xff1a || // : - code === 0xff1b // ; - ) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts index 9c22e2d1fe9b..afb19082ceec 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts @@ -1,5 +1,9 @@ import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' -import { isHttpUrlBodyTerminator, isHttpUrlTrailingPunctuation } from './terminal-http-url-boundary' +import { + isHttpUrlAuthorityCodeAllowed, + isHttpUrlBodyTerminator, + isHttpUrlTrailingPunctuation +} from './terminal-http-url-boundary' type ParsedTerminalHttpLink = { url: string @@ -77,14 +81,41 @@ function hasHttpUrlWordBoundary(lineText: string, startIndex: number): boolean { function findHttpUrlCandidateEnd(lineText: string, startIndex: number): number { const scanEnd = Math.min(lineText.length, startIndex + TERMINAL_HTTP_URL_MAX_LENGTH + 1) - for (let index = startIndex; index < scanEnd; index += 1) { - if (isHttpUrlBodyTerminator(lineText.charCodeAt(index))) { + // Why: authority ends at first / ? #; non-ASCII there must not reach IDNA. + let inAuthority = true + const authorityStart = httpUrlAuthorityStartIndex(lineText, startIndex) + + for (let index = startIndex; index < scanEnd; ) { + const code = lineText.codePointAt(index) ?? 0 + const width = code > 0xffff ? 2 : 1 + + if (inAuthority && index >= authorityStart) { + if (code === 0x2f || code === 0x3f || code === 0x23) { + inAuthority = false + } else if (!isHttpUrlAuthorityCodeAllowed(code)) { + return index + } + } + + if (isHttpUrlBodyTerminator(code)) { return index } + index += width } return scanEnd } +function httpUrlAuthorityStartIndex(lineText: string, startIndex: number): number { + if (lineText.startsWith('https://', startIndex)) { + return startIndex + 'https://'.length + } + if (lineText.startsWith('http://', startIndex)) { + return startIndex + 'http://'.length + } + // Why: scheme finder only yields http(s); treat body start as authority floor. + return startIndex +} + function trimHttpUrlTrailingPunctuation( lineText: string, startIndex: number, diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts index 4cf47a16be31..5c4ce90adff0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts @@ -87,4 +87,63 @@ describe('extractTerminalHttpLinks', () => { expect(extractTerminalHttpLinks(spaced)[0]?.url).toBe(url) expect(extractTerminalHttpLinks(paren)[0]?.url).toBe(url) }) + + it('terminates on CJK punctuation so prose is not IDNA-folded into the host', () => { + // Why (#10571 review): trailing-only trim never saw 、。・ — body must stop first. + const cases: { line: string; end: string }[] = [ + { line: 'https://example.com、それから', end: 'https://example.com' }, + { line: 'https://example.com。次の行', end: 'https://example.com' }, + { line: 'https://example.com・次', end: 'https://example.com' }, + { line: 'https://example.com!注意', end: 'https://example.com' }, + { line: 'https://example.com?詳細', end: 'https://example.com' } + ] + for (const { line, end } of cases) { + expect(extractTerminalHttpLinks(line)).toEqual([ + { url: 'https://example.com/', startIndex: 0, endIndex: end.length } + ]) + } + }) + + it('guards authority so bare CJK after the host cannot retarget via IDNA', () => { + // Why: defense in depth if a terminator is ever missed — letters aren't \p{P}. + const line = 'https://example.comそれから' + expect(extractTerminalHttpLinks(line)).toEqual([ + { + url: 'https://example.com/', + startIndex: 0, + endIndex: 'https://example.com'.length + } + ]) + }) + + it('still allows non-ASCII in the path after leaving authority', () => { + const line = 'see https://example.com/ドキュメント' + const links = extractTerminalHttpLinks(line) + expect(links).toHaveLength(1) + expect(links[0]?.url).toBe(new URL('https://example.com/ドキュメント').toString()) + expect(links[0]?.endIndex).toBe(line.length) + }) + + it('terminates on Unicode whitespace beyond ideographic space', () => { + // Why (CodeRabbit): NBSP / EM SPACE / NARROW NO-BREAK SPACE are \p{White_Space}. + for (const ws of ['\u00a0', '\u2003', '\u202f']) { + const line = `https://example.com${ws}next` + expect(extractTerminalHttpLinks(line)).toEqual([ + { + url: 'https://example.com/', + startIndex: 0, + endIndex: 'https://example.com'.length + } + ]) + } + }) + + it('terminates on property-class punctuation not in the old glyph list', () => { + // Why: 〜 is Pd, ~ is Sm, 〖〗 were missing from the enumerated bracket set. + for (const punct of ['〜', '~', '〖', '〗']) { + const line = `https://example.com${punct}続き` + expect(extractTerminalHttpLinks(line)[0]?.url).toBe('https://example.com/') + expect(extractTerminalHttpLinks(line)[0]?.endIndex).toBe('https://example.com'.length) + } + }) })