@gajae-code/utils 0.11.11 → 0.12.1

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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Centralized path helpers for gajae-code config directories.
3
3
  *
4
- * Uses PI_CONFIG_DIR (default ".gjc") for the config root and
5
- * PI_CODING_AGENT_DIR to override the agent directory.
4
+ * Uses GJC_CONFIG_DIR (legacy alias PI_CONFIG_DIR, default ".gjc") for the
5
+ * config root and GJC_CODING_AGENT_DIR (legacy alias PI_CODING_AGENT_DIR) to
6
+ * override the agent directory.
6
7
  *
7
8
  * On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
8
9
  * variables are set, paths are redirected to XDG-compliant locations under
@@ -44,7 +45,6 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
44
45
  export declare function getProjectDir(): string;
45
46
  /** Set the project directory. */
46
47
  export declare function setProjectDir(dir: string): void;
47
- /** Get the config directory name relative to home (e.g. ".gjc" or PI_CONFIG_DIR override). */
48
48
  export declare function getConfigDirName(): string;
49
49
  /** Get the config agent directory name relative to home (e.g. ".gjc/agent" or PI_CONFIG_DIR + "/agent"). */
50
50
  export declare function getConfigAgentDirName(): string;
@@ -135,6 +135,8 @@ export declare function getModelDbPath(agentDir?: string): string;
135
135
  export declare function getSessionsDir(agentDir?: string): string;
136
136
  /** Get the content-addressed blob store directory (~/.gjc/agent/blobs). */
137
137
  export declare function getBlobsDir(agentDir?: string): string;
138
+ /** Get the resident-text cache root for a profile agent directory. */
139
+ export declare function getResidentCacheRootDir(profileAgentDir: string): string;
138
140
  /** Get the custom themes directory (~/.gjc/agent/themes). */
139
141
  export declare function getCustomThemesDir(agentDir?: string): string;
140
142
  /** Get the tools directory (~/.gjc/agent/tools). */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Strict shell-identifier shape. Used for dotenv keys we accept into
3
+ * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
4
+ * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
5
+ */
6
+ export declare function isValidEnvName(name: string): boolean;
7
+ /**
8
+ * Parses simple POSIX shell environment assignments from files such as
9
+ * ~/.zshrc without executing user shell code. Supports `export KEY=value` and
10
+ * `KEY=value`, including single/double quoted literal values. Dynamic shell
11
+ * expressions are intentionally ignored because evaluating startup files would
12
+ * run arbitrary code during CLI startup.
13
+ */
14
+ export declare function parseShellEnvFile(filePath: string): Record<string, string>;
15
+ /**
16
+ * Parses a .env file synchronously and extracts key-value string pairs.
17
+ * Ignores lines that are empty or start with '#'. Trims whitespace.
18
+ * Allows values to be quoted with single or double quotes.
19
+ * Returns an object of key-value pairs.
20
+ */
21
+ export declare function parseEnvFile(filePath: string): Record<string, string>;
@@ -1,25 +1,5 @@
1
1
  export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
2
- /**
3
- * Strict shell-identifier shape. Used for dotenv keys we accept into
4
- * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
5
- * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
6
- */
7
- export declare function isValidEnvName(name: string): boolean;
8
- /**
9
- * Parses simple POSIX shell environment assignments from files such as
10
- * ~/.zshrc without executing user shell code. Supports `export KEY=value` and
11
- * `KEY=value`, including single/double quoted literal values. Dynamic shell
12
- * expressions are intentionally ignored because evaluating startup files would
13
- * run arbitrary code during CLI startup.
14
- */
15
- export declare function parseShellEnvFile(filePath: string): Record<string, string>;
16
- /**
17
- * Parses a .env file synchronously and extracts key-value string pairs.
18
- * Ignores lines that are empty or start with '#'. Trims whitespace.
19
- * Allows values to be quoted with single or double quotes.
20
- * Returns an object of key-value pairs.
21
- */
22
- export declare function parseEnvFile(filePath: string): Record<string, string>;
2
+ export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
23
3
  export declare function $inheritedEnv(name: string): string | undefined;
