From b1961ce1cb5fa4da5670fb425b406ea3c0b93a0a Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 2 Jul 2026 15:28:24 +1000 Subject: [PATCH] Load packaged Desktop from a dedicated goose-app://goose origin --- ui/desktop/src/appProtocol.test.ts | 45 +++++++++ ui/desktop/src/appProtocol.ts | 91 +++++++++++++++++ ui/desktop/src/gooseServe.test.ts | 151 +++++++++++++++++++---------- ui/desktop/src/gooseServe.ts | 8 +- ui/desktop/src/main.ts | 69 +++++++++++-- 5 files changed, 298 insertions(+), 66 deletions(-) create mode 100644 ui/desktop/src/appProtocol.test.ts create mode 100644 ui/desktop/src/appProtocol.ts diff --git a/ui/desktop/src/appProtocol.test.ts b/ui/desktop/src/appProtocol.test.ts new file mode 100644 index 0000000000..dc7d146c6f --- /dev/null +++ b/ui/desktop/src/appProtocol.test.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + PACKAGED_RENDERER_ORIGIN, + packagedRendererUrl, + rendererContentType, + resolvePackagedRendererPath, +} from './appProtocol'; + +describe('appProtocol', () => { + it('uses the packaged renderer app origin', () => { + expect(PACKAGED_RENDERER_ORIGIN).toBe('goose-app://goose'); + expect(packagedRendererUrl().toString()).toBe('goose-app://goose/index.html'); + }); + + it('resolves packaged renderer asset paths under the renderer root', () => { + const root = path.resolve('/tmp/goose-renderer'); + + expect(resolvePackagedRendererPath('goose-app://goose/', root)).toBe( + path.join(root, 'index.html') + ); + expect(resolvePackagedRendererPath('goose-app://goose/assets/index.js', root)).toBe( + path.join(root, 'assets', 'index.js') + ); + }); + + it('rejects non-renderer URLs and path traversal', () => { + const root = path.resolve('/tmp/goose-renderer'); + + expect(resolvePackagedRendererPath('https://goose/index.html', root)).toBeNull(); + expect(resolvePackagedRendererPath('goose-app://other/index.html', root)).toBeNull(); + expect(resolvePackagedRendererPath('goose-app://goose/%2e%2e/settings.json', root)).toBeNull(); + expect( + resolvePackagedRendererPath('goose-app://goose/assets%5C..%5Csettings.json', root) + ).toBeNull(); + }); + + it('returns content types for renderer assets', () => { + expect(rendererContentType('/tmp/index.html')).toBe('text/html; charset=utf-8'); + expect(rendererContentType('/tmp/assets/index.js')).toBe('text/javascript; charset=utf-8'); + expect(rendererContentType('/tmp/assets/index.css')).toBe('text/css; charset=utf-8'); + expect(rendererContentType('/tmp/assets/font.woff2')).toBe('font/woff2'); + expect(rendererContentType('/tmp/assets/file.bin')).toBe('application/octet-stream'); + }); +}); diff --git a/ui/desktop/src/appProtocol.ts b/ui/desktop/src/appProtocol.ts new file mode 100644 index 0000000000..0992ad1ac2 --- /dev/null +++ b/ui/desktop/src/appProtocol.ts @@ -0,0 +1,91 @@ +import path from 'node:path'; + +export const PACKAGED_RENDERER_PROTOCOL = 'goose-app'; +export const PACKAGED_RENDERER_HOST = 'goose'; +export const PACKAGED_RENDERER_ORIGIN = `${PACKAGED_RENDERER_PROTOCOL}://${PACKAGED_RENDERER_HOST}`; + +export function packagedRendererUrl(): URL { + return new URL(`${PACKAGED_RENDERER_ORIGIN}/index.html`); +} + +function containsTraversalSegment(requestUrl: string): boolean { + return /(?:^|\/|%2f|\\|%5c)(?:\.\.|%2e%2e)(?:$|\/|%2f|\\|%5c|\?|#)/i.test(requestUrl); +} + +export function resolvePackagedRendererPath( + requestUrl: string, + rendererRoot: string +): string | null { + if (containsTraversalSegment(requestUrl)) { + return null; + } + + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return null; + } + + if ( + url.protocol !== `${PACKAGED_RENDERER_PROTOCOL}:` || + url.hostname !== PACKAGED_RENDERER_HOST + ) { + return null; + } + + let pathname: string; + try { + pathname = decodeURIComponent(url.pathname); + } catch { + return null; + } + + const relativePath = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, ''); + if (!relativePath || relativePath.includes('\0') || relativePath.includes('\\')) { + return null; + } + + const root = path.resolve(rendererRoot); + const resolvedPath = path.resolve(root, relativePath); + if (resolvedPath !== root && !resolvedPath.startsWith(`${root}${path.sep}`)) { + return null; + } + + return resolvedPath; +} + +export function rendererContentType(filePath: string): string { + switch (path.extname(filePath).toLowerCase()) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + case '.mjs': + return 'text/javascript; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.ico': + return 'image/x-icon'; + case '.wasm': + return 'application/wasm'; + case '.woff': + return 'font/woff'; + case '.woff2': + return 'font/woff2'; + default: + return 'application/octet-stream'; + } +} diff --git a/ui/desktop/src/gooseServe.test.ts b/ui/desktop/src/gooseServe.test.ts index 2ee41f2ead..08cb056a1f 100644 --- a/ui/desktop/src/gooseServe.test.ts +++ b/ui/desktop/src/gooseServe.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PACKAGED_RENDERER_ORIGIN } from './appProtocol'; import { buildLocalServeUrls, findGooseBinaryPath, startGooseServe } from './gooseServe'; const binaryName = process.platform === 'win32' ? 'goose.exe' : 'goose'; @@ -202,80 +203,124 @@ describe('startGooseServe', () => { } }); - it.skipIf(process.platform === 'win32')('uses TLS URLs and args when TLS is enabled', async () => { + it.skipIf(process.platform === 'win32')( + 'uses TLS URLs and args when TLS is enabled', + async () => { + const tempDir = makeTempDir(); + const argsPath = path.join(tempDir, 'args.txt'); + const goosePath = makeExecutable( + path.join(tempDir, 'goose'), + [ + '#!/usr/bin/env sh', + 'printf "%s\\n" "$@" > "$TEST_ARGS_PATH"', + 'printf "GOOSED_CERT_FINGERPRINT=DD:EE:FF\\n"', + 'while true; do sleep 1; done', + '', + ].join('\n') + ); + vi.stubEnv('GOOSE_BINARY', goosePath); + + const readinessUrls: string[] = []; + const logger = { + info: vi.fn(), + error: vi.fn(), + }; + const readinessFetch = vi.fn(async (input: string, _init?: ReadinessFetchInit) => { + readinessUrls.push(input); + return new Response(null, { status: 200 }); + }); + + const result = await startGooseServe({ + serverSecret: 'test-secret', + dir: tempDir, + tls: true, + env: { + TEST_ARGS_PATH: argsPath, + }, + logger, + readinessFetch, + }); + + try { + expect(readinessUrls[0]).toMatch(/^https:\/\/127\.0\.0\.1:\d+\/status$/); + expect(result.acpUrl).toMatch(/^wss:\/\/127\.0\.0\.1:\d+\/acp\?token=test-secret$/); + expect(result.certFingerprint).toBe('DD:EE:FF'); + const args = await waitForFileLines(argsPath); + expect(args).toContain('--tls'); + expect(args).not.toContain('--allowed-origin'); + } finally { + await result.cleanup(); + } + } + ); + + it.skipIf(process.platform === 'win32')('passes allowed origins to goose serve', async () => { const tempDir = makeTempDir(); + const resourcesPath = path.join(tempDir, 'resources'); const argsPath = path.join(tempDir, 'args.txt'); - const goosePath = makeExecutable( - path.join(tempDir, 'goose'), + makeExecutable( + path.join(resourcesPath, 'bin', binaryName), [ '#!/usr/bin/env sh', 'printf "%s\\n" "$@" > "$TEST_ARGS_PATH"', - 'printf "GOOSED_CERT_FINGERPRINT=DD:EE:FF\\n"', 'while true; do sleep 1; done', '', ].join('\n') ); - vi.stubEnv('GOOSE_BINARY', goosePath); - - const readinessUrls: string[] = []; - const logger = { - info: vi.fn(), - error: vi.fn(), - }; - const readinessFetch = vi.fn(async (input: string, _init?: ReadinessFetchInit) => { - readinessUrls.push(input); - return new Response(null, { status: 200 }); - }); - - const result = await startGooseServe({ - serverSecret: 'test-secret', - dir: tempDir, - tls: true, - env: { - TEST_ARGS_PATH: argsPath, - }, - logger, - readinessFetch, - }); - - try { - expect(readinessUrls[0]).toMatch(/^https:\/\/127\.0\.0\.1:\d+\/status$/); - expect(result.acpUrl).toMatch(/^wss:\/\/127\.0\.0\.1:\d+\/acp\?token=test-secret$/); - expect(result.certFingerprint).toBe('DD:EE:FF'); - await expect(waitForFileLines(argsPath)).resolves.toContain('--tls'); - } finally { - await result.cleanup(); - } - }); - - it.skipIf(process.platform === 'win32')('waits for TLS fingerprint after readiness succeeds', async () => { - const tempDir = makeTempDir(); - const goosePath = makeExecutable( - path.join(tempDir, 'goose'), - [ - '#!/usr/bin/env sh', - 'sleep 0.1', - 'printf "GOOSED_CERT_FINGERPRINT=11:22:33\\n"', - 'while true; do sleep 1; done', - '', - ].join('\n') - ); - vi.stubEnv('GOOSE_BINARY', goosePath); const readinessFetch = vi.fn(async () => new Response(null, { status: 200 })); const result = await startGooseServe({ serverSecret: 'test-secret', dir: tempDir, - tls: true, + isPackaged: true, + resourcesPath, + allowedOrigins: [PACKAGED_RENDERER_ORIGIN], + env: { + TEST_ARGS_PATH: argsPath, + }, readinessFetch, }); try { - expect(readinessFetch).toHaveBeenCalled(); - expect(result.certFingerprint).toBe('11:22:33'); + const args = await waitForFileLines(argsPath); + expect(args).toEqual(expect.arrayContaining(['--allowed-origin', PACKAGED_RENDERER_ORIGIN])); } finally { await result.cleanup(); } }); + + it.skipIf(process.platform === 'win32')( + 'waits for TLS fingerprint after readiness succeeds', + async () => { + const tempDir = makeTempDir(); + const goosePath = makeExecutable( + path.join(tempDir, 'goose'), + [ + '#!/usr/bin/env sh', + 'sleep 0.1', + 'printf "GOOSED_CERT_FINGERPRINT=11:22:33\\n"', + 'while true; do sleep 1; done', + '', + ].join('\n') + ); + vi.stubEnv('GOOSE_BINARY', goosePath); + + const readinessFetch = vi.fn(async () => new Response(null, { status: 200 })); + + const result = await startGooseServe({ + serverSecret: 'test-secret', + dir: tempDir, + tls: true, + readinessFetch, + }); + + try { + expect(readinessFetch).toHaveBeenCalled(); + expect(result.certFingerprint).toBe('11:22:33'); + } finally { + await result.cleanup(); + } + } + ); }); diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts index bfaccaed18..51f663b7f5 100644 --- a/ui/desktop/src/gooseServe.ts +++ b/ui/desktop/src/gooseServe.ts @@ -32,6 +32,7 @@ export interface StartGooseServeOptions extends FindGooseBinaryOptions { dir?: string; serverSecret: string; tls?: boolean; + allowedOrigins?: string[]; env?: Record; logger?: Logger; diagnosticsDir?: string; @@ -135,10 +136,7 @@ const appendErrorTail = (target: string[], lines: string[], maxLines = 100): voi const CERT_FINGERPRINT_PREFIX = 'GOOSED_CERT_FINGERPRINT='; const TLS_FINGERPRINT_TIMEOUT_MS = 5000; -const fetchStatus = async ( - statusUrl: string, - readinessFetch: ReadinessFetch -): Promise => { +const fetchStatus = async (statusUrl: string, readinessFetch: ReadinessFetch): Promise => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1000); @@ -321,6 +319,7 @@ export const startGooseServe = async ({ dir, serverSecret, tls = false, + allowedOrigins = [], env: additionalEnv = {}, isPackaged, resourcesPath, @@ -358,6 +357,7 @@ export const startGooseServe = async ({ const args = [ 'serve', ...(tls ? ['--tls'] : []), + ...allowedOrigins.flatMap((origin) => ['--allowed-origin', origin]), '--platform', 'desktop', '--host', diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index dd938be40c..94c68ecd52 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -11,12 +11,13 @@ import { net, Notification, powerSaveBlocker, + protocol, screen, session, shell, Tray, } from 'electron'; -import { pathToFileURL, format as formatUrl, URLSearchParams } from 'node:url'; +import { format as formatUrl, URLSearchParams } from 'node:url'; import { Buffer } from 'node:buffer'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; @@ -54,6 +55,25 @@ import type { GooseApp } from './types/apps'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity'; import { buildCSP } from './utils/csp'; +import { + PACKAGED_RENDERER_ORIGIN, + PACKAGED_RENDERER_PROTOCOL, + packagedRendererUrl, + rendererContentType, + resolvePackagedRendererPath, +} from './appProtocol'; + +protocol.registerSchemesAsPrivileged([ + { + scheme: PACKAGED_RENDERER_PROTOCOL, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, +]); function shouldSetupUpdater(): boolean { // Setup updater if either the flag is enabled OR dev updates are enabled @@ -817,10 +837,44 @@ async function handleFileOpen(filePath: string) { declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare var MAIN_WINDOW_VITE_NAME: string; +function getPackagedRendererRoot(): string { + return path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`); +} + +let packagedRendererProtocolRegistered = false; + +function registerPackagedRendererProtocol() { + if (MAIN_WINDOW_VITE_DEV_SERVER_URL || packagedRendererProtocolRegistered) { + return; + } + + protocol.handle(PACKAGED_RENDERER_PROTOCOL, async (request) => { + const filePath = resolvePackagedRendererPath(request.url, getPackagedRendererRoot()); + if (!filePath) { + return new Response('Not found', { status: 404 }); + } + + try { + const data = await fs.readFile(filePath); + return new Response(new Uint8Array(data), { + headers: { + 'Content-Type': rendererContentType(filePath), + }, + }); + } catch { + return new Response('Not found', { status: 404 }); + } + }); + packagedRendererProtocolRegistered = true; +} + function getAppUrl(): URL { - return MAIN_WINDOW_VITE_DEV_SERVER_URL - ? new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL) - : pathToFileURL(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)); + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + return new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL); + } + + registerPackagedRendererProtocol(); + return packagedRendererUrl(); } // Parse command line arguments @@ -1147,6 +1201,7 @@ const createChat = async ( serverSecret, dir: workingDir, tls: true, + allowedOrigins: app.isPackaged ? [PACKAGED_RENDERER_ORIGIN] : undefined, env: { GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined, }, @@ -2409,6 +2464,7 @@ const registerGlobalShortcuts = () => { async function appMain() { await configureProxy(); + registerPackagedRendererProtocol(); // Ensure Windows shims are available before any MCP processes are spawned await ensureWinShims(); @@ -2451,11 +2507,6 @@ async function appMain() { // Register global shortcuts based on settings registerGlobalShortcuts(); - session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { - details.requestHeaders['Origin'] = 'http://localhost:5173'; - callback({ cancel: false, requestHeaders: details.requestHeaders }); - }); - if (settings.showMenuBarIcon) { createTray(); }