fix: shell ACP providers on desktop (#10907)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user