@sayknow-cli/utils 0.7.11 → 0.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/broken-pipe.d.ts +4 -3
- package/dist/types/canonical-log-dir.d.ts +20 -0
- package/dist/types/cli-metadata.d.ts +33 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/crash-fingerprint.d.ts +82 -0
- package/dist/types/crash-journal.d.ts +84 -0
- package/dist/types/crash-redaction.d.ts +20 -0
- package/dist/types/dirs.d.ts +127 -36
- package/dist/types/env-file.d.ts +59 -0
- package/dist/types/env.d.ts +19 -1
- package/dist/types/error-classification.d.ts +12 -0
- package/dist/types/fetch-retry.d.ts +6 -3
- package/dist/types/header-value.d.ts +10 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/logger.d.ts +22 -8
- package/dist/types/native-process.d.ts +12 -0
- package/dist/types/procmgr.d.ts +1 -35
- package/dist/types/safe-error.d.ts +20 -0
- package/dist/types/sanitize-text.d.ts +10 -0
- package/dist/types/shell-config.d.ts +58 -0
- package/dist/types/stream.d.ts +2 -1
- package/dist/types/which.d.ts +1 -1
- package/package.json +2 -2
- package/src/broken-pipe.ts +16 -4
- package/src/canonical-log-dir.ts +59 -0
- package/src/cli-metadata.ts +74 -0
- package/src/cli.ts +30 -2
- package/src/crash-fingerprint.ts +294 -0
- package/src/crash-journal.ts +292 -0
- package/src/crash-redaction.ts +558 -0
- package/src/dirs.ts +583 -165
- package/src/env-file.ts +326 -20
- package/src/env.ts +321 -35
- package/src/error-classification.ts +31 -0
- package/src/fetch-retry.ts +23 -10
- package/src/frontmatter.ts +28 -2
- package/src/glob.ts +13 -10
- package/src/header-value.ts +15 -0
- package/src/index.ts +6 -0
- package/src/logger.ts +252 -97
- package/src/native-process.ts +19 -0
- package/src/postmortem.ts +1 -55
- package/src/procmgr.ts +16 -212
- package/src/ptree.ts +3 -2
- package/src/safe-error.ts +40 -0
- package/src/sanitize-text.ts +14 -0
- package/src/shell-config.ts +329 -0
- package/src/stream.ts +32 -1
- package/src/which.ts +6 -5
package/src/procmgr.ts
CHANGED
|
@@ -1,216 +1,18 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import { Process, ProcessStatus } from "@sayknow-cli/natives";
|
|
4
1
|
import type { Subprocess } from "bun";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* macOS leaves `MallocStackLogging=0` (or similar) inherited by debug-attached
|
|
20
|
-
* shells. Bun's libc init then prints `MallocStackLogging: can't turn off
|
|
21
|
-
* malloc stack logging because it was not enabled.` to stderr for every
|
|
22
|
-
* subprocess. Scrubbing once at startup means every child we spawn — bash,
|
|
23
|
-
* bun subagents, plugin installs, ptree commands — inherits a clean env.
|
|
24
|
-
*/
|
|
25
|
-
export function scrubProcessEnv(): void {
|
|
26
|
-
delete process.env.MallocStackLogging;
|
|
27
|
-
delete process.env.MallocStackLoggingNoCompact;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Check if a shell binary is executable.
|
|
32
|
-
*/
|
|
33
|
-
function isExecutable(path: string): boolean {
|
|
34
|
-
try {
|
|
35
|
-
fs.accessSync(path, fs.constants.X_OK);
|
|
36
|
-
return true;
|
|
37
|
-
} catch {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Build the spawn environment (cached).
|
|
44
|
-
*
|
|
45
|
-
* `CI=true` is injected unless the documented `SKC_BASH_NO_CI` (or its legacy
|
|
46
|
-
* `PI_BASH_NO_CI` / `CLAUDE_BASH_NO_CI` aliases) is set to a canonical truthy
|
|
47
|
-
* flag value.
|
|
48
|
-
*/
|
|
49
|
-
function buildSpawnEnv(shell: string): Record<string, string> {
|
|
50
|
-
const noCI = $pickflag("SKC_BASH_NO_CI", "PI_BASH_NO_CI", "CLAUDE_BASH_NO_CI");
|
|
51
|
-
const inherited = filterProcessEnv(Bun.env);
|
|
52
|
-
delete inherited.SKC_SESSION_FILE;
|
|
53
|
-
delete inherited.SKC_MANAGED_OWNER_TRANSCRIPT_PATH;
|
|
54
|
-
return {
|
|
55
|
-
...inherited,
|
|
56
|
-
SHELL: shell,
|
|
57
|
-
GIT_EDITOR: "true",
|
|
58
|
-
GPG_TTY: "not a tty",
|
|
59
|
-
SKCCODE: "1",
|
|
60
|
-
CLAUDECODE: "1",
|
|
61
|
-
...(noCI ? {} : { CI: "true" }),
|
|
62
|
-
} as Record<string, string>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Get shell args, optionally including login shell flag.
|
|
67
|
-
*
|
|
68
|
-
* Honors the documented `SKC_BASH_NO_LOGIN` first, with `PI_BASH_NO_LOGIN` and
|
|
69
|
-
* `CLAUDE_BASH_NO_LOGIN` as legacy aliases. Boolean-like values follow the
|
|
70
|
-
* canonical flag contract (`1`/`Y`/`TRUE`/`YES`/`ON`, case-insensitive), so an
|
|
71
|
-
* explicit `SKC_BASH_NO_LOGIN=0` keeps the login shell even when a legacy alias
|
|
72
|
-
* is set to a truthy value.
|
|
73
|
-
*/
|
|
74
|
-
function getShellArgs(): string[] {
|
|
75
|
-
const noLogin = $pickflag("SKC_BASH_NO_LOGIN", "PI_BASH_NO_LOGIN", "CLAUDE_BASH_NO_LOGIN");
|
|
76
|
-
return noLogin ? ["-c"] : ["-l", "-c"];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Get shell prefix for wrapping commands (profilers, strace, etc.).
|
|
81
|
-
*
|
|
82
|
-
* Resolved from trusted sources only. The prefix is interpolated ahead of every
|
|
83
|
-
* bash command (`${prefix} ${command}`) and executed through the shell, so it is
|
|
84
|
-
* an arbitrary-command-execution surface. `$env` merges the caller's
|
|
85
|
-
* `cwd/.env`, which means repository content could otherwise set it; resolution
|
|
86
|
-
* therefore goes through the non-project resolver (launching shell plus
|
|
87
|
-
* SKC/user-owned `.env` files), matching how provider credentials are resolved.
|
|
88
|
-
*/
|
|
89
|
-
function getShellPrefix(): string | undefined {
|
|
90
|
-
return $pickCredentialEnv("PI_SHELL_PREFIX", "CLAUDE_CODE_SHELL_PREFIX");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Build full shell config from a shell path.
|
|
95
|
-
*/
|
|
96
|
-
function buildConfig(shell: string): ShellConfig {
|
|
97
|
-
return {
|
|
98
|
-
shell,
|
|
99
|
-
args: getShellArgs(),
|
|
100
|
-
env: buildSpawnEnv(shell),
|
|
101
|
-
prefix: getShellPrefix(),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Resolve a basic shell (bash or sh) as fallback.
|
|
107
|
-
*/
|
|
108
|
-
export function resolveBasicShell(): string | undefined {
|
|
109
|
-
for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
|
|
110
|
-
const resolved = $which(name);
|
|
111
|
-
if (resolved) return resolved;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (process.platform !== "win32") {
|
|
115
|
-
const searchPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
|
|
116
|
-
const candidates = ["bash", "sh"];
|
|
117
|
-
|
|
118
|
-
for (const name of candidates) {
|
|
119
|
-
for (const dir of searchPaths) {
|
|
120
|
-
const fullPath = path.join(dir, name);
|
|
121
|
-
if (fs.existsSync(fullPath)) return fullPath;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return undefined;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Get shell configuration based on platform.
|
|
131
|
-
* Resolution order:
|
|
132
|
-
* 1. User-specified shellPath in settings.json
|
|
133
|
-
* 2. On Windows: Git Bash in known locations, then bash on PATH
|
|
134
|
-
* 3. On Unix: $SHELL if bash/zsh, then fallback paths
|
|
135
|
-
* 4. Fallback: sh
|
|
136
|
-
*/
|
|
137
|
-
export function getShellConfig(customShellPath?: string): ShellConfig {
|
|
138
|
-
if (cachedShellConfig) {
|
|
139
|
-
return cachedShellConfig;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 1. Check user-specified shell path
|
|
143
|
-
if (customShellPath) {
|
|
144
|
-
if (fs.existsSync(customShellPath)) {
|
|
145
|
-
cachedShellConfig = buildConfig(customShellPath);
|
|
146
|
-
return cachedShellConfig;
|
|
147
|
-
}
|
|
148
|
-
throw new Error(
|
|
149
|
-
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.skc/agent/settings.json`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (process.platform === "win32") {
|
|
154
|
-
// 2. Try Git Bash in known locations
|
|
155
|
-
const paths: string[] = [];
|
|
156
|
-
const programFiles = Bun.env.ProgramFiles;
|
|
157
|
-
if (programFiles) {
|
|
158
|
-
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
|
159
|
-
}
|
|
160
|
-
const programFilesX86 = Bun.env["ProgramFiles(x86)"];
|
|
161
|
-
if (programFilesX86) {
|
|
162
|
-
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const path of paths) {
|
|
166
|
-
if (fs.existsSync(path)) {
|
|
167
|
-
cachedShellConfig = buildConfig(path);
|
|
168
|
-
return cachedShellConfig;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
|
173
|
-
const bashOnPath = $which("bash.exe");
|
|
174
|
-
if (bashOnPath) {
|
|
175
|
-
cachedShellConfig = buildConfig(bashOnPath);
|
|
176
|
-
return cachedShellConfig;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
throw new Error(
|
|
180
|
-
`No bash shell found. Options:\n` +
|
|
181
|
-
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
|
182
|
-
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
|
183
|
-
` 3. Set shellPath in ~/.skc/agent/settings.json\n\n` +
|
|
184
|
-
`Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
|
|
189
|
-
const userShell = Bun.env.SHELL;
|
|
190
|
-
const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
|
|
191
|
-
if (isValidShell && isExecutable(userShell)) {
|
|
192
|
-
cachedShellConfig = buildConfig(userShell);
|
|
193
|
-
return cachedShellConfig;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// 4. Fallback: use basic shell
|
|
197
|
-
const basicShell = resolveBasicShell();
|
|
198
|
-
if (basicShell) {
|
|
199
|
-
cachedShellConfig = buildConfig(basicShell);
|
|
200
|
-
return cachedShellConfig;
|
|
201
|
-
}
|
|
202
|
-
cachedShellConfig = buildConfig("sh");
|
|
203
|
-
return cachedShellConfig;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Clear the memoized shell configuration so the next {@link getShellConfig}
|
|
208
|
-
* call re-resolves the shell and re-reads the environment (shell selection and
|
|
209
|
-
* the bash CI/login flags). Primarily for tests that vary those inputs.
|
|
210
|
-
*/
|
|
211
|
-
export function resetShellConfigCache(): void {
|
|
212
|
-
cachedShellConfig = null;
|
|
213
|
-
}
|
|
2
|
+
import { nativeProcessBindings } from "./native-process";
|
|
3
|
+
|
|
4
|
+
// Shell configuration lives in the natives-free ./shell-config module so
|
|
5
|
+
// consumers that only need shell resolution (e.g. Settings.getShellConfig)
|
|
6
|
+
// can import it without materializing @sayknow-cli/natives (W5b S1/idle
|
|
7
|
+
// module-trace gate). Re-exported here for compatibility with existing
|
|
8
|
+
// procmgr consumers, which already depend on natives for process control.
|
|
9
|
+
export {
|
|
10
|
+
getShellConfig,
|
|
11
|
+
resetShellConfigCache,
|
|
12
|
+
resolveBasicShell,
|
|
13
|
+
type ShellConfig,
|
|
14
|
+
scrubProcessEnv,
|
|
15
|
+
} from "./shell-config";
|
|
214
16
|
|
|
215
17
|
/**
|
|
216
18
|
* Check if a process is running.
|
|
@@ -222,6 +24,7 @@ export function isPidRunning(pid: number | Subprocess): boolean {
|
|
|
222
24
|
return true;
|
|
223
25
|
}
|
|
224
26
|
|
|
27
|
+
const { Process, ProcessStatus } = nativeProcessBindings();
|
|
225
28
|
return Process.fromPid(pid)?.status() === ProcessStatus.Running;
|
|
226
29
|
}
|
|
227
30
|
|
|
@@ -233,5 +36,6 @@ export async function onProcessExit(proc: Subprocess | number, abortSignal?: Abo
|
|
|
233
36
|
);
|
|
234
37
|
}
|
|
235
38
|
|
|
39
|
+
const { Process } = nativeProcessBindings();
|
|
236
40
|
return (await Process.fromPid(proc)?.waitForExit({ signal: abortSignal })) ?? true;
|
|
237
41
|
}
|
package/src/ptree.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* - Convenience helpers: captureText / execText, AbortSignal, timeouts.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { Process } from "@sayknow-cli/natives";
|
|
11
10
|
import type { Spawn, Subprocess } from "bun";
|
|
11
|
+
import { nativeProcessBindings } from "./native-process";
|
|
12
12
|
|
|
13
13
|
type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
|
|
14
14
|
|
|
@@ -217,7 +217,8 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
217
217
|
kill(reason?: Exception) {
|
|
218
218
|
if (reason && !this.#exitReasonPending) this.#exitReasonPending = reason;
|
|
219
219
|
if (!this.proc.killed)
|
|
220
|
-
void
|
|
220
|
+
void nativeProcessBindings()
|
|
221
|
+
.Process.fromPid(this.proc.pid)
|
|
221
222
|
?.terminate()
|
|
222
223
|
?.catch(e => void e);
|
|
223
224
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throw-proof error description.
|
|
3
|
+
*
|
|
4
|
+
* Hostile or exotic thrown values can make `String(value)` throw (a `toString`
|
|
5
|
+
* that throws, a revoked proxy, a null-prototype object) and a `message` getter
|
|
6
|
+
* can throw too. Logging or recording a failure must never become a second
|
|
7
|
+
* failure, so every failure path that stringifies a caught value uses this.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { safeErrorDescription } from "@sayknow-cli/utils";
|
|
12
|
+
*
|
|
13
|
+
* try {
|
|
14
|
+
* await risky();
|
|
15
|
+
* } catch (error) {
|
|
16
|
+
* logger.warn("risky failed", { error: safeErrorDescription(error) });
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function safeErrorDescription(value: unknown): string {
|
|
21
|
+
let isError = false;
|
|
22
|
+
try {
|
|
23
|
+
isError = value instanceof Error;
|
|
24
|
+
} catch {
|
|
25
|
+
// Hostile proxies can throw from getPrototypeOf during instanceof.
|
|
26
|
+
}
|
|
27
|
+
if (isError) {
|
|
28
|
+
try {
|
|
29
|
+
const message = (value as { message?: unknown }).message;
|
|
30
|
+
if (typeof message === "string") return message;
|
|
31
|
+
} catch {
|
|
32
|
+
// Hostile error getters must not replace the primary failure.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return String(value);
|
|
37
|
+
} catch {
|
|
38
|
+
return "<unprintable error>";
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/sanitize-text.ts
CHANGED
|
@@ -17,6 +17,7 @@ const ESC_CHAR = "\x1b";
|
|
|
17
17
|
// Well-formed strings only need control/ANSI detection: C0 (excl. \t \n),
|
|
18
18
|
// CR, DEL, and C1. ESC (0x1B) is in \x0B-\x1F.
|
|
19
19
|
const CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
|
|
20
|
+
const DISPLAY_FORMAT_RE = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
|
|
20
21
|
|
|
21
22
|
const REPLACEMENT_CHAR = "\ufffd";
|
|
22
23
|
|
|
@@ -28,6 +29,19 @@ export function sanitizeText(text: string): string {
|
|
|
28
29
|
return sanitizeWellFormedText(text);
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Sanitize untrusted text that must occupy exactly one rendered row.
|
|
34
|
+
*
|
|
35
|
+
* {@link sanitizeText} deliberately preserves `\n`, and width-based truncation
|
|
36
|
+
* treats it as zero-width, so a value carrying line breaks can still inject
|
|
37
|
+
* extra rows and evade a single-line width budget. Flatten every CR/LF run to a
|
|
38
|
+
* single space before the usual control/ANSI strip, then remove directional
|
|
39
|
+
* format controls that can visually reorder an otherwise safe row.
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizeDisplayLine(text: string): string {
|
|
42
|
+
return sanitizeText(text.replace(/[\r\n]+/gu, " ")).replace(DISPLAY_FORMAT_RE, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
function sanitizeWellFormedText(text: string): string {
|
|
32
46
|
CONTROL_RE.lastIndex = 0;
|
|
33
47
|
if (CONTROL_RE.exec(text) === null) return text;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { canonicalEnvKey, getTrustedHomeDir } from "./dirs";
|
|
4
|
+
import {
|
|
5
|
+
$credentialEnv,
|
|
6
|
+
$inheritedEnv,
|
|
7
|
+
$pickCredentialEnv,
|
|
8
|
+
$pickflag,
|
|
9
|
+
filterProcessEnv,
|
|
10
|
+
startupProjectEnvSnapshot,
|
|
11
|
+
} from "./env";
|
|
12
|
+
import { $which } from "./which";
|
|
13
|
+
|
|
14
|
+
export interface ShellConfig {
|
|
15
|
+
shell: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
env: Record<string, string>;
|
|
18
|
+
prefix: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
let cachedShellConfig: ShellConfig | null = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Strip disabled macOS malloc-stack-logging vars from `process.env` in place.
|
|
24
|
+
*
|
|
25
|
+
* macOS leaves `MallocStackLogging=0` (or similar) inherited by debug-attached
|
|
26
|
+
* shells. Bun's libc init then prints `MallocStackLogging: can't turn off
|
|
27
|
+
* malloc stack logging because it was not enabled.` to stderr for every
|
|
28
|
+
* subprocess. Scrubbing once at startup means every child we spawn — bash,
|
|
29
|
+
* bun subagents, plugin installs, ptree commands — inherits a clean env.
|
|
30
|
+
*/
|
|
31
|
+
export function scrubProcessEnv(): void {
|
|
32
|
+
delete process.env.MallocStackLogging;
|
|
33
|
+
delete process.env.MallocStackLoggingNoCompact;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a shell binary is executable.
|
|
38
|
+
*/
|
|
39
|
+
function isExecutable(path: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
fs.accessSync(path, fs.constants.X_OK);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Variables that make the shell, Bun, or git run code on their own. Bun's
|
|
50
|
+
* `BUN_OPTIONS` can preload code before a source-mode worker entrypoint; bash
|
|
51
|
+
* sources `BASH_ENV` (sh: `ENV`) before every non-interactive command, and zsh
|
|
52
|
+
* sources `$ZDOTDIR/.zshenv`. git applies `GIT_CONFIG_*`, reads config from
|
|
53
|
+
* `GIT_DIR`/`GIT_COMMON_DIR`, `$XDG_CONFIG_HOME/git/config` and `$HOME/.gitconfig`;
|
|
54
|
+
* on Windows it can also derive its home from `USERPROFILE`, `HOMEDRIVE` and
|
|
55
|
+
* `HOMEPATH`. It runs e.g. `core.fsmonitor` during `git status` and helpers from
|
|
56
|
+
* `GIT_EXEC_PATH`, `GIT_PROXY_COMMAND` for `git://` remotes,
|
|
57
|
+
* `GIT_EXTERNAL_DIFF`, `GIT_SSH(_COMMAND)`, `GIT_ASKPASS` (auth prompts, even
|
|
58
|
+
* with `GIT_TERMINAL_PROMPT=0`) and `GIT_SEQUENCE_EDITOR` (`git rebase -i`).
|
|
59
|
+
* `GIT_TEMPLATE_DIR` can also seed executable hooks when `git init` creates a
|
|
60
|
+
* repository. Interpreters the shell starts also load code named by the
|
|
61
|
+
* environment: Node `NODE_OPTIONS` (`--require`), Python `PYTHONPATH`
|
|
62
|
+
* (`sitecustomize`), `PYTHONUSERBASE` (`usercustomize`), and `PYTHONSTARTUP`
|
|
63
|
+
* (interactive startup), Perl `PERL5OPT`/`PERL5LIB` (`-M`), the JVM
|
|
64
|
+
* `JAVA_TOOL_OPTIONS`/`JDK_JAVA_OPTIONS` (`-javaagent`). The dynamic loader
|
|
65
|
+
* runs code for every dynamically linked command from `LD_PRELOAD` and `LD_AUDIT` modules, and
|
|
66
|
+
* resolves libraries from `LD_LIBRARY_PATH` first, so a project directory there
|
|
67
|
+
* can shadow a system library (macOS: `DYLD_INSERT_LIBRARIES` and the
|
|
68
|
+
* `DYLD_*_LIBRARY_PATH`/`DYLD_*_FRAMEWORK_PATH` search overrides). Sibling
|
|
69
|
+
* spellings load the same code: Perl `PERLLIB`, the JVM `_JAVA_OPTIONS`, and npm
|
|
70
|
+
* `npm_config_node_options` (matched separately below, in any case). Common tools also read programs from the
|
|
71
|
+
* environment: ripgrep `RIPGREP_CONFIG_PATH` (a config file that can add
|
|
72
|
+
* `--pre=<program>`), GNU tar `TAR_OPTIONS` (`--use-compress-program`,
|
|
73
|
+
* `--to-command`), rsync `RSYNC_RSH`, make `MAKEFLAGS` (`SHELL=`) and
|
|
74
|
+
* `MAKEFILES` (a makefile with `$(shell …)`), and bash `SHELLOPTS`, which turns on
|
|
75
|
+
* `xtrace` so `PS4` command substitutions run in every `bash -c`. A repository
|
|
76
|
+
* `.env` must not be able to set them, or bash tool commands could execute
|
|
77
|
+
* repository code.
|
|
78
|
+
*/
|
|
79
|
+
const PROJECT_DENIED_SPAWN_ENV =
|
|
80
|
+
/^(?:BASH_ENV|BUN_OPTIONS|ENV|ZDOTDIR|HOME|USERPROFILE|HOMEDRIVE|HOMEPATH|XDG_CONFIG_HOME|GIT_DIR|GIT_COMMON_DIR|GIT_EXEC_PATH|GIT_EXTERNAL_DIFF|GIT_PROXY_COMMAND|GIT_SSH|GIT_SSH_COMMAND|GIT_ASKPASS|GIT_SEQUENCE_EDITOR|GIT_TEMPLATE_DIR|NODE_OPTIONS|PYTHONPATH|PYTHONUSERBASE|PYTHONSTARTUP|PERL5OPT|PERL5LIB|JAVA_TOOL_OPTIONS|JDK_JAVA_OPTIONS|_JAVA_OPTIONS|PERLLIB|RIPGREP_CONFIG_PATH|TAR_OPTIONS|RSYNC_RSH|MAKEFLAGS|MAKEFILES|SHELLOPTS|PS4|LD_PRELOAD|LD_AUDIT|LD_LIBRARY_PATH|DYLD_(?:INSERT_LIBRARIES|LIBRARY_PATH|FALLBACK_LIBRARY_PATH|FRAMEWORK_PATH|FALLBACK_FRAMEWORK_PATH|VERSIONED_LIBRARY_PATH|VERSIONED_FRAMEWORK_PATH)|GIT_CONFIG_(?:COUNT|PARAMETERS|GLOBAL|SYSTEM|KEY_\d+|VALUE_\d+))$/;
|
|
81
|
+
/**
|
|
82
|
+
* npm reads `npm_config_<key>` with the prefix in any case and normalizes the key
|
|
83
|
+
* (case and `_`/`-`), so `npm_config_Node_Options` and `npm_config_node-options`
|
|
84
|
+
* both set `node-options`, which npm hands to the Node processes it starts as
|
|
85
|
+
* `NODE_OPTIONS`, and `script-shell` picks the program every lifecycle script
|
|
86
|
+
* (`npm run`, `npm test`, install hooks) runs through.
|
|
87
|
+
*/
|
|
88
|
+
const PROJECT_DENIED_NPM_SPAWN_ENV = /^npm_config_(?:node[_-]options|script[_-]shell)$/i;
|
|
89
|
+
const PROJECT_HOME_SPAWN_ENV = new Set(["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Names of hook-capable variables whose live value came from the project `.env`.
|
|
93
|
+
*
|
|
94
|
+
* Provenance comes from `$inheritedEnv`, the same launch snapshot that guards
|
|
95
|
+
* credentials: a value the launching shell exported with a different value than
|
|
96
|
+
* the project declaration is kept. Bun does not reveal which source supplied a
|
|
97
|
+
* value, so an inherited value the project repeats verbatim, or a key the project
|
|
98
|
+
* declares dynamically, is ambiguous and fails closed (dropped).
|
|
99
|
+
*/
|
|
100
|
+
export function projectDeclaredSpawnHookNames(env: Record<string, string | undefined> = Bun.env): string[] {
|
|
101
|
+
const snapshot = startupProjectEnvSnapshot();
|
|
102
|
+
const names: string[] = [];
|
|
103
|
+
for (const name of Object.keys(env)) {
|
|
104
|
+
const key = canonicalEnvKey(name);
|
|
105
|
+
const dynamic = snapshot.dynamic.has(key);
|
|
106
|
+
const denied = PROJECT_DENIED_SPAWN_ENV.test(key) || PROJECT_DENIED_NPM_SPAWN_ENV.test(key);
|
|
107
|
+
if (!denied || (snapshot.values[key] === undefined && !dynamic)) continue;
|
|
108
|
+
if (env[name] === undefined || (!dynamic && $inheritedEnv(name) !== undefined)) continue;
|
|
109
|
+
names.push(name);
|
|
110
|
+
}
|
|
111
|
+
return names;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The account home SKC trusts, or undefined when none is trustworthy. */
|
|
115
|
+
function trustedHome(): string | undefined {
|
|
116
|
+
try {
|
|
117
|
+
return getTrustedHomeDir();
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Remove project-declared hook variables from a spawn environment in place.
|
|
125
|
+
* If removing a home selector leaves `HOME` unset, provide the trusted account
|
|
126
|
+
* home when one exists rather than allowing a Windows profile fallback.
|
|
127
|
+
*/
|
|
128
|
+
export function stripProjectSpawnHooks(env: Record<string, string>): Record<string, string> {
|
|
129
|
+
const removed = projectDeclaredSpawnHookNames(env);
|
|
130
|
+
for (const name of removed) delete env[name];
|
|
131
|
+
const removedHomeSelector = removed.some(name => PROJECT_HOME_SPAWN_ENV.has(canonicalEnvKey(name)));
|
|
132
|
+
if ((env.HOME === undefined || env.HOME === "") && (process.platform !== "win32" || removedHomeSelector)) {
|
|
133
|
+
const home = trustedHome();
|
|
134
|
+
if (home !== undefined) env.HOME = home;
|
|
135
|
+
else delete env.HOME;
|
|
136
|
+
}
|
|
137
|
+
return env;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The process environment without project-declared hook variables, for spawning
|
|
142
|
+
* the bash tool's shell processes (isolated worker chain and PTY sessions).
|
|
143
|
+
*/
|
|
144
|
+
export function projectSafeSpawnEnv(): Record<string, string> {
|
|
145
|
+
return stripProjectSpawnHooks(filterProcessEnv(Bun.env));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Provide the trusted HOME override needed when PTY hook filtering removes a home selector. */
|
|
149
|
+
export function projectSafeHomeOverride(explicit?: Record<string, string>): Record<string, string> {
|
|
150
|
+
if (Object.hasOwn(explicit ?? {}, "HOME")) return {};
|
|
151
|
+
const removesHomeSelector = projectDeclaredSpawnHookNames().some(
|
|
152
|
+
name => !Object.hasOwn(explicit ?? {}, name) && PROJECT_HOME_SPAWN_ENV.has(canonicalEnvKey(name)),
|
|
153
|
+
);
|
|
154
|
+
if (!removesHomeSelector) return {};
|
|
155
|
+
const home = projectSafeSpawnEnv().HOME;
|
|
156
|
+
return home === undefined ? {} : { HOME: home };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build the spawn environment (cached).
|
|
161
|
+
*
|
|
162
|
+
* The spawn environment is the inherited process environment plus the fixed
|
|
163
|
+
* hardening entries below. `CI` is not injected: a process that did not export
|
|
164
|
+
* it must not look like CI to the tools it runs.
|
|
165
|
+
*/
|
|
166
|
+
function buildSpawnEnv(shell: string): Record<string, string> {
|
|
167
|
+
const inherited = filterProcessEnv(Bun.env);
|
|
168
|
+
delete inherited.SKC_SESSION_FILE;
|
|
169
|
+
delete inherited.SKC_MANAGED_OWNER_TRANSCRIPT_PATH;
|
|
170
|
+
return {
|
|
171
|
+
...inherited,
|
|
172
|
+
SHELL: shell,
|
|
173
|
+
GIT_EDITOR: "true",
|
|
174
|
+
GPG_TTY: "not a tty",
|
|
175
|
+
SKCCODE: "1",
|
|
176
|
+
CLAUDECODE: "1",
|
|
177
|
+
} as Record<string, string>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get shell args, optionally including login shell flag.
|
|
182
|
+
*
|
|
183
|
+
* Honors the documented `SKC_BASH_NO_LOGIN` first, with `PI_BASH_NO_LOGIN` and
|
|
184
|
+
* `CLAUDE_BASH_NO_LOGIN` as legacy aliases. Boolean-like values follow the
|
|
185
|
+
* canonical flag contract (`1`/`Y`/`TRUE`/`YES`/`ON`, case-insensitive), so an
|
|
186
|
+
* explicit `SKC_BASH_NO_LOGIN=0` keeps the login shell even when a legacy alias
|
|
187
|
+
* is set to a truthy value.
|
|
188
|
+
*/
|
|
189
|
+
function getShellArgs(): string[] {
|
|
190
|
+
const noLogin = $pickflag("SKC_BASH_NO_LOGIN", "PI_BASH_NO_LOGIN", "CLAUDE_BASH_NO_LOGIN");
|
|
191
|
+
return noLogin ? ["-c"] : ["-l", "-c"];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get shell prefix for wrapping commands (profilers, strace, etc.).
|
|
196
|
+
*
|
|
197
|
+
* Resolved from trusted sources only. The prefix is interpolated ahead of every
|
|
198
|
+
* bash command (`${prefix} ${command}`) and executed through the shell, so it is
|
|
199
|
+
* an arbitrary-command-execution surface. `$env` merges the caller's
|
|
200
|
+
* `cwd/.env`, which means repository content could otherwise set it; resolution
|
|
201
|
+
* therefore goes through the non-project resolver (launching shell plus
|
|
202
|
+
* SKC/user-owned `.env` files), matching how provider credentials are resolved.
|
|
203
|
+
*/
|
|
204
|
+
function getShellPrefix(): string | undefined {
|
|
205
|
+
return $pickCredentialEnv("PI_SHELL_PREFIX", "CLAUDE_CODE_SHELL_PREFIX");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Build full shell config from a shell path.
|
|
210
|
+
*/
|
|
211
|
+
function buildConfig(shell: string): ShellConfig {
|
|
212
|
+
return {
|
|
213
|
+
shell,
|
|
214
|
+
args: getShellArgs(),
|
|
215
|
+
env: buildSpawnEnv(shell),
|
|
216
|
+
prefix: getShellPrefix(),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a basic shell (bash or sh) as fallback.
|
|
222
|
+
*/
|
|
223
|
+
export function resolveBasicShell(): string | undefined {
|
|
224
|
+
for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
|
|
225
|
+
const resolved = $which(name);
|
|
226
|
+
if (resolved) return resolved;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (process.platform !== "win32") {
|
|
230
|
+
const searchPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
|
|
231
|
+
const candidates = ["bash", "sh"];
|
|
232
|
+
|
|
233
|
+
for (const name of candidates) {
|
|
234
|
+
for (const dir of searchPaths) {
|
|
235
|
+
const fullPath = path.join(dir, name);
|
|
236
|
+
if (fs.existsSync(fullPath)) return fullPath;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get shell configuration based on platform.
|
|
246
|
+
* Resolution order:
|
|
247
|
+
* 1. User-specified shellPath in settings.json
|
|
248
|
+
* 2. On Windows: Git Bash in known locations, then bash on PATH
|
|
249
|
+
* 3. On Unix: $SHELL if bash/zsh, then fallback paths
|
|
250
|
+
* 4. Fallback: sh
|
|
251
|
+
*/
|
|
252
|
+
export function getShellConfig(customShellPath?: string): ShellConfig {
|
|
253
|
+
if (cachedShellConfig) {
|
|
254
|
+
return cachedShellConfig;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 1. Check user-specified shell path
|
|
258
|
+
if (customShellPath) {
|
|
259
|
+
if (fs.existsSync(customShellPath)) {
|
|
260
|
+
cachedShellConfig = buildConfig(customShellPath);
|
|
261
|
+
return cachedShellConfig;
|
|
262
|
+
}
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.skc/agent/settings.json`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (process.platform === "win32") {
|
|
269
|
+
// 2. Try Git Bash in known locations
|
|
270
|
+
const paths: string[] = [];
|
|
271
|
+
const programFiles = Bun.env.ProgramFiles;
|
|
272
|
+
if (programFiles) {
|
|
273
|
+
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
|
274
|
+
}
|
|
275
|
+
const programFilesX86 = Bun.env["ProgramFiles(x86)"];
|
|
276
|
+
if (programFilesX86) {
|
|
277
|
+
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const path of paths) {
|
|
281
|
+
if (fs.existsSync(path)) {
|
|
282
|
+
cachedShellConfig = buildConfig(path);
|
|
283
|
+
return cachedShellConfig;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
|
288
|
+
const bashOnPath = $which("bash.exe");
|
|
289
|
+
if (bashOnPath) {
|
|
290
|
+
cachedShellConfig = buildConfig(bashOnPath);
|
|
291
|
+
return cachedShellConfig;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
throw new Error(
|
|
295
|
+
`No bash shell found. Options:\n` +
|
|
296
|
+
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
|
297
|
+
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
|
298
|
+
` 3. Set shellPath in ~/.skc/agent/settings.json\n\n` +
|
|
299
|
+
`Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
|
|
304
|
+
// Bun fills an unset SHELL from cwd/.env, so read only the launching shell's value.
|
|
305
|
+
const userShell = $credentialEnv("SHELL");
|
|
306
|
+
const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
|
|
307
|
+
if (isValidShell && isExecutable(userShell)) {
|
|
308
|
+
cachedShellConfig = buildConfig(userShell);
|
|
309
|
+
return cachedShellConfig;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 4. Fallback: use basic shell
|
|
313
|
+
const basicShell = resolveBasicShell();
|
|
314
|
+
if (basicShell) {
|
|
315
|
+
cachedShellConfig = buildConfig(basicShell);
|
|
316
|
+
return cachedShellConfig;
|
|
317
|
+
}
|
|
318
|
+
cachedShellConfig = buildConfig("sh");
|
|
319
|
+
return cachedShellConfig;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Clear the memoized shell configuration so the next {@link getShellConfig}
|
|
324
|
+
* call re-resolves the shell and re-reads the environment (shell selection and
|
|
325
|
+
* the bash CI/login flags). Primarily for tests that vary those inputs.
|
|
326
|
+
*/
|
|
327
|
+
export function resetShellConfigCache(): void {
|
|
328
|
+
cachedShellConfig = null;
|
|
329
|
+
}
|