24
4
  /**
25
5
  * Intentional re-export of Bun.env.
@@ -28,6 +28,12 @@ export declare function resolveBasicShell(): string | undefined;
28
28
  * 4. Fallback: sh
29
29
  */
30
30
  export declare function getShellConfig(customShellPath?: string): ShellConfig;
31
+ /**
32
+ * Clear the memoized shell configuration so the next {@link getShellConfig}
33
+ * call re-resolves the shell and re-reads the environment (shell selection and
34
+ * the bash CI/login flags). Primarily for tests that vary those inputs.
35
+ */
36
+ export declare function resetShellConfigCache(): void;
31
37
  /**
32
38
  * Check if a process is running.
33
39
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.11.11",
4
+ "version": "0.12.1",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@gajae-code/natives": "0.11.11",
34
+ "@gajae-code/natives": "0.12.1",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/dirs.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Centralized path helpers for gajae-code config directories.
3
3
  *
4
- * Uses PI_CONFIG_DIR (default ".gjc") for the config root and
5
- * PI_CODING_AGENT_DIR to override the agent directory.
4
+ * Uses GJC_CONFIG_DIR (legacy alias PI_CONFIG_DIR, default ".gjc") for the
5
+ * config root and GJC_CODING_AGENT_DIR (legacy alias PI_CODING_AGENT_DIR) to
6
+ * override the agent directory.
6
7
  *
7
8
  * On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
8
9
  * variables are set, paths are redirected to XDG-compliant locations under
@@ -15,6 +16,7 @@ import * as fs from "node:fs";
15
16
  import * as os from "node:os";
16
17
  import * as path from "node:path";
17
18
  import { engines, version } from "../package.json" with { type: "json" };
19
+ import { parseEnvFile } from "./env-file";
18
20
 
19
21
  /** App name (e.g. "gjc") */
20
22
  export const APP_NAME: string = "gjc";
@@ -147,9 +149,57 @@ export function setProjectDir(dir: string): void {
147
149
  process.chdir(projectDir);
148
150
  }
149
151
 
152
+ /**
153
+ * Reject a configured config-directory name that would escape the home-relative
154
+ * root it is documented to stay under.
155
+ *
156
+ * The configured value names a directory beneath `<home>` — the discovery docs
157
+ * state that "even an absolute-looking configured name is joined beneath
158
+ * `<home>`", which `path.join` delivers for a leading separator but not for
159
+ * `..` segments. Consumers join this name with `<home>` (and with project
160
+ * ancestors) to locate user-level `mcp.json`, `SYSTEM.md`, skills, agents and
161
+ * installed plugins, so a `..` segment would point that discovery at a
162
+ * directory outside the config root entirely. Fall back to the default name
163
+ * instead of honoring an escaping value.
164
+ */
165
+ function sanitizeConfigDirName(value: string | undefined): string | undefined {
166
+ const trimmed = value?.trim();
167
+ if (!trimmed) return undefined;
168
+ if (path.normalize(trimmed).split(/[\\/]/).includes("..")) return undefined;
169
+ return trimmed;
170
+ }
171
+
150
172
  /** Get the config directory name relative to home (e.g. ".gjc" or PI_CONFIG_DIR override). */
173
+ /**
174
+ * Config-directory name, rejected when it comes from the caller's project `.env`.
175
+ *
176
+ * The name is joined with the home directory to build the config root, and that
177
+ * root plus the agent directory beneath it supply two of the `.env` files
178
+ * `$credentialEnv` treats as trusted. Bun loads `cwd/.env` into `process.env`
179
+ * before any module runs, so a repository could otherwise point the config root
180
+ * at a directory it ships and have its own `.env` treated as trusted —
181
+ * recovering every endpoint and credential redirect the boundary rejects.
182
+ *
183
+ * `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
184
+ * it applies the same conservative ambiguity rule directly, matching how
185
+ * `GJC_CODING_AGENT_DIR` is treated.
186
+ */
187
+ function trustedConfigDirName(name: "GJC_CONFIG_DIR" | "PI_CONFIG_DIR"): string | undefined {
188
+ const value = process.env[name];
189
+ if (!value) return undefined;
190
+ if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
191
+ return value;
192
+ }
193
+
151
194
  export function getConfigDirName(): string {
152
- return process.env.GJC_CONFIG_DIR ?? process.env.PI_CONFIG_DIR ?? CONFIG_DIR_NAME;
195
+ // Both guards apply: the value must come from a trusted source (not the
196
+ // caller's project `.env`), and it must still be a single name that stays
197
+ // beneath home once joined.
198
+ return (
199
+ sanitizeConfigDirName(trustedConfigDirName("GJC_CONFIG_DIR")) ??
200
+ sanitizeConfigDirName(trustedConfigDirName("PI_CONFIG_DIR")) ??
201
+ CONFIG_DIR_NAME
202
+ );
153
203
  }
