diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts index 4deb00d8f..cf9eac255 100644 --- a/ui/desktop/src/gooseServe.ts +++ b/ui/desktop/src/gooseServe.ts @@ -33,6 +33,8 @@ export interface StartGooseServeOptions extends FindGooseBinaryOptions { serverSecret: string; tls?: boolean; env?: Record; + /** PATH from the user's login shell, appended so goosed can find CLI providers. */ + loginShellPath?: string | null; logger?: Logger; diagnosticsDir?: string; readinessFetch?: ReadinessFetch; @@ -288,7 +290,8 @@ const withStartupDiagnosticsPath = ( const buildGooseServeEnv = ( serverSecret: string, binaryPath: string, - additionalEnv: Record + additionalEnv: Record, + loginShellPath?: string | null ): Record => { const homeDir = process.env.HOME || os.homedir(); const pathKey = process.platform === 'win32' ? 'Path' : 'PATH'; @@ -297,7 +300,9 @@ const buildGooseServeEnv = ( const env: Record = { ...process.env, HOME: homeDir, - [pathKey]: `${path.dirname(binaryPath)}${path.delimiter}${currentPath}`, + [pathKey]: [path.dirname(binaryPath), currentPath, loginShellPath] + .filter(Boolean) + .join(path.delimiter), }; if (process.platform === 'win32') { @@ -322,6 +327,7 @@ export const startGooseServe = async ({ serverSecret, tls = false, env: additionalEnv = {}, + loginShellPath, isPackaged, resourcesPath, logger = defaultLogger, @@ -385,7 +391,7 @@ export const startGooseServe = async ({ } const spawnOptions = { - env: buildGooseServeEnv(secretKey, goosePath, additionalEnv), + env: buildGooseServeEnv(secretKey, goosePath, additionalEnv, loginShellPath), cwd: workingDir, windowsHide: true, shell: false as const, diff --git a/ui/desktop/src/loginShellPath.ts b/ui/desktop/src/loginShellPath.ts new file mode 100644 index 000000000..8869c1edb --- /dev/null +++ b/ui/desktop/src/loginShellPath.ts @@ -0,0 +1,64 @@ +import { spawn } from 'child_process'; + +import type { Logger } from './gooseServe'; + +const RESOLVE_TIMEOUT_MS = 5000; + +/** + * Resolve the user's full PATH by running their login shell (bash/zsh). + * + * The desktop app launched from Finder/Dock inherits a minimal PATH from + * launchd, so goosed can't find CLI-backed providers (claude, etc.). Sourcing + * the user's profile via a login+interactive shell recovers the real PATH. + * Doing this here rather than in goosed keeps the plain `goose` CLI on the + * ambient PATH. Returns null on non-macOS platforms, timeout, or any failure. + */ +const resolveLoginShellPath = (logger?: Logger): Promise => { + if (process.platform !== 'darwin') { + return Promise.resolve(null); + } + + const shell = process.env.SHELL || 'bash'; + + return new Promise((resolve) => { + // detached: a new session keeps the interactive shell's job-control setup + // from stealing the terminal foreground and suspending the app. + // Use `printenv PATH` instead of `echo $PATH` so the command is + // shell-neutral: fish treats $PATH as a list and space-joins it under + // `echo`, which would corrupt the resolved PATH for fish users. + const child = spawn(shell, ['-l', '-i', '-c', 'printenv PATH'], { + stdio: ['ignore', 'pipe', 'ignore'], + detached: true, + windowsHide: true, + }); + + const timer = setTimeout(() => { + child.kill(); + resolve(null); + }, RESOLVE_TIMEOUT_MS); + timer.unref?.(); + + let stdout = ''; + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + }); + child.on('error', (error) => { + clearTimeout(timer); + logger?.error('Failed to resolve login shell PATH', error); + resolve(null); + }); + child.on('close', (code) => { + clearTimeout(timer); + const path = stdout.trim().split('\n').pop()?.trim(); + resolve(code === 0 && path ? path : null); + }); + }); +}; + +let cached: Promise | undefined; + +/** Resolve the login-shell PATH once per app run, caching the result. */ +export const getLoginShellPath = (logger?: Logger): Promise => { + cached ??= resolveLoginShellPath(logger); + return cached; +}; diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 7f27448e3..daff13e85 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -28,6 +28,7 @@ import { execFileSync, spawn, execFile } from 'child_process'; import 'dotenv/config'; import { checkBackendStatus } from './backendStatus'; import { startGooseServe } from './gooseServe'; +import { getLoginShellPath } from './loginShellPath'; import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry'; import { acpWebSocketUrlFromHttpBase, normalizeAcpHttpBaseUrl } from './acp/url'; import { expandTilde, sanitizeGoosePathRoot } from './utils/pathUtils'; @@ -1155,6 +1156,8 @@ const createChat = async ( } else { const localCertificateTrust = trustBackendCertificate('127.0.0.1', null); + const loginShellPath = await getLoginShellPath(log); + let gooseServeResult: Awaited>; try { gooseServeResult = await startGooseServe({ @@ -1164,6 +1167,7 @@ const createChat = async ( env: { GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined, }, + loginShellPath, isPackaged: app.isPackaged, resourcesPath: app.isPackaged ? process.resourcesPath : undefined, logger: log,