fix: shell ACP providers on desktop (#10907)

This commit is contained in:
Alex Hancock
2026-08-05 09:37:10 -04:00
committed by GitHub
parent 96a5a99cdd
commit f1e8e8ce0d
3 changed files with 77 additions and 3 deletions
+9 -3
View File
@@ -33,6 +33,8 @@ export interface StartGooseServeOptions extends FindGooseBinaryOptions {
serverSecret: string;
tls?: boolean;
env?: Record<string, string | undefined>;
/** 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<string, string | undefined>
additionalEnv: Record<string, string | undefined>,
loginShellPath?: string | null
): Record<string, string | undefined> => {
const homeDir = process.env.HOME || os.homedir();
const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
@@ -297,7 +300,9 @@ const buildGooseServeEnv = (
const env: Record<string, string | undefined> = {
...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,
+64
View File
@@ -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<string | null> => {
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<string | null> | undefined;
/** Resolve the login-shell PATH once per app run, caching the result. */
export const getLoginShellPath = (logger?: Logger): Promise<string | null> => {
cached ??= resolveLoginShellPath(logger);
return cached;
};
+4
View File
@@ -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<ReturnType<typeof startGooseServe>>;
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,