1 import { execFileSync } from "child_process";
2 import { accessSync, constants, statSync } from "fs";
3 import { homedir } from "os";
4 import { delimiter, isAbsolute, join } from "path";
5
6 /**
7 * Locating the agent binary across launch contexts.
8 *
9 * When VS Code is launched from a GUI (Dock, Finder, Spotlight) rather than a
10 * terminal, the process inherits a minimal system PATH that omits the dirs
11 * where developer tools are usually installed (Homebrew, Cargo, ~/.local/bin,
12 * etc.). A `command` like `sigit` then fails to spawn with ENOENT even though
13 * it runs fine from the user's terminal. We work around this by resolving the
14 * command against an *augmented* PATH that adds the common install locations
15 * and, on macOS/Linux, the user's real login-shell PATH.
16 */
17
18 let cachedLoginPath: string | null | undefined;
19
20 /** Common install directories that GUI launches frequently miss. */
21 function commonBinDirs(): string[] {
22 const home = homedir();
23 if (process.platform === "win32") {
24 return [];
25 }
26 return [
27 "/usr/local/bin",
28 "/opt/homebrew/bin",
29 "/opt/homebrew/sbin",
30 "/usr/bin",
31 "/bin",
32 "/usr/sbin",
33 "/sbin",
34 join(home, ".local", "bin"),
35 join(home, "bin"),
36 join(home, ".cargo", "bin"),
37 join(home, ".bun", "bin"),
38 join(home, ".deno", "bin"),
39 join(home, "go", "bin"),
40 join(home, ".sigit", "bin")
41 ];
42 }
43
44 /**
45 * The PATH exported by the user's interactive login shell. GUI-launched apps on
46 * macOS don't inherit this, so we ask the shell directly. Cached after the
47 * first (best-effort, time-boxed) lookup.
48 */
49 function loginShellPath(): string | undefined {
50 if (cachedLoginPath !== undefined) {
51 return cachedLoginPath ?? undefined;
52 }
53 cachedLoginPath = null;
54 if (process.platform === "win32") {
55 return undefined;
56 }
57 const shell = process.env.SHELL || "/bin/zsh";
58 try {
59 // `-ilc` → interactive login shell running one command. We echo a sentinel
60 // around PATH so noisy rc-file output doesn't corrupt the value.
61 const out = execFileSync(shell, ["-ilc", 'printf "__SIGIT_PATH__%s__SIGIT_END__" "$PATH"'], {
62 encoding: "utf8",
63 timeout: 3000,
64 stdio: ["ignore", "pipe", "ignore"]
65 });
66 const match = /__SIGIT_PATH__([\s\S]*?)__SIGIT_END__/.exec(out);
67 if (match && match[1]) {
68 cachedLoginPath = match[1];
69 }
70 } catch {
71 // Shell missing, slow, or non-interactive — fall back to common dirs only.
72 }
73 return cachedLoginPath ?? undefined;
74 }
75
76 /**
77 * A PATH string combining the current process PATH, the login-shell PATH, and
78 * the common install dirs — de-duplicated, original order preserved.
79 */
80 export function augmentedPath(): string {
81 const parts: string[] = [];
82 const seen = new Set<string>();
83 const push = (value: string | undefined) => {
84 if (!value) {
85 return;
86 }
87 for (const dir of value.split(delimiter)) {
88 if (dir && !seen.has(dir)) {
89 seen.add(dir);
90 parts.push(dir);
91 }
92 }
93 };
94 push(process.env.PATH);
95 push(loginShellPath());
96 for (const dir of commonBinDirs()) {
97 push(dir);
98 }
99 return parts.join(delimiter);
100 }
101
102 /**
103 * Sandbox-safe HuggingFace cache defaults.
104 *
105 * The on-device `sigit` agent redirects its model cache into a macOS App Group
106 * container (`~/Library/Group Containers/group.com.ondeinference.apps/…`) so it
107 * can share weights with the Onde Inference desktop app. That container is only
108 * writable by processes carrying the matching App Group entitlement; a `sigit`
109 * spawned by VS Code has none, so model downloads fail with EPERM ("Operation
110 * not permitted", os error 1). Pointing HF_HOME / HF_HUB_CACHE at the standard
111 * per-user cache keeps downloads in a directory the editor's child process can
112 * actually write to. These are *defaults* — a real env var or an agent's `env`
113 * entry still wins (see AcpClient.spawn).
114 */
115 export function defaultModelCacheEnv(): Record<string, string> {
116 const hfHome = join(homedir(), ".cache", "huggingface");
117 return {
118 HF_HOME: hfHome,
119 HF_HUB_CACHE: join(hfHome, "hub")
120 };
121 }
122
123 function isExecutableFile(p: string): boolean {
124 try {
125 if (!statSync(p).isFile()) {
126 return false;
127 }
128 if (process.platform === "win32") {
129 return true;
130 }
131 accessSync(p, constants.X_OK);
132 return true;
133 } catch {
134 return false;
135 }
136 }
137
138 /** On Windows, the suffixes that make a bare command name executable. */
139 function windowsExts(): string[] {
140 const pathext = process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD";
141 return pathext.split(";").filter(Boolean);
142 }
143
144 /**
145 * Resolve `command` to an absolute executable path, searching `pathString`
146 * (defaults to the augmented PATH). Returns undefined when nothing matches.
147 *
148 * An absolute or path-qualified command is returned as-is when it points at an
149 * executable file, so explicit user configuration always wins.
150 */
151 export function resolveExecutable(command: string, pathString = augmentedPath()): string | undefined {
152 if (!command) {
153 return undefined;
154 }
155
156 const hasPathSep = command.includes("/") || command.includes("\\");
157 if (isAbsolute(command) || hasPathSep) {
158 if (isExecutableFile(command)) {
159 return command;
160 }
161 if (process.platform === "win32") {
162 for (const ext of windowsExts()) {
163 const candidate = command + ext;
164 if (isExecutableFile(candidate)) {
165 return candidate;
166 }
167 }
168 }
169 return undefined;
170 }
171
172 for (const dir of pathString.split(delimiter)) {
173 if (!dir) {
174 continue;
175 }
176 const base = join(dir, command);
177 if (isExecutableFile(base)) {
178 return base;
179 }
180 if (process.platform === "win32") {
181 for (const ext of windowsExts()) {
182 const candidate = base + ext;
183 if (isExecutableFile(candidate)) {
184 return candidate;
185 }
186 }
187 }
188 }
189 return undefined;
190 }