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;
}