From a4410ecf5dfc622eab10e3d98aeab5d274ec3b69 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:53:20 +0000 Subject: [PATCH 1/6] feat(ai): Implemented the packaged-renderer CSS fix without changing the #1960/#1961 placeholder boundary. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the packaged-renderer CSS fix without changing the #1960/#1961 placeholder boundary. Key changes: - Desktop Vite now loads `propr-ui`’s PostCSS pipeline using native cross-platform paths. - Tailwind resolves its config/content relative to `propr-ui`. - Production builds fail if emitted CSS contains `@tailwind`/`@apply` or lacks `.h-5`, `.space-y-5`, `.bg-primary-500`, or `.dashboard-card`. - Packaged smoke now measures the 1280×820 window, logo bounds, controls, help text, button, and runtime footer spacing. Validation passed: - Production desktop package - Emitted CSS inspection - Desktop/UI typechecks - 24 Electron tests - 21 DesktopExperience tests - Web UI production build - `git diff --check` The sandboxed launch was attempted but blocked before window creation because this non-root container cannot configure Electron’s root-owned `4755` sandbox helper or create a user namespace. The smoke remains strict and does not use `--no-sandbox`; CI already provisions the helper correctly. PR: #1971 Comment by: @integry (ID: 5464159479) Model: gpt-5.6-sol --- apps/desktop/README.md | 5 ++- apps/desktop/scripts/smoke-packaged.mjs | 60 ++++++++++++++++++++++++- apps/desktop/src/main.ts | 48 ++++++++++++++++++++ apps/desktop/vite.renderer.config.ts | 30 ++++++++++++- propr-ui/postcss.config.js | 8 +++- propr-ui/tailwind.config.js | 13 +++--- 6 files changed, 153 insertions(+), 11 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 265883486..6ba6d7e1e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -27,9 +27,10 @@ generated workspace `dist` directories. Development renderer URLs are accepted only when Electron Forge supplies an HTTP loopback URL. Packaged builds load the generated renderer from the application ASAR through an app-owned protocol. -The packaged-binary smoke test verifies the hardened fuse states, launches the Linux artifact without a +The packaged-binary smoke test verifies the hardened fuse states, launches the Linux artifact at 1280x820 without a sandbox-disabling flag, rejects main-process uncaught exceptions, and requires proof that `window.proprDesktop` is -exposed before accepting renderer-ready and a clean exit. +exposed. It also checks the real renderer bounds for the title-bar logo and connection-card controls before accepting +renderer-ready and a clean exit. `desktop:audit` deliberately applies separate policies to the two dependency surfaces: low-or-higher advisories fail the production-runtime audit, while high and critical advisories fail the desktop development/build-tool audit. Release diff --git a/apps/desktop/scripts/smoke-packaged.mjs b/apps/desktop/scripts/smoke-packaged.mjs index ed36bb5a3..421becd6e 100644 --- a/apps/desktop/scripts/smoke-packaged.mjs +++ b/apps/desktop/scripts/smoke-packaged.mjs @@ -15,6 +15,7 @@ import { const READY_EVENT = 'desktop.renderer.ready'; const PRELOAD_BRIDGE_PROOF = '"preloadBridgeExposed":true'; const PROFILE_API_PROOF = 'desktop.renderer.profile_api.ready'; +const LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready'; const MAIN_PROCESS_ERROR_MARKERS = [ 'desktop.main_process.uncaught_exception', 'A JavaScript error occurred in the main process', @@ -23,6 +24,62 @@ const MAIN_PROCESS_ERROR_MARKERS = [ const TIMEOUT_MS = 30_000; const binaryPath = resolve('out', `propr-desktop-linux-${process.arch}`, 'propr-desktop'); +const parseLayout = smokeOutput => { + for (const line of smokeOutput.split(/\r?\n/)) { + if (!line.includes(LAYOUT_READY_EVENT)) continue; + try { + const record = JSON.parse(line.slice(line.indexOf('{'))); + if (record.event === LAYOUT_READY_EVENT) return record.layout; + } catch { + // Ignore non-JSON Chromium output that happens to mention the event name. + } + } + return undefined; +}; + +const assertGap = (before, after, minimum, description) => { + const gap = after.top - before.bottom; + if (gap < minimum) { + throw new Error(`Packaged layout ${description} gap was ${gap}px; expected at least ${minimum}px`); + } +}; + +const assertPackagedLayout = layout => { + if (!layout) throw new Error('Packaged desktop did not report renderer layout bounds'); + if (layout.missing?.length) { + throw new Error(`Packaged renderer layout was missing: ${layout.missing.join(', ')}`); + } + if (layout.windowBounds?.width !== 1280 || layout.windowBounds?.height !== 820) { + throw new Error(`Packaged window was not 1280x820: ${JSON.stringify(layout.windowBounds)}`); + } + if (layout.viewport.width < 1200 || layout.viewport.height < 740) { + throw new Error(`Packaged renderer viewport is unexpectedly small: ${JSON.stringify(layout.viewport)}`); + } + if (layout.logo.height < 18 || layout.logo.height > 22 || layout.logo.width < 40 || layout.logo.width > 100) { + throw new Error(`Packaged title-bar logo has unreasonable bounds: ${JSON.stringify(layout.logo)}`); + } + if ( + layout.logo.top < layout.titlebar.top + || layout.logo.bottom > layout.titlebar.bottom + || layout.card.left < 0 + || layout.card.right > layout.viewport.width + || layout.card.top < layout.titlebar.bottom + || layout.card.bottom > layout.viewport.height + ) { + throw new Error('Packaged logo or connection card extends outside its layout container'); + } + for (const name of ['connectionName', 'apiUrl', 'submit']) { + const control = layout[name]; + if (control.height < 36 || control.left < layout.card.left || control.right > layout.card.right) { + throw new Error(`Packaged ${name} control has unreasonable bounds: ${JSON.stringify(control)}`); + } + } + assertGap(layout.connectionName, layout.apiUrl, 28, 'between connection inputs'); + assertGap(layout.apiUrl, layout.apiHelp, 6, 'between API input and help text'); + assertGap(layout.apiHelp, layout.submit, 16, 'between API help and submit button'); + assertGap(layout.submit, layout.footer, 20, 'between submit button and runtime footer'); +}; + if (process.platform !== 'linux') { throw new Error('The packaged-binary smoke test currently targets the Linux artifact'); } @@ -137,8 +194,9 @@ try { if (!output.includes(PROFILE_API_PROOF) || receivedProfileApiOrigin !== DESKTOP_RENDERER_ORIGIN) { throw new Error('Packaged desktop did not complete a profile API request from its exact renderer origin'); } + assertPackagedLayout(parseLayout(output)); - console.log('Packaged Linux desktop reached renderer-ready and completed a profile API request with sandboxing enabled.'); + console.log('Packaged Linux desktop reached renderer-ready with compiled layout, sandboxing, and profile API proof.'); } finally { profileApiServer.closeAllConnections(); await new Promise(resolveClose => profileApiServer.close(resolveClose)); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d121bd8d8..f83c5e3ea 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -24,6 +24,7 @@ const devServerUrl = typeof MAIN_WINDOW_VITE_DEV_SERVER_URL === 'string' : undefined; const PACKAGED_RENDERER_SCHEME = 'propr-app'; const PACKAGED_RENDERER_HOST = 'renderer'; +const PACKAGED_LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready'; const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`); const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; let mainWindow: BrowserWindow | null = null; @@ -110,6 +111,50 @@ const openAllowedExternalUrl = async (url: string): Promise => { await shell.openExternal(url); }; +const inspectPackagedLayout = async (window: BrowserWindow): Promise> => { + const rendererLayout = await window.webContents.executeJavaScript(`(async () => { + const deadline = performance.now() + 5000; + let elements; + do { + const card = document.querySelector('.desktop-connection-card'); + const form = card?.querySelector('form'); + const labels = form ? Array.from(form.querySelectorAll(':scope > label')) : []; + elements = { + titlebar: document.querySelector('.desktop-titlebar'), + logo: document.querySelector('.desktop-titlebar img[alt="ProPR"]'), + card, + connectionName: labels[0]?.querySelector('input'), + apiUrl: labels[1]?.querySelector('input'), + apiHelp: labels[1]?.querySelector('span'), + submit: form?.querySelector(':scope > button[type="submit"]'), + footer: card?.lastElementChild, + }; + if (Object.values(elements).every(Boolean) && elements.footer.textContent.includes('Runtime:')) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + + const missing = Object.entries(elements).filter(([, element]) => !element).map(([name]) => name); + if (missing.length > 0) return { missing }; + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const bounds = element => { + const rect = element.getBoundingClientRect(); + return { + bottom: rect.bottom, + height: rect.height, + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + }; + }; + return { + viewport: { height: window.innerHeight, width: window.innerWidth }, + ...Object.fromEntries(Object.entries(elements).map(([name, element]) => [name, bounds(element)])), + }; + })()`); + return { windowBounds: window.getBounds(), ...rendererLayout }; +}; + const createMainWindow = async (): Promise => { const window = new BrowserWindow(createBrowserWindowOptions(join(__dirname, 'preload.cjs'), !app.isPackaged)); const readyToShow = new Promise(resolveReady => window.once('ready-to-show', resolveReady)); @@ -168,6 +213,9 @@ const createMainWindow = async (): Promise => { } log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN }); } + if (app.isPackaged && process.env.PROPR_DESKTOP_SMOKE_TEST === '1') { + log('info', PACKAGED_LAYOUT_READY_EVENT, { layout: await inspectPackagedLayout(window) }); + } log('info', 'desktop.renderer.ready', { preloadBridgeExposed: true }); if (app.isPackaged && process.env.PROPR_DESKTOP_SMOKE_TEST === '1') { app.quit(); diff --git a/apps/desktop/vite.renderer.config.ts b/apps/desktop/vite.renderer.config.ts index c8de93b75..457d63950 100644 --- a/apps/desktop/vite.renderer.config.ts +++ b/apps/desktop/vite.renderer.config.ts @@ -8,6 +8,7 @@ import { viteFileSystemUrl } from './src/vite-file-system-url'; const rootPackage = JSON.parse( readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'), ) as { version: string }; +const proprUiRoot = fileURLToPath(new URL('../../propr-ui', import.meta.url)); const rendererEntrySource = '../../propr-ui/src/desktop.tsx'; const rendererEntryDevelopmentUrl = viteFileSystemUrl( fileURLToPath(new URL(rendererEntrySource, import.meta.url)), @@ -29,13 +30,40 @@ const developmentCspPlugin: Plugin = { }, }; +const compiledRendererCssPlugin: Plugin = { + name: 'propr-desktop-compiled-renderer-css', + apply: 'build', + enforce: 'post', + generateBundle(_options, bundle) { + const css = Object.values(bundle) + .flatMap(output => output.type === 'asset' && output.fileName.endsWith('.css') + ? [typeof output.source === 'string' + ? output.source + : Buffer.from(output.source).toString('utf8')] + : []) + .join('\n'); + if (!css) throw new Error('Desktop renderer build emitted no CSS'); + if (/@(?:tailwind|apply)\b/.test(css)) { + throw new Error('Desktop renderer CSS still contains uncompiled Tailwind directives'); + } + for (const selector of ['.h-5', '.space-y-5', '.bg-primary-500', '.dashboard-card']) { + if (!css.includes(selector)) { + throw new Error(`Desktop renderer CSS is missing representative selector ${selector}`); + } + } + }, +}; + export default defineConfig({ base: './', + css: { + postcss: proprUiRoot, + }, define: { __APP_VERSION__: JSON.stringify(rootPackage.version), __PROPR_DESKTOP__: 'true', }, - plugins: [developmentCspPlugin, react()], + plugins: [developmentCspPlugin, react(), compiledRendererCssPlugin], publicDir: '../../propr-ui/public', build: { sourcemap: true, diff --git a/propr-ui/postcss.config.js b/propr-ui/postcss.config.js index e99ebc2c0..46297d023 100644 --- a/propr-ui/postcss.config.js +++ b/propr-ui/postcss.config.js @@ -1,6 +1,10 @@ +import { fileURLToPath } from 'node:url'; + export default { plugins: { - tailwindcss: {}, + tailwindcss: { + config: fileURLToPath(new URL('./tailwind.config.js', import.meta.url)), + }, autoprefixer: {}, }, -} \ No newline at end of file +}; diff --git a/propr-ui/tailwind.config.js b/propr-ui/tailwind.config.js index 32975812d..259d7e38a 100644 --- a/propr-ui/tailwind.config.js +++ b/propr-ui/tailwind.config.js @@ -1,9 +1,12 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], + content: { + relative: true, + files: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + }, theme: { extend: { colors: { @@ -23,4 +26,4 @@ export default { }, }, plugins: [], -} \ No newline at end of file +} From d5465a331066e1776071826bd8a528f09f8c651b Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:19:38 +0000 Subject: [PATCH 2/6] feat(ai): Implemented F3 only; S1 remains untouched. Implemented F3 only; S1 remains untouched. - Hardened `propr://open` validation in [security.ts](/tmp/git-processor/worktrees/integry/propr/pr-1971-followup-2026-08-29T19-10-30/apps/desktop/src/security.ts:25), rejecting external/protocol-relative paths, traversal, controls, malformed encoding, login/pairing routes, and hosted-flow parameters. - Added ordered startup buffering and post-load hash routing in [desktop-deep-link.ts](/tmp/git-processor/worktrees/integry/propr/pr-1971-followup-2026-08-29T19-10-30/propr-ui/src/desktop-deep-link.ts:3), integrated at [desktop.tsx](/tmp/git-processor/worktrees/integry/propr/pr-1971-followup-2026-08-29T19-10-30/propr-ui/src/desktop.tsx:145). - Added `/tasks`, startup-buffer, post-load, and unsafe-input regressions. Validation: - Electron tests: 26/26 passed - Focused UI tests: 5/5 passed - Desktop/UI typechecks: passed - UI lint: passed - Production package: passed - `git diff --check`: passed - Sandboxed layout/API-origin smoke: attempted twice, but the container cannot launch Electron because generated `chrome-sandbox` is `node:node 0755`; Electron requires root ownership and mode `4755`. No sandbox-disabling workaround was used. PR: #1971 Comment by: @integry (ID: 5464313572) Model: gpt-5.6-sol --- apps/desktop/src/security.test.ts | 32 ++++++++++++ apps/desktop/src/security.ts | 71 +++++++++++++++++++++++++- propr-ui/src/desktop-deep-link.test.ts | 56 ++++++++++++++++++++ propr-ui/src/desktop-deep-link.ts | 26 ++++++++++ propr-ui/src/desktop.tsx | 16 ++++-- 5 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 propr-ui/src/desktop-deep-link.test.ts create mode 100644 propr-ui/src/desktop-deep-link.ts diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts index aecda058a..2b89dcfd2 100644 --- a/apps/desktop/src/security.test.ts +++ b/apps/desktop/src/security.test.ts @@ -3,9 +3,11 @@ import { describe, it } from 'node:test'; import { deepLinkFromArguments, applyDevelopmentRendererCsp, + dashboardPathFromDeepLink, isSafeExternalUrl, isTrustedRendererUrl, normalizeApiBaseUrl, + normalizeDesktopDashboardPath, normalizeDeepLink, rendererContentSecurityPolicy, validatedDevServerUrl, @@ -73,6 +75,36 @@ describe('desktop URL security', () => { assert.equal(normalizeDeepLink('propr://user:secret@connect'), null); }); + it('accepts a normal internal dashboard route from an open deep link', () => { + const link = 'propr://open?path=%2Ftasks'; + assert.equal(dashboardPathFromDeepLink(link), '/tasks'); + assert.equal(normalizeDeepLink(link), link); + assert.equal(normalizeDesktopDashboardPath('/tasks?status=open'), '/tasks?status=open'); + }); + + it('rejects malformed and unsafe open deep-link paths', () => { + const rejected = [ + 'propr://open', + 'propr://open?path=', + 'propr://open?path=%2Ftasks&path=%2Fplans', + 'propr://open?path=%2Ftasks&extra=true', + 'propr://open?path=https%3A%2F%2Fevil.example%2Ftasks', + 'propr://open?path=%2F%2Fevil.example%2Ftasks', + 'propr://open?path=%2Ftasks%252F..%252Flogin', + 'propr://open?path=%2Ftasks%252F%252e%252e%252Flogin', + 'propr://open?path=%2Ftasks%250Anext', + 'propr://open?path=%2Ftasks%255Cnext', + 'propr://open?path=%2Flogin%3Fredirect_to%3D%252Ftasks', + 'propr://open?path=%2Fdesktop%2Fpairing%3Fpairing_id%3Dattacker', + 'propr://open?path=%2Ftasks%3Ftunnel%3Dt-attacker.propr.dev', + 'propr://open?path=%2Ftasks%3Fflow%3Dattacker', + ]; + rejected.forEach(link => { + assert.equal(dashboardPathFromDeepLink(link), null, link); + assert.equal(normalizeDeepLink(link), null, link); + }); + }); + it('publishes a restrictive production policy', () => { const policy = rendererContentSecurityPolicy(); assert.match(policy, /default-src 'self'/); diff --git a/apps/desktop/src/security.ts b/apps/desktop/src/security.ts index ab6ad6f73..a355acc6f 100644 --- a/apps/desktop/src/security.ts +++ b/apps/desktop/src/security.ts @@ -3,6 +3,14 @@ import { DESKTOP_PROTOCOL } from './shared/contract'; // WHATWG URL.hostname retains brackets around IPv6 literals. const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']); const DEEP_LINK_ACTIONS = new Set(['connect', 'open']); +const DESKTOP_DASHBOARD_ORIGIN = 'https://desktop.propr.invalid'; +const RESERVED_DASHBOARD_PARAMETERS = new Set([ + 'flow', + 'logged_out', + 'oauth_complete', + 'redirect_to', + 'tunnel', +]); const parseUrl = (value: string): URL | null => { try { @@ -14,6 +22,66 @@ const parseUrl = (value: string): URL | null => { const hasCredentials = (url: URL): boolean => Boolean(url.username || url.password); +const isSafeDashboardPathForm = (value: string): boolean => { + if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return false; + if (/[\u0000-\u001F\u007F\\]/.test(value)) return false; + const pathname = value.split(/[?#]/, 1)[0]; + return !pathname.split('/').some(segment => segment === '.' || segment === '..'); +}; + +const fullyDecodeDashboardPath = (value: string): string | null => { + let decoded = value; + for (let remaining = value.length + 1; remaining > 0; remaining -= 1) { + if (!isSafeDashboardPathForm(decoded)) return null; + if (!decoded.includes('%')) return decoded; + if (/%(?![\da-f]{2})/i.test(decoded)) return null; + try { + const next = decodeURIComponent(decoded); + if (next === decoded) return decoded; + decoded = next; + } catch { + return null; + } + } + return null; +}; + +export const normalizeDesktopDashboardPath = (value: string): string | null => { + if (!value || value.length > 2_048) return null; + const fullyDecoded = fullyDecodeDashboardPath(value); + if (!fullyDecoded) return null; + try { + const url = new URL(value, DESKTOP_DASHBOARD_ORIGIN); + const decodedUrl = new URL(fullyDecoded, DESKTOP_DASHBOARD_ORIGIN); + if (url.origin !== DESKTOP_DASHBOARD_ORIGIN || decodedUrl.origin !== DESKTOP_DASHBOARD_ORIGIN) return null; + const route = decodedUrl.pathname.toLowerCase().replace(/\/+$/, '') || '/'; + if (route === '/login' || route.startsWith('/login/') || route === '/desktop/pairing') return null; + if ([...decodedUrl.searchParams.keys()].some(key => RESERVED_DASHBOARD_PARAMETERS.has(key.toLowerCase()))) { + return null; + } + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } +}; + +export const dashboardPathFromDeepLink = (value: string): string | null => { + if (value.length > 2_048 || /[\u0000-\u001F\u007F]/.test(value)) return null; + const url = parseUrl(value); + if ( + !url + || url.protocol !== `${DESKTOP_PROTOCOL}:` + || url.hostname !== 'open' + || hasCredentials(url) + || url.port + || url.hash + || (url.pathname !== '' && url.pathname !== '/') + ) return null; + const entries = [...url.searchParams.entries()]; + if (entries.length !== 1 || entries[0][0] !== 'path') return null; + return normalizeDesktopDashboardPath(entries[0][1]); +}; + export const normalizeApiBaseUrl = (value: string): string | null => { const url = parseUrl(value.trim()); if (!url || hasCredentials(url) || url.hash || url.search) return null; @@ -55,10 +123,11 @@ export const isTrustedRendererUrl = ( }; export const normalizeDeepLink = (value: string): string | null => { - if (value.length > 2_048) return null; + if (value.length > 2_048 || /[\u0000-\u001F\u007F]/.test(value)) return null; const url = parseUrl(value); if (!url || url.protocol !== `${DESKTOP_PROTOCOL}:` || hasCredentials(url)) return null; if (!DEEP_LINK_ACTIONS.has(url.hostname) || url.port || url.hash) return null; + if (url.hostname === 'open' && !dashboardPathFromDeepLink(value)) return null; return url.href; }; diff --git a/propr-ui/src/desktop-deep-link.test.ts b/propr-ui/src/desktop-deep-link.test.ts new file mode 100644 index 000000000..c30ff81a5 --- /dev/null +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DesktopDeepLinkNavigation } from './desktop-deep-link'; + +describe('desktop open deep-link navigation', () => { + it('preserves a startup-buffered link until the dashboard is ready', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + + expect(navigation.receive('propr://open?path=%2Ftasks')).toBe(true); + expect(navigate).not.toHaveBeenCalled(); + + navigation.setDashboardReady(); + expect(navigate).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith('/tasks'); + }); + + it('preserves the order of multiple accepted links buffered during startup', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + + navigation.receive('propr://open?path=%2Fplans'); + navigation.receive('propr://open?path=%2Ftasks'); + navigation.setDashboardReady(); + + expect(navigate.mock.calls).toEqual([['/plans'], ['/tasks']]); + }); + + it('delivers a valid link received after the dashboard has loaded', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + navigation.setDashboardReady(); + + expect(navigation.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen')).toBe(true); + expect(navigate).toHaveBeenCalledWith('/tasks?status=open'); + }); + + it('does not route malformed or unsafe links before or after dashboard load', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + const rejected = [ + 'not a URL', + 'propr://open?path=https%3A%2F%2Fevil.example', + 'propr://open?path=%2F%2Fevil.example', + 'propr://open?path=%2Ftasks%252F..%252Flogin', + 'propr://open?path=%2Ftasks%250Anext', + 'propr://open?path=%2Flogin%3Foauth_complete%3Dtrue', + 'propr://open?path=%2Ftasks%3Fflow%3Dattacker', + 'propr://open?path=%2Ftasks%3Ftunnel%3Dt-attacker.propr.dev', + ]; + + rejected.forEach(link => expect(navigation.receive(link), link).toBe(false)); + navigation.setDashboardReady(); + rejected.forEach(link => expect(navigation.receive(link), link).toBe(false)); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/propr-ui/src/desktop-deep-link.ts b/propr-ui/src/desktop-deep-link.ts new file mode 100644 index 000000000..6972698d1 --- /dev/null +++ b/propr-ui/src/desktop-deep-link.ts @@ -0,0 +1,26 @@ +import { dashboardPathFromDeepLink } from '../../apps/desktop/src/security'; + +/** Holds an accepted dashboard route until the shared hash router can observe it. */ +export class DesktopDeepLinkNavigation { + private dashboardReady = false; + private readonly pendingPaths: string[] = []; + + constructor(private readonly navigate: (path: string) => void) {} + + receive(value: string): boolean { + const path = dashboardPathFromDeepLink(value); + if (!path) return false; + if (this.dashboardReady) this.navigate(path); + else this.pendingPaths.push(path); + return true; + } + + setDashboardReady(): void { + this.dashboardReady = true; + this.pendingPaths.splice(0).forEach(path => this.navigate(path)); + } + + setDashboardUnavailable(): void { + this.dashboardReady = false; + } +} diff --git a/propr-ui/src/desktop.tsx b/propr-ui/src/desktop.tsx index 993447a0b..7bfee062f 100644 --- a/propr-ui/src/desktop.tsx +++ b/propr-ui/src/desktop.tsx @@ -1,4 +1,4 @@ -import { StrictMode, type ComponentType, useEffect, useState } from 'react'; +import { StrictMode, type ComponentType, useCallback, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import type { DesktopAppMetadata, @@ -6,6 +6,7 @@ import type { StorageSecurity, } from '../../apps/desktop/src/shared/contract'; import { activateDesktopProfile } from './desktop-profile'; +import { DesktopDeepLinkNavigation } from './desktop-deep-link'; import './index.css'; import './desktop.css'; @@ -141,13 +142,17 @@ export const DesktopRoot = () => { const [initialApiUrl, setInitialApiUrl] = useState('http://localhost:4000'); const [loading, setLoading] = useState(true); const [fatalError, setFatalError] = useState(null); + const [deepLinkNavigation] = useState(() => new DesktopDeepLinkNavigation(path => { + window.location.hash = path; + })); - const loadDashboard = async (activeProfile: DesktopProfile) => { + const loadDashboard = useCallback(async (activeProfile: DesktopProfile) => { window.__PROPR_CONFIG__ = { apiBaseUrl: activeProfile.apiBaseUrl }; const application = await import('./App'); setProfile(activeProfile); setDashboardApp(() => application.default); - }; + deepLinkNavigation.setDashboardReady(); + }, [deepLinkNavigation]); useEffect(() => { if (!bridge) { @@ -162,6 +167,8 @@ export const DesktopRoot = () => { if (deepLink.hostname === 'connect') { const apiUrl = deepLink.searchParams.get('api'); if (apiUrl) setInitialApiUrl(apiUrl); + } else if (deepLink.hostname === 'open') { + deepLinkNavigation.receive(value); } } catch { // Main validates protocol input; ignore malformed values defensively. @@ -185,7 +192,7 @@ export const DesktopRoot = () => { cancelled = true; unsubscribe(); }; - }, [bridge]); + }, [bridge, deepLinkNavigation, loadDashboard]); const connect = async (label: string, apiBaseUrl: string) => { if (!bridge) return; @@ -198,6 +205,7 @@ export const DesktopRoot = () => { await bridge.profiles.setActive(null); setProfile(null); setDashboardApp(null); + deepLinkNavigation.setDashboardUnavailable(); window.__PROPR_CONFIG__ = undefined; window.location.hash = ''; }; From 6fe54e8667a648429c5e4080d44bb0d5ff936abe Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:48:00 +0000 Subject: [PATCH 3/6] feat(ai): Fixed the flaky full-suite failure in [DesktopExperience.test.tsx](/tmp/git-processor/worktrees/integry/propr/pr-1979-followup-2026-08-29T19-43-24/propr-ui/src/desktop/DesktopExperience.test.tsx:293). Fixed the flaky full-suite failure in [DesktopExperience.test.tsx](/tmp/git-processor/worktrees/integry/propr/pr-1979-followup-2026-08-29T19-43-24/propr-ui/src/desktop/DesktopExperience.test.tsx:293). The test now opens instance management through the title-bar control, avoiding an unrelated shortcut-listener timing race. Production behavior is unchanged. Validation: - UI suite: 70 files, 500 tests passed - UI typecheck: passed - `git diff --check`: passed No commit created. PR: #1979 Comment by: @github-actions[bot] (ID: 5464467107) Model: gpt-5.6-sol --- propr-ui/src/desktop/DesktopExperience.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx index d1ae8880e..8fb9bbb4b 100644 --- a/propr-ui/src/desktop/DesktopExperience.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -296,10 +296,15 @@ describe('DesktopExperience', () => { .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }) .mockImplementationOnce(() => pendingProbe.promise); const adapters = adaptersFor([localProfile], localProfile.id, probe); - render(
Connected app
); + render( + + +
Connected app
+
+ ); expect(await screen.findByText('Connected app')).toBeInTheDocument(); - fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); if (profileKind === 'new') { fireEvent.click(await screen.findByRole('button', { name: /Add instance/i })); fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'New server' } }); From c524a9e5508611ca26aad7bedc908c745304dfd8 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:58:01 +0000 Subject: [PATCH 4/6] feat(ai): Reapplied the canonical deep-link repair on exact head `6fe54e8667a648429c5e4080d44bb0d5ff936abe`. Reapplied the canonical deep-link repair on exact head `6fe54e8667a648429c5e4080d44bb0d5ff936abe`. Changed only: - [security.ts](/tmp/git-processor/worktrees/integry/propr/pr-1980-followup-2026-08-29T19-52-01/apps/desktop/src/security.ts:32) - [security.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-1980-followup-2026-08-29T19-52-01/apps/desktop/src/security.test.ts:77) - [desktop-deep-link.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-1980-followup-2026-08-29T19-52-01/propr-ui/src/desktop-deep-link.test.ts:30) Verification: - Desktop: 27/27 passed - Focused UI: 25/25 passed - Full UI: 500/500 passed - Desktop/UI typechecks: passed - Production package: passed - Exact encoded `#` and `?`, single/double-encoded traversal variants: all returned `null` - Normal `/tasks?status=open#recent`: preserved - CI-only `DesktopExperience.test.tsx` change: preserved - Final diff: three requested files only Packaged smoke was attempted but the non-root worker cannot configure the required root-owned setuid Chromium sandbox helper and lacks Xvfb. The application correctly refused to launch without sandboxing. Per instruction, I did not commit or push. `git ls-remote` therefore still reports the published head as `6fe54e8`; post-publication verification can only occur after the system creates and publishes its automatic commit. PR: #1980 Comment by: @integry (ID: 5464512567) Model: gpt-5.6-sol --- apps/desktop/src/security.test.ts | 19 ++++++++++ apps/desktop/src/security.ts | 49 ++++++++++++++++---------- propr-ui/src/desktop-deep-link.test.ts | 8 +++-- 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts index 2b89dcfd2..25cc01f05 100644 --- a/apps/desktop/src/security.test.ts +++ b/apps/desktop/src/security.test.ts @@ -77,9 +77,28 @@ describe('desktop URL security', () => { it('accepts a normal internal dashboard route from an open deep link', () => { const link = 'propr://open?path=%2Ftasks'; + const queryAndHashLink = 'propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent'; assert.equal(dashboardPathFromDeepLink(link), '/tasks'); assert.equal(normalizeDeepLink(link), link); assert.equal(normalizeDesktopDashboardPath('/tasks?status=open'), '/tasks?status=open'); + assert.equal(dashboardPathFromDeepLink(queryAndHashLink), '/tasks?status=open#recent'); + assert.equal(normalizeDesktopDashboardPath('/tasks?status=open#recent'), '/tasks?status=open#recent'); + }); + + it('rejects encoded delimiters combined with encoded traversal', () => { + const rejectedPaths = [ + '/tasks%23/%2e%2e/login', + '/tasks%23/%252e%252e/login', + '/tasks%3f/%2e%2e/login', + '/tasks%3f/%252e%252e/login', + ]; + + rejectedPaths.forEach(path => { + const link = `propr://open?path=${encodeURIComponent(path)}`; + assert.equal(normalizeDesktopDashboardPath(path), null, path); + assert.equal(dashboardPathFromDeepLink(link), null, link); + assert.equal(normalizeDeepLink(link), null, link); + }); }); it('rejects malformed and unsafe open deep-link paths', () => { diff --git a/apps/desktop/src/security.ts b/apps/desktop/src/security.ts index a355acc6f..f6d9a13d0 100644 --- a/apps/desktop/src/security.ts +++ b/apps/desktop/src/security.ts @@ -29,16 +29,39 @@ const isSafeDashboardPathForm = (value: string): boolean => { return !pathname.split('/').some(segment => segment === '.' || segment === '..'); }; -const fullyDecodeDashboardPath = (value: string): string | null => { +const isSafeDecodedPathScope = (value: string): boolean => { + if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return false; + if (/[\u0000-\u001F\u007F\\]/.test(value)) return false; + return !value.split('/').some(segment => segment === '.' || segment === '..'); +}; + +const isAllowedDashboardUrl = (url: URL): boolean => { + if (url.origin !== DESKTOP_DASHBOARD_ORIGIN) return false; + const route = url.pathname.toLowerCase().replace(/\/+$/, '') || '/'; + if (route === '/login' || route.startsWith('/login/') || route === '/desktop/pairing') return false; + return ![...url.searchParams.keys()].some(key => RESERVED_DASHBOARD_PARAMETERS.has(key.toLowerCase())); +}; + +const fullyDecodeDashboardPath = (value: string): URL | null => { let decoded = value; + // Keep the original path scope while decoding so encoded delimiters cannot hide traversal in a later layer. + let decodedPathScope = value.split(/[?#]/, 1)[0]; for (let remaining = value.length + 1; remaining > 0; remaining -= 1) { - if (!isSafeDashboardPathForm(decoded)) return null; - if (!decoded.includes('%')) return decoded; + if (!isSafeDashboardPathForm(decoded) || !isSafeDecodedPathScope(decodedPathScope)) return null; + let url: URL; + try { + url = new URL(decoded, DESKTOP_DASHBOARD_ORIGIN); + } catch { + return null; + } + if (!isAllowedDashboardUrl(url)) return null; + if (!decoded.includes('%')) return url; if (/%(?![\da-f]{2})/i.test(decoded)) return null; try { const next = decodeURIComponent(decoded); - if (next === decoded) return decoded; + if (next === decoded) return url; decoded = next; + decodedPathScope = decodeURIComponent(decodedPathScope); } catch { return null; } @@ -48,21 +71,9 @@ const fullyDecodeDashboardPath = (value: string): string | null => { export const normalizeDesktopDashboardPath = (value: string): string | null => { if (!value || value.length > 2_048) return null; - const fullyDecoded = fullyDecodeDashboardPath(value); - if (!fullyDecoded) return null; - try { - const url = new URL(value, DESKTOP_DASHBOARD_ORIGIN); - const decodedUrl = new URL(fullyDecoded, DESKTOP_DASHBOARD_ORIGIN); - if (url.origin !== DESKTOP_DASHBOARD_ORIGIN || decodedUrl.origin !== DESKTOP_DASHBOARD_ORIGIN) return null; - const route = decodedUrl.pathname.toLowerCase().replace(/\/+$/, '') || '/'; - if (route === '/login' || route.startsWith('/login/') || route === '/desktop/pairing') return null; - if ([...decodedUrl.searchParams.keys()].some(key => RESERVED_DASHBOARD_PARAMETERS.has(key.toLowerCase()))) { - return null; - } - return `${url.pathname}${url.search}${url.hash}`; - } catch { - return null; - } + const url = fullyDecodeDashboardPath(value); + if (!url) return null; + return `${url.pathname}${url.search}${url.hash}`; }; export const dashboardPathFromDeepLink = (value: string): string | null => { diff --git a/propr-ui/src/desktop-deep-link.test.ts b/propr-ui/src/desktop-deep-link.test.ts index c30ff81a5..8e42cb6db 100644 --- a/propr-ui/src/desktop-deep-link.test.ts +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -30,8 +30,8 @@ describe('desktop open deep-link navigation', () => { const navigation = new DesktopDeepLinkNavigation(navigate); navigation.setDashboardReady(); - expect(navigation.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen')).toBe(true); - expect(navigate).toHaveBeenCalledWith('/tasks?status=open'); + expect(navigation.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent')).toBe(true); + expect(navigate).toHaveBeenCalledWith('/tasks?status=open#recent'); }); it('does not route malformed or unsafe links before or after dashboard load', () => { @@ -42,6 +42,10 @@ describe('desktop open deep-link navigation', () => { 'propr://open?path=https%3A%2F%2Fevil.example', 'propr://open?path=%2F%2Fevil.example', 'propr://open?path=%2Ftasks%252F..%252Flogin', + 'propr://open?path=%2Ftasks%2523%2F%252e%252e%2Flogin', + 'propr://open?path=%2Ftasks%2523%2F%25252e%25252e%2Flogin', + 'propr://open?path=%2Ftasks%253F%2F%252e%252e%2Flogin', + 'propr://open?path=%2Ftasks%253F%2F%25252e%25252e%2Flogin', 'propr://open?path=%2Ftasks%250Anext', 'propr://open?path=%2Flogin%3Foauth_complete%3Dtrue', 'propr://open?path=%2Ftasks%3Fflow%3Dattacker', From 48f85811addf4bccfd390961e8dcb68971e6a0e6 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:04:18 +0000 Subject: [PATCH 5/6] feat(ai): Fixed the full-suite flake in [notificationManagementRoutes.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-1980-followup-2026-08-29T19-58-19/packages/api/test/notificationManagementRoutes.test.ts:65). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed the full-suite flake in [notificationManagementRoutes.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-1980-followup-2026-08-29T19-58-19/packages/api/test/notificationManagementRoutes.test.ts:65). Node occasionally emits a 31-byte ECDH private scalar by omitting a leading zero. The fixture now pads it to VAPID’s required 32-byte width, preventing valid mismatched-key cases from being misclassified as malformed. No production code changed. Validation: - Previously failing test: 3/3 passed - Desktop tests: 27/27 passed - Focused UI tests: 25/25 passed - Desktop/UI typechecks: passed - Production package: passed - `git diff --check`: passed - Final diff: one test file only The published PR head is `c524a9e5`, contains the canonical deep-link fix, and still targets `1951-epic-desktop-experience-uxs`. A local full-suite rerun was unavailable because this worker has neither Docker nor Redis. Sandboxed smoke was also blocked by the environment’s missing root-owned Chromium sandbox helper and Xvfb. No commit was created. PR: #1980 Comment by: @github-actions[bot] (ID: 5464573988) Model: gpt-5.6-sol --- packages/api/test/notificationManagementRoutes.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/api/test/notificationManagementRoutes.test.ts b/packages/api/test/notificationManagementRoutes.test.ts index df66da03a..fc5370563 100644 --- a/packages/api/test/notificationManagementRoutes.test.ts +++ b/packages/api/test/notificationManagementRoutes.test.ts @@ -65,9 +65,13 @@ function recorder(): { response: Response; status: () => number; body: () => unk function vapidPair(): { publicKey: string; privateKey: string } { const ecdh = createECDH('prime256v1'); ecdh.generateKeys(); + // Node omits leading zero bytes, while VAPID private keys are fixed-width scalars. + const privateKey = Buffer.alloc(32); + const generatedPrivateKey = ecdh.getPrivateKey(); + generatedPrivateKey.copy(privateKey, privateKey.length - generatedPrivateKey.length); return { publicKey: ecdh.getPublicKey(undefined, 'uncompressed').toString('base64url'), - privateKey: ecdh.getPrivateKey().toString('base64url') + privateKey: privateKey.toString('base64url') }; } From d99f006211026dac64d355ceb9d5c31d2683b22b Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:52:04 +0000 Subject: [PATCH 6/6] feat(ai): Implemented F1 only on base head `48f85811addf4bccfd390961e8dcb68971e6a0e6`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented F1 only on base head `48f85811addf4bccfd390961e8dcb68971e6a0e6`. Changes: - [security.ts](/tmp/git-processor/worktrees/integry/propr/pr-1979-followup-2026-08-29T20-41-29/apps/desktop/src/security.ts) now checks the serialized canonical candidate’s length/controls and requires its dashboard path to equal the validated input path before returning it. - Added desktop and renderer regressions for percent-expansion beyond 2,048 and an exactly 2,048-character valid canonical link. - Existing traversal, encoded-delimiter, auth/pairing, query, and hash tests remain passing. Verification: - Desktop tests: 28 passed. - Focused renderer tests: 5 passed. - Desktop and UI typechecks: passed. - Production package: passed. - `git diff --check`: passed. - Packaged smoke: environment-blocked because no usable Chromium sandbox helper is installed/configured. - Full suite: reached file 155/320 before blocking on unavailable Redis (`127.0.0.1:6379`); neither Redis nor Docker is installed. Only the three scoped files are modified. Per instruction, I did not commit or push; the system must publish the resulting branch head. PR: #1979 Comment by: @integry (ID: 5464771154) Model: gpt-5.6-sol --- apps/desktop/src/security.test.ts | 19 +++++++++++++++++++ apps/desktop/src/security.ts | 12 ++++++++++-- propr-ui/src/desktop-deep-link.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts index 25cc01f05..0a88499f1 100644 --- a/apps/desktop/src/security.test.ts +++ b/apps/desktop/src/security.test.ts @@ -85,6 +85,25 @@ describe('desktop URL security', () => { assert.equal(normalizeDesktopDashboardPath('/tasks?status=open#recent'), '/tasks?status=open#recent'); }); + it('revalidates open links after canonical serialization', () => { + const rawPath = `/tasks/${'é '.repeat(300)}end`; + const rawLink = `propr://open?path=${rawPath}`; + const expandedCanonicalLink = new URL(rawLink).href; + assert.ok(rawLink.length < 2_048); + assert.ok(expandedCanonicalLink.length > 2_048); + assert.notEqual(dashboardPathFromDeepLink(rawLink), null); + assert.equal(dashboardPathFromDeepLink(expandedCanonicalLink), null); + assert.equal(normalizeDeepLink(rawLink), null); + + const canonicalPrefix = 'propr://open?path=%2Ftasks%2F'; + const suffix = 'a'.repeat(2_048 - canonicalPrefix.length); + const boundaryCanonicalLink = `${canonicalPrefix}${suffix}`; + assert.equal(boundaryCanonicalLink.length, 2_048); + assert.equal(new URL(boundaryCanonicalLink).href, boundaryCanonicalLink); + assert.equal(dashboardPathFromDeepLink(boundaryCanonicalLink), `/tasks/${suffix}`); + assert.equal(normalizeDeepLink(boundaryCanonicalLink), boundaryCanonicalLink); + }); + it('rejects encoded delimiters combined with encoded traversal', () => { const rejectedPaths = [ '/tasks%23/%2e%2e/login', diff --git a/apps/desktop/src/security.ts b/apps/desktop/src/security.ts index f6d9a13d0..8b1695840 100644 --- a/apps/desktop/src/security.ts +++ b/apps/desktop/src/security.ts @@ -138,8 +138,16 @@ export const normalizeDeepLink = (value: string): string | null => { const url = parseUrl(value); if (!url || url.protocol !== `${DESKTOP_PROTOCOL}:` || hasCredentials(url)) return null; if (!DEEP_LINK_ACTIONS.has(url.hostname) || url.port || url.hash) return null; - if (url.hostname === 'open' && !dashboardPathFromDeepLink(value)) return null; - return url.href; + const dashboardPath = url.hostname === 'open' ? dashboardPathFromDeepLink(value) : null; + if (url.hostname === 'open' && dashboardPath === null) return null; + + const canonicalCandidate = url.href; + if (canonicalCandidate.length > 2_048 || /[\u0000-\u001F\u007F]/.test(canonicalCandidate)) return null; + if ( + url.hostname === 'open' + && dashboardPathFromDeepLink(canonicalCandidate) !== dashboardPath + ) return null; + return canonicalCandidate; }; export const deepLinkFromArguments = (argv: readonly string[]): string | null => { diff --git a/propr-ui/src/desktop-deep-link.test.ts b/propr-ui/src/desktop-deep-link.test.ts index 8e42cb6db..b431da2ff 100644 --- a/propr-ui/src/desktop-deep-link.test.ts +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -34,6 +34,28 @@ describe('desktop open deep-link navigation', () => { expect(navigate).toHaveBeenCalledWith('/tasks?status=open#recent'); }); + it('rejects an expanded canonical link and accepts one at the length limit', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + navigation.setDashboardReady(); + + const rawPath = `/tasks/${'é '.repeat(300)}end`; + const rawLink = `propr://open?path=${rawPath}`; + const expandedCanonicalLink = new URL(rawLink).href; + expect(rawLink.length).toBeLessThan(2_048); + expect(expandedCanonicalLink.length).toBeGreaterThan(2_048); + expect(navigation.receive(expandedCanonicalLink)).toBe(false); + + const canonicalPrefix = 'propr://open?path=%2Ftasks%2F'; + const suffix = 'a'.repeat(2_048 - canonicalPrefix.length); + const boundaryCanonicalLink = `${canonicalPrefix}${suffix}`; + expect(boundaryCanonicalLink).toHaveLength(2_048); + expect(new URL(boundaryCanonicalLink).href).toBe(boundaryCanonicalLink); + expect(navigation.receive(boundaryCanonicalLink)).toBe(true); + expect(navigate).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith(`/tasks/${suffix}`); + }); + it('does not route malformed or unsafe links before or after dashboard load', () => { const navigate = vi.fn(); const navigation = new DesktopDeepLinkNavigation(navigate);