mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Load packaged Desktop from a dedicated goose-app://goose origin
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user