Load packaged Desktop from a dedicated goose-app://goose origin

This commit is contained in:
Lifei Zhou
2026-07-02 15:28:24 +10:00
parent 8cb6d4766c
commit b1961ce1cb
5 changed files with 298 additions and 66 deletions
+45
View File
@@ -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');
});
});
+91
View File
@@ -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';
}
}
+98 -53
View File
@@ -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();
}
}
);
});
+4 -4
View File
@@ -32,6 +32,7 @@ export interface StartGooseServeOptions extends FindGooseBinaryOptions {
dir?: string;
serverSecret: string;
tls?: boolean;
allowedOrigins?: string[];
env?: Record<string, string | undefined>;
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<boolean> => {
const fetchStatus = async (statusUrl: string, readinessFetch: ReadinessFetch): Promise<boolean> => {
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',
+60 -9
View File
@@ -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();
}