154
204
 
155
205
  /** Get the config agent directory name relative to home (e.g. ".gjc/agent" or PI_CONFIG_DIR + "/agent"). */
@@ -248,7 +298,43 @@ class DirResolver {
248
298
  }
249
299
  }
250
300
 
251
- let dirs = new DirResolver(process.env.GJC_CODING_AGENT_DIR);
301
+ /**
302
+ * Agent-directory override, rejected when it comes from the caller's project
303
+ * `.env`.
304
+ *
305
+ * This directory selects the agent's own `.env`, which is one of the trusted
306
+ * sources `$credentialEnv` consults. Bun loads `cwd/.env` into `process.env`
307
+ * before any module runs, so a repository could otherwise point this at a
308
+ * directory it ships and have its own `.env` treated as trusted — recovering
309
+ * every redirect the credential boundary is meant to reject.
310
+ *
311
+ * `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
312
+ * it applies the same conservative ambiguity rule directly: a value that matches
313
+ * what the project `.env` sets is not honoured. An operator whose environment
314
+ * happens to carry the identical value loses the override, which is the same
315
+ * trade-off `resolveLiveCredentialEnvValue` already makes.
316
+ */
317
+ function trustedAgentDirOverrideFor(name: "GJC_CODING_AGENT_DIR" | "PI_CODING_AGENT_DIR"): string | undefined {
318
+ const value = process.env[name];
319
+ if (!value) return undefined;
320
+ if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
321
+ return value;
322
+ }
323
+
324
+ /**
325
+ * Both spellings are honoured, mirroring `getConfigDirName`.
326
+ *
327
+ * `PI_CODING_AGENT_DIR` is the legacy alias this module's own header documents,
328
+ * and parts of the product already resolve it (`gc-runtime.ts:370`,
329
+ * `deep-interview-runtime.ts:384`). Reading only the `GJC_` spelling here split
330
+ * the agent directory in two: `gjc gc` operated on the aliased directory while
331
+ * everything reaching `getAgentDir()` stayed on the default.
332
+ */
333
+ function trustedAgentDirOverride(): string | undefined {
334
+ return trustedAgentDirOverrideFor("GJC_CODING_AGENT_DIR") ?? trustedAgentDirOverrideFor("PI_CODING_AGENT_DIR");
335
+ }
336
+
337
+ let dirs = new DirResolver(trustedAgentDirOverride());
252
338
 
253
339
  // Anchor home for the resolver. Captured at module load to stay stable across
254
340
  // test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
@@ -460,6 +546,11 @@ export function getBlobsDir(agentDir?: string): string {
460
546
  return dirs.agentSubdir(agentDir, "blobs", "data");
461
547
  }
462
548
 
549
+ /** Get the resident-text cache root for a profile agent directory. */
550
+ export function getResidentCacheRootDir(profileAgentDir: string): string {
551
+ return dirs.agentSubdir(profileAgentDir, "resident-cache", "cache");
552
+ }
553
+
463
554
  /** Get the custom themes directory (~/.gjc/agent/themes). */
