File size: 6,769 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | /**
* Login-shell PATH probe β enrich `process.env.PATH` with entries from the
* user's login shell.
*
* When kimi-code is launched from a context that skipped the user's shell
* profile (GUI launchers, non-login parent shells), `process.env.PATH`
* misses entries like `/opt/homebrew/bin`, so commands spawned by the Bash
* tool can't find tools the user has in their interactive shell (e.g.
* `gh`). We run the user's login shell once (`$SHELL -l -c /usr/bin/env`),
* extract its PATH, and append the entries the current PATH lacks. Existing
* entries keep their order and priority; failures (no resolvable shell,
* hung or broken profile) silently leave PATH untouched.
*
* launchd/daemon launches can leave `$SHELL` unset or blank, so
* the probe falls back to the OS account's login shell from the user
* database before giving up.
*
* Like `detectEnvironment`, the probe is a pure function of injected deps
* so the suite runs identically on any host. Windows is skipped: the
* problem is specific to POSIX login-shell profiles.
*/
import { userInfo } from 'node:os';
import { execFileText } from './environment';
export interface LoginShellPathDeps {
readonly platform: string;
readonly env: Record<string, string | undefined>;
/** Login shell from the OS user database; fallback when $SHELL is unset. */
readonly userShell: () => string | undefined;
readonly execFileText: (
file: string,
args: readonly string[],
timeoutMs: number,
) => Promise<string | undefined>;
}
const LOGIN_SHELL_ENV_TIMEOUT_MS = 5_000;
/**
* Run the user's login shell and return its PATH, or `undefined` when the
* probe does not apply (Windows, no resolvable shell) or fails (spawn
* error, timeout, no PATH in the output).
*/
export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise<string | undefined> {
if (deps.platform === 'win32') return undefined;
// A set-but-blank $SHELL (some daemon/launchd envs) must also fall back.
const envShell = deps.env['SHELL']?.trim();
const shell = envShell === undefined || envShell.length === 0 ? deps.userShell() : envShell;
if (shell === undefined || shell.length === 0) return undefined;
// `env` prints the resolved environment in every shell dialect, unlike
// `echo $PATH`, which fish would join with spaces. Invoke it by absolute
// path: a bare `env` resolves through the inherited PATH β which may
// carry cwd-dependent components β from the workspace cwd, so a
// repo-planted `env` binary could run at session startup and feed us an
// arbitrary PATH. The absolute path also bypasses profile function
// shadowing, and /usr/bin/env is guaranteed on every mainstream POSIX
// system (it is the canonical shebang interpreter path).
const stdout = await deps.execFileText(
shell,
['-l', '-c', '/usr/bin/env'],
LOGIN_SHELL_ENV_TIMEOUT_MS,
);
if (stdout === undefined) return undefined;
// Profile output lands on stdout before `env` runs, so keep the last
// PATH= line.
let path: string | undefined;
for (const line of stdout.split('\n')) {
if (line.startsWith('PATH=')) {
path = line.slice('PATH='.length).trim();
}
}
if (path === undefined || path.length === 0) return undefined;
return path;
}
/**
* Union of the current PATH and the login-shell PATH: the current PATH
* string is kept verbatim β including empty components, which POSIX
* command lookup treats as the current directory β and login-shell
* entries the current PATH lacks are appended in their own order. When
* nothing is missing the current string is returned unchanged. Only
* absolute login-shell entries are imported: empty, `.`, and relative
* components are all cwd-dependent lookup, and appending one the user
* did not already have would widen their search path β LocalKaos runs
* commands from arbitrary workspace directories.
*/
export function mergeLoginShellPath(
currentPath: string | undefined,
loginShellPath: string,
): string {
const current = currentPath ?? '';
const seen = new Set(current.split(':').filter((entry) => entry.length > 0));
const additions: string[] = [];
for (const entry of loginShellPath.split(':')) {
// The probe only runs on POSIX (win32 bails before merging), so a
// leading slash is a sufficient absoluteness test. Empty components
// fail it too.
if (!entry.startsWith('/') || seen.has(entry)) continue;
seen.add(entry);
additions.push(entry);
}
if (additions.length === 0) return current;
// `undefined` means "no PATH at all", so the additions stand alone; ''
// is a real (cwd-only) PATH whose empty component must survive as a
// leading colon.
if (currentPath === undefined) return additions.join(':');
return `${current}:${additions.join(':')}`;
}
/** Probe the login shell and merge its PATH into `deps.env['PATH']`. */
export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise<void> {
const loginShellPath = await probeLoginShellPath(deps);
if (loginShellPath === undefined) return;
const currentPath = deps.env['PATH'];
const merged = mergeLoginShellPath(currentPath, loginShellPath);
// Only write when something was appended β an unset PATH must stay
// unset (assigning '' would turn "implementation default search path"
// into "cwd-only lookup"), and a set PATH must not be rewritten.
if (merged === (currentPath ?? '')) return;
deps.env['PATH'] = merged;
}
/**
* Production convenience β apply the probe to `process.env` once per
* process. Memoised like `detectEnvironmentFromNode`: the login-shell PATH
* does not change for the lifetime of the process, and repeated
* `LocalKaos.create()` calls must not re-spawn the shell.
*/
/**
* Login shell from the OS user database (`/etc/passwd` via getpwuid on
* Linux, Directory Services on macOS). `userInfo()` throws when the uid
* has no database entry (e.g. containers running an arbitrary uid), and
* service accounts may carry `/usr/sbin/nologin` β the latter needs no
* special casing here because probing it simply fails and degrades
* silently.
*/
function userShellFromNode(): string | undefined {
try {
const shell = userInfo().shell;
return shell === null || shell.length === 0 ? undefined : shell;
} catch {
return undefined;
}
}
let appliedLoginShellPath: Promise<void> | undefined;
export function applyLoginShellPathFromNode(): Promise<void> {
if (appliedLoginShellPath !== undefined) return appliedLoginShellPath;
appliedLoginShellPath = applyLoginShellPath({
platform: process.platform,
env: process.env as Record<string, string | undefined>,
userShell: userShellFromNode,
execFileText,
});
return appliedLoginShellPath;
}
|