diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4ab6340f51..833a9238f8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -63,6 +63,7 @@ "@sigstore/bundle": "5.0.0", "@sigstore/tuf": "5.0.0", "@sigstore/verify": "4.1.2", + "@xterm/addon-web-links": "0.12.0", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -105,6 +106,7 @@ "@fontsource-variable/geist-mono", "@maka/ui", "@xterm/addon-fit", + "@xterm/addon-web-links", "@xterm/xterm", "react", "react-dom", diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 373f18d046..e874a8709b 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -4785,6 +4785,34 @@ THE SOFTWARE. ================================================================================ +Package: @xterm/addon-web-links@0.12.0 +Declared license: MIT +Selected license: MIT +Repository: https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-links + +--- LICENSE --- +Copyright (c) 2017, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ + Package: @xterm/headless@6.0.0 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/src/main/__tests__/terminal-interaction-policy.test.ts b/apps/desktop/src/main/__tests__/terminal-interaction-policy.test.ts new file mode 100644 index 0000000000..28ccd2a743 --- /dev/null +++ b/apps/desktop/src/main/__tests__/terminal-interaction-policy.test.ts @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + terminalWebUrl, +} from '../../renderer/features/workbar/testing.js'; + +describe('terminalWebUrl', () => { + it('accepts only explicit HTTP(S) URLs', () => { + assert.equal(terminalWebUrl('https://example.com/a?q=1'), 'https://example.com/a?q=1'); + assert.equal(terminalWebUrl('http://localhost:3000'), 'http://localhost:3000/'); + }); + + it('rejects non-http(s) and malformed values', () => { + for (const url of [ + 'file:///etc/passwd', + 'javascript:alert(1)', + 'mailto:a@b.com', + '/tmp/a', + 'invalid', + ]) { + assert.equal(terminalWebUrl(url), null); + } + }); +}); diff --git a/apps/desktop/src/renderer/features/workbar/README.md b/apps/desktop/src/renderer/features/workbar/README.md index 85c141f985..bb4000b778 100644 --- a/apps/desktop/src/renderer/features/workbar/README.md +++ b/apps/desktop/src/renderer/features/workbar/README.md @@ -71,6 +71,10 @@ remounted when the active session changes. - Terminal ownership is registered as soon as `start` returns, before the tab state commits. Host projection excludes resources owned by another Session, so a Session switch cannot briefly reattach an old Terminal. +- The task terminal loads FitAddon and WebLinksAddon on the same Terminal + instance the panel owns. Web links are filtered to explicit HTTP(S) URLs in + the renderer (`terminalWebUrl`); main's external-link guard stays the final + boundary behind `window.open`. - Side Chat survives panel collapse and is cleaned only when its tab closes or when navigation leaves its source session. - Disposed Side Chat operations are fenced at every fork/send boundary; a late diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 11c1e45a56..be36042ddf 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -49,6 +49,7 @@ export * from './tools/side-chat/quote-companion-visibility.js'; export { useQuoteCompanion, } from './tools/side-chat/use-quote-companion.js'; +export * from './tools/terminal/terminal-interaction-policy.js'; export * from './tools/terminal/session-terminal-hydration.js'; export * from './tools/terminal/session-terminal-query.js'; export * from './tools/terminal/session-terminal-frame.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx index 8ffbe54aba..8a1389ea0d 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx @@ -24,11 +24,13 @@ import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; import { useUiLocale } from '@maka/ui'; import { ICON_SIZE, Terminal as TerminalIcon } from '@maka/ui/icons'; import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; import { Terminal } from '@xterm/xterm'; import { getDesktopConversationCopy } from '../../../../locales/conversation-copy'; import { SessionTerminalHydration, SessionTerminalRenderQueue } from './session-terminal-hydration'; import { suppressTerminalQueryReplies } from './session-terminal-query'; import { scheduleTerminalFrame } from './session-terminal-frame'; +import { terminalWebUrl } from './terminal-interaction-policy'; import { useWorkbarServices } from '../../services-context.js'; import { getTerminalFontSize, subscribeTerminalFontSize } from '../../../../theme'; @@ -97,6 +99,15 @@ export function SessionTerminalPanel(props: { }); const fit = new FitAddon(); terminal.loadAddon(fit); + // Renderer-side URL filter; main's external-link guard stays the final + // boundary behind window.open. + const webLinks = new WebLinksAddon((event, value) => { + const url = terminalWebUrl(value); + if (!url) return; + event.preventDefault(); + window.open(url, '_blank', 'noopener,noreferrer'); + }); + terminal.loadAddon(webLinks); terminal.open(host); terminalRef.current = terminal; fitRef.current = fit; diff --git a/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-interaction-policy.ts b/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-interaction-policy.ts new file mode 100644 index 0000000000..32773c9c22 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-interaction-policy.ts @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure decision for terminal link clicks: which link values may be opened. + * No DOM, no xterm import, so node:test can exercise it without a browser. + */ + +/** + * Only explicit HTTP(S) URLs may be opened from terminal output. The main + * process external-link guard remains the final boundary; this keeps the + * renderer from even attempting other schemes. + */ +export function terminalWebUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null; + } catch { + return null; + } +} diff --git a/package-lock.json b/package-lock.json index 872cfd700d..390d2341a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "@sigstore/bundle": "5.0.0", "@sigstore/tuf": "5.0.0", "@sigstore/verify": "4.1.2", + "@xterm/addon-web-links": "0.12.0", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -6256,6 +6257,12 @@ "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", "license": "MIT" }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://npm.corp.kuaishou.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/headless": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",