464
555
  export function getCustomThemesDir(agentDir?: string): string {
465
556
  return dirs.agentSubdir(agentDir, "themes");
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Environment-file parsing primitives.
3
+ *
4
+ * Kept in a leaf module so both `env.ts` and `dirs.ts` can use them. `env.ts`
5
+ * imports `dirs.ts`, so anything `dirs.ts` needs from the env layer has to live
6
+ * below both of them.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import { isSafeEnvValue } from "./spawn-env";
10
+
11
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
12
+
13
+ /**
14
+ * Strict shell-identifier shape. Used for dotenv keys we accept into
15
+ * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
16
+ * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
17
+ */
18
+ export function isValidEnvName(name: string): boolean {
19
+ return ENV_NAME_RE.test(name);
20
+ }
21
+
22
+ function stripInlineShellComment(value: string): string {
23
+ let quote: '"' | "'" | undefined;
24
+ for (let i = 0; i < value.length; i++) {
25
+ const char = value[i];
26
+ if (char === "\\") {
27
+ i++;
28
+ continue;
29
+ }
30
+ if ((char === '"' || char === "'") && (!quote || quote === char)) {
31
+ quote = quote ? undefined : char;
32
+ continue;
33
+ }
34
+ if (char === "#" && !quote && (i === 0 || /\s/.test(value[i - 1] ?? ""))) {
35
+ return value.slice(0, i).trimEnd();
36
+ }
37
+ }
38
+ return value.trimEnd();
39
+ }
40
+
41
+ /**
42
+ * Parses simple POSIX shell environment assignments from files such as
43
+ * ~/.zshrc without executing user shell code. Supports `export KEY=value` and
44
+ * `KEY=value`, including single/double quoted literal values. Dynamic shell
45
+ * expressions are intentionally ignored because evaluating startup files would
46
+ * run arbitrary code during CLI startup.
47
+ */
48
+ export function parseShellEnvFile(filePath: string): Record<string, string> {
49
+ const result: Record<string, string> = {};
50
+ try {
51
+ const content = fs.readFileSync(filePath, "utf-8");
52
+ for (const line of content.split("\n")) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed || trimmed.startsWith("#")) continue;
55
+
56
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
57
+ if (!match) continue;
58
+
59
+ const key = match[1];
60
+ if (!isValidEnvName(key)) continue;
61
+
62
+ let value = stripInlineShellComment(match[2] ?? "").trim();
63
+ if (value.endsWith(";")) value = value.slice(0, -1).trimEnd();
64
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
65
+ value = value.slice(1, -1);
66
+ }
67
+ if (!isSafeEnvValue(value)) continue;
68
+ if (/[$`]/.test(value)) continue;
69
+
70
+ result[key] = value;
71
+ }
72
+ } catch {
73
+ // File doesn't exist or can't be read - return empty result
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Parses a .env file synchronously and extracts key-value string pairs.
81
+ * Ignores lines that are empty or start with '#'. Trims whitespace.
82
+ * Allows values to be quoted with single or double quotes.
83
+ * Returns an object of key-value pairs.
84
+ */
85
+ export function parseEnvFile(filePath: string): Record<string, string> {
86
+ const result: Record<string, string> = {};
87
+ try {
88
+ const content = fs.readFileSync(filePath, "utf-8");
89
+ for (const line of content.split("\n")) {
90
+ const trimmed = line.trim();
91
+ // Skip comments and blank lines
92
+ if (!trimmed || trimmed.startsWith("#")) continue;
93
+
94
+ const eqIndex = trimmed.indexOf("=");
95
+ if (eqIndex === -1) continue;
96
+
97
+ const key = trimmed.slice(0, eqIndex).trim();
98
+ if (!isValidEnvName(key)) continue;
99
+
100
+ let value = trimmed.slice(eqIndex + 1).trim();
101
+
102
+ // Remove surrounding quotes (" or ')
103
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
104
+ value = value.slice(1, -1);
105
+ }
106
+ if (!isSafeEnvValue(value)) continue;
107
+
108
+ result[key] = value;
109
+ }
110
+ } catch {
111
+ // File doesn't exist or can't be read - return empty result
112
+ }
113
+
114
+ return result;
115
+ }
package/src/env.ts CHANGED
@@ -1,4 +1,3 @@
1
- import * as fs from "node:fs";
2
1
  import * as os from "node:os";
3
2
  import * as path from "node:path";
4
3
  import { getAgentDir, getConfigRootDir } from "./dirs";
@@ -6,111 +5,10 @@ import { isSafeEnvName, isSafeEnvValue } from "./spawn-env";
6
5
 
7
6
  export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
8
7
 
9
- const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+ import { parseEnvFile, parseShellEnvFile } from "./env-file";
10
9
 
11
- /**
12
- * Strict shell-identifier shape. Used for dotenv keys we accept into
13
- * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
14
- * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
15
- */
16
- export function isValidEnvName(name: string): boolean {
17
- return ENV_NAME_RE.test(name);
18
- }
19
-
20
- function stripInlineShellComment(value: string): string {
21
- let quote: '"' | "'" | undefined;
22
- for (let i = 0; i < value.length; i++) {
23
- const char = value[i];
24
- if (char === "\\") {
25
- i++;
26
- continue;
27
- }
28
- if ((char === '"' || char === "'") && (!quote || quote === char)) {
29
- quote = quote ? undefined : char;
30
- continue;
31
- }
32
- if (char === "#" && !quote && (i === 0 || /\s/.test(value[i - 1] ?? ""))) {
33
- return value.slice(0, i).trimEnd();
34
- }
35
- }
36
- return value.trimEnd();
37
- }
38
-
39
- /**
40
- * Parses simple POSIX shell environment assignments from files such as
41
- * ~/.zshrc without executing user shell code. Supports `export KEY=value` and
42
- * `KEY=value`, including single/double quoted literal values. Dynamic shell
43
- * expressions are intentionally ignored because evaluating startup files would
44
- * run arbitrary code during CLI startup.
45
- */
46
- export function parseShellEnvFile(filePath: string): Record<string, string> {
47
- const result: Record<string, string> = {};
48
- try {
49
- const content = fs.readFileSync(filePath, "utf-8");
50
- for (const line of content.split("\n")) {
51
- const trimmed = line.trim();
52
- if (!trimmed || trimmed.startsWith("#")) continue;
53
-
54
- const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
55
- if (!match) continue;
56
-
57
- const key = match[1];
58
- if (!isValidEnvName(key)) continue;
59
-
60
- let value = stripInlineShellComment(match[2] ?? "").trim();
61
- if (value.endsWith(";")) value = value.slice(0, -1).trimEnd();
62
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
63
- value = value.slice(1, -1);
64
- }
65
- if (!isSafeEnvValue(value)) continue;
66
- if (/[$`]/.test(value)) continue;
67
-
68
- result[key] = value;
69
- }
70
- } catch {
71
- // File doesn't exist or can't be read - return empty result
72
- }
73
-
74
- return result;
75
- }
76
-
77
- /**
78
- * Parses a .env file synchronously and extracts key-value string pairs.
79
- * Ignores lines that are empty or start with '#'. Trims whitespace.
80
- * Allows values to be quoted with single or double quotes.
81
- * Returns an object of key-value pairs.
82
- */
83
- export function parseEnvFile(filePath: string): Record<string, string> {
84
- const result: Record<string, string> = {};
85
- try {
86
- const content = fs.readFileSync(filePath, "utf-8");
87
- for (const line of content.split("\n")) {
88
- const trimmed = line.trim();
89
- // Skip comments and blank lines
90
- if (!trimmed || trimmed.startsWith("#")) continue;
91
-
92
- const eqIndex = trimmed.indexOf("=");
93
- if (eqIndex === -1) continue;
94
-
95
- const key = trimmed.slice(0, eqIndex).trim();
96
- if (!isValidEnvName(key)) continue;
97
-
98
- let value = trimmed.slice(eqIndex + 1).trim();
99
-
100
- // Remove surrounding quotes (" or ')
101
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
102
- value = value.slice(1, -1);
103
- }
104
- if (!isSafeEnvValue(value)) continue;
105
-
106
- result[key] = value;
107
- }
108
- } catch {
109
- // File doesn't exist or can't be read - return empty result
110
- }
111
-
112
- return result;
113
- }
10
+ // Re-exported so the public surface of this module is unchanged.
11
+ export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
114
12
 
115
13
  function resolveFileEnvValue(file: Record<string, string>, name: string): string | undefined {
116
14
  if (!isSafeEnvName(name)) return undefined;
package/src/postmortem.ts CHANGED
@@ -229,11 +229,21 @@ function redactCrashSecrets(text: string): string {
229
229
  redacted = redacted.replace(/\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "«redacted-auth»");
230
230
  redacted = redacted.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "«redacted-jwt»");
231
231
  redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "«redacted-api-key»");
232
+ // `gh[opsur]_` covers the classic PAT/OAuth/server/user/refresh prefixes;
233
+ // fine-grained PATs use an entirely different `github_pat_` prefix and would
234
+ // otherwise survive into a log the module keeps indefinitely.
232
235
  redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
236
+ redacted = redacted.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "«redacted-github-token»");
233
237
  redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
234
- redacted = redacted.replace(/\bAKIA[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
238
+ // AKIA is the long-term access key id; ASIA is the temporary/STS one, which is
239
+ // the shape that actually shows up in a crashed request. The id alone is not
240
+ // the credential: an STS payload carries `SecretAccessKey` and `SessionToken`
241
+ // alongside it, so the labeled-value rule below must name both. `secret_key`
242
+ // does not match `SecretAccessKey` (the canonical field has `Access` in the
243
+ // middle), and `access_token` does not match `SessionToken`.
244
+ redacted = redacted.replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
235
245
  redacted = redacted.replace(
236
- /(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
246
+ /(?<![A-Za-z0-9_])(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|client[_-]?secret|secret[_-]?key|secret[_-]?access[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
237
247
  "$1«redacted»",
238
248
  );
239
249
  return redacted;
package/src/procmgr.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { Process, ProcessStatus } from "@gajae-code/natives";
4
4
  import type { Subprocess } from "bun";
5
- import { $env, filterProcessEnv } from "./env";
5
+ import { $pickCredentialEnv, $pickflag, filterProcessEnv } from "./env";
6
6
  import { $which } from "./which";
7
7
 
8
8
  export interface ShellConfig {
@@ -41,9 +41,13 @@ function isExecutable(path: string): boolean {
41
41
 
42
42
  /**
43
43
  * Build the spawn environment (cached).
44
+ *
45
+ * `CI=true` is injected unless the documented `GJC_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.
44
48
  */
45
49
  function buildSpawnEnv(shell: string): Record<string, string> {
46
- const noCI = $env.PI_BASH_NO_CI || $env.CLAUDE_BASH_NO_CI;
50
+ const noCI = $pickflag("GJC_BASH_NO_CI", "PI_BASH_NO_CI", "CLAUDE_BASH_NO_CI");
47
51
  const inherited = filterProcessEnv(Bun.env);
48
52
  delete inherited.GJC_SESSION_FILE;
49
53
  delete inherited.GJC_MANAGED_OWNER_TRANSCRIPT_PATH;
@@ -60,18 +64,30 @@ function buildSpawnEnv(shell: string): Record<string, string> {
60
64
 
61
65
  /**
62
66
  * Get shell args, optionally including login shell flag.
63
- * Supports PI_BASH_NO_LOGIN and ANTHROPIC_MODEL_BASH_NO_LOGIN to skip -l.
67
+ *
68
+ * Honors the documented `GJC_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 `GJC_BASH_NO_LOGIN=0` keeps the login shell even when a legacy alias
72
+ * is set to a truthy value.
64
73
  */
65
74
  function getShellArgs(): string[] {
66
- const noLogin = $env.PI_BASH_NO_LOGIN || $env.CLAUDE_BASH_NO_LOGIN;
75
+ const noLogin = $pickflag("GJC_BASH_NO_LOGIN", "PI_BASH_NO_LOGIN", "CLAUDE_BASH_NO_LOGIN");
67
76
  return noLogin ? ["-c"] : ["-l", "-c"];
68
77
  }
69
78
 
70
79
  /**
71
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
+ * GJC/user-owned `.env` files), matching how provider credentials are resolved.
72
88
  */
73
89
  function getShellPrefix(): string | undefined {
74
- return $env.PI_SHELL_PREFIX || $env.CLAUDE_CODE_SHELL_PREFIX;
90
+ return $pickCredentialEnv("PI_SHELL_PREFIX", "CLAUDE_CODE_SHELL_PREFIX");
75
91
  }
76
92
 
77
93
  /**
@@ -187,6 +203,15 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
187
203
  return cachedShellConfig;
188
204
  }
189
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
+ }
214
+
190
215
  /**
191
216
  * Check if a process is running.
192
217
  */