@gajae-code/utils 0.14.2 → 0.15.0

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.
@@ -4,7 +4,7 @@ export declare const CRASH_EVENT_MAX_BYTES = 512;
4
4
  export declare const CRASH_EVENT_KIND = "gjc-crash-event.v1";
5
5
  /** Preview cap for the message class carried by an event. */
6
6
  export declare const CRASH_EVENT_MESSAGE_MAX_BYTES = 256;
7
- export type CrashEvent = CrashOccurrenceEvent | CrashReportedEvent | CrashRelayedEvent | CrashAcknowledgedEvent | CrashNudgedEvent;
7
+ export type CrashEvent = CrashOccurrenceEvent | CrashRefusedEvent | CrashReportedEvent | CrashRelayedEvent | CrashAcknowledgedEvent | CrashNudgedEvent;
8
8
  export interface CrashOccurrenceEvent {
9
9
  readonly kind: "occurrence";
10
10
  readonly fingerprint: string;
@@ -14,6 +14,14 @@ export interface CrashOccurrenceEvent {
14
14
  readonly errorName: string;
15
15
  readonly messageClass: string;
16
16
  }
17
+ export interface CrashRefusedEvent {
18
+ readonly kind: "refused";
19
+ readonly fingerprint: string;
20
+ readonly fpv: number;
21
+ readonly recordId: string;
22
+ readonly contractVersion: string;
23
+ readonly at: number;
24
+ }
17
25
  export interface CrashReportedEvent {
18
26
  readonly kind: "reported";
19
27
  readonly fingerprint: string;
@@ -50,15 +50,39 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
50
50
  export declare function getProjectDir(): string;
51
51
  /** Set the project directory. */
52
52
  export declare function setProjectDir(dir: string): void;
53
- export declare function getConfigDirName(): string;
54
- /** Get the config agent directory name relative to home (e.g. ".gjc/agent" or PI_CONFIG_DIR + "/agent"). */
53
+ /**
54
+ * Windows environment variable names are case-insensitive, so a project dotenv
55
+ * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
56
+ * provenance lookup here is spelled in upper case, so the snapshot must be
57
+ * keyed the same way or the declaration is invisible to the guard while still
58
+ * being live in the process. POSIX names are case-sensitive and must not fold.
59
+ */
60
+ export declare function canonicalEnvKey(name: string): string;
55
61
  export declare function getConfigAgentDirName(): string;
56
62
  /** Get the config root directory (~/.gjc). */
57
63
  export declare function getConfigRootDir(): string;
58
- /** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
64
+ /**
65
+ * The authoritative home for user-scope state.
66
+ *
67
+ * Provenance-checked and resolved at call time: a home established or changed
68
+ * after this module loaded is honored, while a home the project dotenv could
69
+ * have planted is rejected in favor of the OS account database. See
70
+ * {@link resolveTrustedHome}.
71
+ */
72
+ export declare function getTrustedHomeDir(): string;
73
+ /** Trusted config root, resolved at call time; preserves the configured nested config-dir name. */
74
+ export declare function getTrustedConfigRootDir(): string;
75
+ /**
76
+ * Set the coding agent directory. Creates a fresh resolver, invalidating all
77
+ * cached paths.
78
+ *
79
+ * This also exports `GJC_CODING_AGENT_DIR`, so child processes inherit the same
80
+ * selection and resolve the same storage lane.
81
+ */
59
82
  export declare function setAgentDir(dir: string): void;
60
83
  /** Get the agent config directory (~/.gjc/agent). */
61
84
  export declare function getAgentDir(): string;
85
+ export declare function getConfigDirName(): string;
62
86
  /**
63
87
  * Join a file under the provenance-checked agent directory, never the XDG
64
88
  * state category. Automatic crash relay must not follow `XDG_STATE_HOME`:
@@ -80,11 +104,12 @@ export declare function getLogPath(date?: Date): string;
80
104
  * Get the plugins directory (~/.gjc/plugins or its XDG equivalent).
81
105
  *
82
106
  * No-arg form (production callers) goes through the XDG-aware DirResolver so
83
- * reads and writes always agree. The optional `home` parameter is for test
84
- * isolation: when it differs from `os.homedir()` it short-circuits the resolver
85
- * and returns `<home>/<configDir>/plugins` so tests with a temp HOME get a
86
- * deterministic path. Passing `os.homedir()` explicitly is identical to the
87
- * no-arg form — XDG semantics are preserved.
107
+ * reads and writes always agree. The optional `home` parameter names an explicit
108
+ * home: when it differs from the authoritative home resolved right now it
109
+ * short-circuits the resolver and returns `<home>/<configDir>/plugins`, giving
110
+ * callers that carry their own home (and tests with a temp HOME) a deterministic
111
+ * path. Passing the authoritative home explicitly is identical to the no-arg
112
+ * form — XDG semantics are preserved.
88
113
  */
89
114
  export declare function getPluginsDir(home?: string): string;
90
115
  /** Where npm installs packages (~/.gjc/plugins/node_modules). */
@@ -133,14 +158,6 @@ export declare function getToolChoiceCapabilityCachePath(): string;
133
158
  export declare function getNativesDir(): string;
134
159
  /** Get the stats database path (~/.gjc/stats.db). */
135
160
  export declare function getStatsDbPath(): string;
136
- /** Get the autoresearch state directory (~/.gjc/autoresearch). */
137
- export declare function getAutoresearchDir(): string;
138
- /** Get the per-project autoresearch state directory (~/.gjc/autoresearch/<encoded-project>). */
139
- export declare function getAutoresearchProjectDir(encodedProject: string): string;
140
- /** Get the per-project autoresearch SQLite database path (~/.gjc/autoresearch/<encoded-project>.db). */
141
- export declare function getAutoresearchDbPath(encodedProject: string): string;
142
- /** Get the per-run artifact directory (~/.gjc/autoresearch/<encoded-project>/runs/<runId>). */
143
- export declare function getAutoresearchRunDir(encodedProject: string, runId: number): string;
144
161
  /** Get the path to agent.db (SQLite database for settings and auth storage). */
145
162
  export declare function getAgentDbPath(agentDir?: string): string;
146
163
  /** Get the path to history.db (SQLite database for session history). */
@@ -189,7 +206,14 @@ export declare function getProjectModulesDir(cwd?: string): string;
189
206
  export declare function getProjectPromptsDir(cwd?: string): string;
190
207
  /** Get the project-level plugin overrides path (.gjc/plugin-overrides.json). */
191
208
  export declare function getProjectPluginOverridesPath(cwd?: string): string;
192
- /** Get the primary MCP config file path (first candidate). */
193
- export declare function getMCPConfigPath(scope: "user" | "project", cwd?: string): string;
209
+ /**
210
+ * Get the primary MCP config file path (first candidate).
211
+ *
212
+ * User scope lives in the agent directory, so a profile override
213
+ * (`--agent-dir`, `GJC_CODING_AGENT_DIR`, `setAgentDir()`) moves it. Pass
214
+ * `agentDir` to resolve the scope of a session whose agent directory differs
215
+ * from the process-wide one.
216
+ */
217
+ export declare function getMCPConfigPath(scope: "user" | "project", cwd?: string, agentDir?: string): string;
194
218
  /** Get the SSH config file path. */
195
219
  export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string): string;
@@ -22,7 +22,7 @@ export declare function parseShellEnvFile(filePath: string): Record<string, stri
22
22
  * `filterCredentialInheritedEnv`) decide provenance by comparing
23
23
  * `process.env` against this parse, so the accepted syntax must be a superset
24
24
  * of what Bun's own dotenv loader honors in `cwd/.env`: `export KEY=value`,
25
- * whitespace around `=`, and `#` comments after unquoted values (quotes keep
25
+ * whitespace around `=` or `:`, and `#` comments after unquoted values (quotes keep
26
26
  * their `#`). Values that Bun would expand (`$VAR`, `${VAR}`, backticks,
27
27
  * command substitution) are kept as their literal text: the trust rule only
28
28
  * needs the parser to see the key at all, and an operator environment value
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Sanitize a value destined for an HTTP header.
3
+ *
4
+ * OS-derived components (kernel release, hostname, os.version) can contain
5
+ * non-ASCII characters — e.g. Android kernel releases such as
6
+ * `4.4.302-Minimal™-EAS-QTI_Haptic-R26` — which `Headers`/`fetch` reject
7
+ * before the request is ever sent. Strip everything outside printable
8
+ * ASCII so header construction can never throw on runtime-derived values.
9
+ */
10
+ export declare function sanitizeHeaderComponent(value: string): string;
@@ -12,6 +12,7 @@ export * from "./format";
12
12
  export * from "./frontmatter";
13
13
  export * from "./fs-error";
14
14
  export * from "./glob";
15
+ export * from "./header-value";
15
16
  export * from "./hook-fetch";
16
17
  export * from "./json";
17
18
  export * as logger from "./logger";
@@ -12,3 +12,12 @@
12
12
  * string after the control probe.
13
13
  */
14
14
  export declare function sanitizeText(text: string): string;
15
+ /**
16
+ * Sanitize untrusted text that must occupy exactly one rendered row.
17
+ *
18
+ * {@link sanitizeText} deliberately preserves `\n`, and width-based truncation
19
+ * treats it as zero-width, so a value carrying line breaks can still inject
20
+ * extra rows and evade a single-line width budget. Flatten every CR/LF run to a
21
+ * single space before the usual control/ANSI strip.
22
+ */
23
+ export declare function sanitizeDisplayLine(text: string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.14.2",
4
+ "version": "0.15.0",
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.14.2",
34
+ "@gajae-code/natives": "0.15.0",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
@@ -41,7 +41,7 @@
41
41
  "@types/bun": "^1.3.14"
42
42
  },
43
43
  "engines": {
44
- "bun": ">=1.3.14"
44
+ "bun": ">=1.4.0"
45
45
  },
46
46
  "files": [
47
47
  "src",
@@ -24,6 +24,7 @@ export const CRASH_EVENT_MESSAGE_MAX_BYTES = 256;
24
24
 
25
25
  export type CrashEvent =
26
26
  | CrashOccurrenceEvent
27
+ | CrashRefusedEvent
27
28
  | CrashReportedEvent
28
29
  | CrashRelayedEvent
29
30
  | CrashAcknowledgedEvent
@@ -39,6 +40,15 @@ export interface CrashOccurrenceEvent {
39
40
  readonly messageClass: string;
40
41
  }
41
42
 
43
+ export interface CrashRefusedEvent {
44
+ readonly kind: "refused";
45
+ readonly fingerprint: string;
46
+ readonly fpv: number;
47
+ readonly recordId: string;
48
+ readonly contractVersion: string;
49
+ readonly at: number;
50
+ }
51
+
42
52
  export interface CrashReportedEvent {
43
53
  readonly kind: "reported";
44
54
  readonly fingerprint: string;
@@ -109,11 +119,20 @@ export function formatCrashEventLine(event: CrashEvent): string {
109
119
  u: sanitizeEventText(truncateUtf8(event.issueUrl, 256)),
110
120
  ...(event.commented ? { c: 1 } : {}),
111
121
  }
112
- : event.kind === "relayed"
113
- ? { k: "relayed", fp: event.fingerprint, at: event.at, e: event.eventId, r: event.recordId }
114
- : event.kind === "acknowledged"
115
- ? { k: "acknowledged", fp: event.fingerprint, at: event.at }
116
- : { k: "nudged", at: event.at };
122
+ : event.kind === "refused"
123
+ ? {
124
+ k: "refused",
125
+ fp: event.fingerprint,
126
+ v: event.fpv,
127
+ r: event.recordId,
128
+ c: sanitizeEventText(truncateUtf8(event.contractVersion, 64)),
129
+ at: event.at,
130
+ }
131
+ : event.kind === "relayed"
132
+ ? { k: "relayed", fp: event.fingerprint, at: event.at, e: event.eventId, r: event.recordId }
133
+ : event.kind === "acknowledged"
134
+ ? { k: "acknowledged", fp: event.fingerprint, at: event.at }
135
+ : { k: "nudged", at: event.at };
117
136
  return `${CRASH_EVENT_KIND} ${JSON.stringify(body)}\n`;
118
137
  };
119
138
 
@@ -169,6 +188,14 @@ export function parseCrashEventLine(line: string): CrashEvent | undefined {
169
188
  if (typeof body.u !== "string" || body.u.length === 0) return undefined;
170
189
  return { kind: "reported", fingerprint, at, issueUrl: sanitizeEventText(body.u), commented: body.c === 1 };
171
190
  }
191
+ case "refused": {
192
+ if (!fingerprint) return undefined;
193
+ if (typeof body.r !== "string" || !/^[0-9a-f]{8,32}$/.test(body.r)) return undefined;
194
+ if (typeof body.v !== "number" || !Number.isSafeInteger(body.v) || body.v < 1) return undefined;
195
+ if (typeof body.c !== "string" || body.c.length === 0 || /[\u0000-\u001f\u007f-\u009f]/.test(body.c))
196
+ return undefined;
197
+ return { kind: "refused", fingerprint, fpv: body.v, recordId: body.r, contractVersion: body.c, at };
198
+ }
172
199
  case "relayed": {
173
200
  if (!fingerprint) return undefined;
174
201
  if (typeof body.e !== "string" || !/^[0-9a-f]{32}$/.test(body.e)) return undefined;
package/src/dirs.ts CHANGED
@@ -145,8 +145,9 @@ export function getProjectDir(): string {
145
145
 
146
146
  /** Set the project directory. */
147
147
  export function setProjectDir(dir: string): void {
148
- projectDir = standardizeMacOSPath(path.resolve(dir));
149
- process.chdir(projectDir);
148
+ const resolved = standardizeMacOSPath(path.resolve(dir));
149
+ process.chdir(resolved);
150
+ projectDir = standardizeMacOSPath(process.cwd());
150
151
  }
151
152
 
152
153
  /**
@@ -169,9 +170,42 @@ function sanitizeConfigDirName(value: string | undefined): string | undefined {
169
170
  return trimmed;
170
171
  }
171
172
 
172
- /** Get the config directory name relative to home (e.g. ".gjc" or PI_CONFIG_DIR override). */
173
173
  /**
174
- * Config-directory name, rejected when it comes from the caller's project `.env`.
174
+ * Windows environment variable names are case-insensitive, so a project dotenv
175
+ * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
176
+ * provenance lookup here is spelled in upper case, so the snapshot must be
177
+ * keyed the same way or the declaration is invisible to the guard while still
178
+ * being live in the process. POSIX names are case-sensitive and must not fold.
179
+ */
180
+ export function canonicalEnvKey(name: string): string {
181
+ return process.platform === "win32" ? name.toUpperCase() : name;
182
+ }
183
+
184
+ function projectEnvSnapshot(cwd = process.cwd()): { values: Record<string, string>; dynamic: Set<string> } {
185
+ const nodeEnv = process.env.NODE_ENV;
186
+ const validNodeEnv = nodeEnv && /^[A-Za-z0-9_-]+$/.test(nodeEnv) ? nodeEnv : undefined;
187
+ const files = [
188
+ ".env",
189
+ ...(validNodeEnv ? [`.env.${validNodeEnv}`] : []),
190
+ ...(validNodeEnv !== "test" ? [".env.local"] : []),
191
+ ...(validNodeEnv ? [`.env.${validNodeEnv}.local`] : []),
192
+ ];
193
+ const values: Record<string, string> = {};
194
+ const dynamic = new Set<string>();
195
+ for (const file of files) {
196
+ for (const [rawKey, value] of Object.entries(parseEnvFile(path.join(cwd, file)))) {
197
+ const key = canonicalEnvKey(rawKey);
198
+ values[key] = value;
199
+ if (/[$`]/.test(value)) dynamic.add(key);
200
+ else dynamic.delete(key);
201
+ }
202
+ }
203
+ return { values, dynamic };
204
+ }
205
+
206
+ /**
207
+ * Resolve an environment value only when it is not supplied by the caller's
208
+ * project dotenv (or when the inherited value is observably distinct).
175
209
  *
176
210
  * The name is joined with the home directory to build the config root, and that
177
211
  * root plus the agent directory beneath it supply two of the `.env` files
@@ -186,25 +220,188 @@ function sanitizeConfigDirName(value: string | undefined): string | undefined {
186
220
  * happens to carry the identical value loses the override, which is the same
187
221
  * trade-off `resolveLiveCredentialEnvValue` already makes.
188
222
  */
189
- function trustedConfigDirName(name: "GJC_CONFIG_DIR" | "PI_CONFIG_DIR"): string | undefined {
223
+ function trustedValue(
224
+ name: string,
225
+ project: { values: Record<string, string>; dynamic: Set<string> },
226
+ ): string | undefined {
190
227
  const value = process.env[name];
191
228
  if (!value) return undefined;
192
- if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
229
+ const key = canonicalEnvKey(name);
230
+ const projectValue = project.values[key];
231
+ if (projectValue !== undefined && (project.dynamic.has(key) || projectValue === value)) return undefined;
193
232
  return value;
194
233
  }
195
234
 
196
- export function getConfigDirName(): string {
197
- // Both guards apply: the value must come from a trusted source (not the
198
- // caller's project `.env`), and it must still be a single name that stays
199
- // beneath home once joined.
235
+ function resolveConfigDirName(project: { values: Record<string, string>; dynamic: Set<string> }): string {
200
236
  return (
201
- sanitizeConfigDirName(trustedConfigDirName("GJC_CONFIG_DIR")) ??
202
- sanitizeConfigDirName(trustedConfigDirName("PI_CONFIG_DIR")) ??
237
+ sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", project)) ??
238
+ sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", project)) ??
203
239
  CONFIG_DIR_NAME
204
240
  );
205
241
  }
206
242
 
207
- /** Get the config agent directory name relative to home (e.g. ".gjc/agent" or PI_CONFIG_DIR + "/agent"). */
243
+ /**
244
+ * A home directory is usable only when it is absolute and resolves to somewhere
245
+ * strictly below a filesystem root. A relative value would anchor user state
246
+ * beneath whatever the current directory happens to be, and a root would place
247
+ * it at `/.gjc`.
248
+ *
249
+ * The root test normalizes first, because a root has many spellings: `/.`, `//`,
250
+ * `/foo/..` and `C:\x\..` are all roots that a raw string comparison against
251
+ * `path.parse(home).root` misses, and `path.join(home, ".gjc")` would happily
252
+ * produce `/.gjc` from every one of them.
253
+ *
254
+ * The **original spelling** is returned, never the normalized form. Provenance
255
+ * compares the declared dotenv value against this result, and both sides must
256
+ * stay in the same spelling: canonicalizing only this side would make
257
+ * `HOME=/tmp/base/../attacker` compare unequal to its own declaration and let a
258
+ * project-planted home through as if it were operator-supplied.
259
+ */
260
+ function usableHome(home: string | undefined): string | undefined {
261
+ if (!home || !path.isAbsolute(home)) return undefined;
262
+ const normalized = path.resolve(home);
263
+ return normalized === path.parse(normalized).root ? undefined : home;
264
+ }
265
+
266
+ /**
267
+ * The account home for the running uid, read through the operating system's own
268
+ * account database.
269
+ *
270
+ * On Linux this must go through NSS rather than parsing `/etc/passwd`: LDAP and
271
+ * SSSD accounts have no local passwd entry, and a direct file read would miss
272
+ * them and fall through to an environment-derived value. `getent passwd` is the
273
+ * NSS front end, so it resolves local and directory-backed accounts alike.
274
+ *
275
+ * Only an **environment-independent** result is memoized, and only on success.
276
+ * The cache is keyed by the effective account identity, not by process lifetime:
277
+ * a setuid or container identity transition must never reuse another uid's home.
278
+ * The NSS answer cannot change during one identity's process lifetime, so
279
+ * per-identity caching is safe.
280
+ * The `os.userInfo()` fallback is different: Bun derives `homedir` from `$HOME`,
281
+ * so caching it would freeze one side of the independence comparison in
282
+ * {@link resolveTrustedHome}. A planted home that was live at first resolution
283
+ * would stay cached, and once the runtime home moved it would no longer *equal*
284
+ * the runtime home -- passing the echo check and being promoted to independent
285
+ * evidence. Provenance is carried with the value so that can never happen.
286
+ */
287
+ type AccountHome = { home: string; envDerived: boolean };
288
+ type AccountIdentity = { key: string; uid: number };
289
+
290
+ const accountHomeCache = new Map<string, AccountHome>();
291
+
292
+ function accountIdentity(info: os.UserInfo<string>): AccountIdentity {
293
+ const uid = process.platform === "win32" ? info.uid : (process.geteuid?.() ?? info.uid);
294
+ return {
295
+ key: `${process.platform}:uid=${uid}:user=${info.username}`,
296
+ uid,
297
+ };
298
+ }
299
+
300
+ /** The uid's home field from the NSS account database, or undefined. */
301
+ function nssAccountHome(uid: number): string | undefined {
302
+ try {
303
+ // Spawned with an empty environment so nothing the caller controls (HOME,
304
+ // NSS module configuration, locale) can steer the answer.
305
+ const result = Bun.spawnSync({
306
+ cmd: ["getent", "passwd", String(uid)],
307
+ env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" },
308
+ stdout: "pipe",
309
+ stderr: "ignore",
310
+ });
311
+ if (result.exitCode !== 0) return undefined;
312
+ // `getent` echoes passwd-format records; the home directory is field 6.
313
+ const line = new TextDecoder().decode(result.stdout).split("\n")[0];
314
+ return usableHome(line?.split(":")[5]);
315
+ } catch {
316
+ return undefined;
317
+ }
318
+ }
319
+
320
+ function accountHomeFromSystem(): AccountHome | undefined {
321
+ try {
322
+ const info = os.userInfo();
323
+ const identity = accountIdentity(info);
324
+ const cached = accountHomeCache.get(identity.key);
325
+ if (cached !== undefined) return cached;
326
+ if (process.platform === "linux") {
327
+ const nss = nssAccountHome(identity.uid);
328
+ if (nss !== undefined) {
329
+ // NSS is environment-independent and stable: safe to memoize.
330
+ const result = { home: nss, envDerived: false };
331
+ accountHomeCache.set(identity.key, result);
332
+ return result;
333
+ }
334
+ }
335
+ // `os.userInfo().homedir` is the portable path for macOS and Windows, and on
336
+ // Linux is reached only when NSS is unavailable. Bun derives it from `$HOME`,
337
+ // so it is re-read every time and never cached, and it is flagged so the
338
+ // caller can refuse to treat it as independent evidence.
339
+ const fallback = usableHome(info.homedir);
340
+ if (fallback !== undefined) return { home: fallback, envDerived: true };
341
+ } catch {
342
+ // Do not retain or consult a prior identity's result when the current
343
+ // identity cannot be observed. An unavailable uid is not evidence for any
344
+ // other uid and must fail closed instead of inheriting stale state.
345
+ return undefined;
346
+ }
347
+ return undefined;
348
+ }
349
+
350
+ /**
351
+ * Resolve the authoritative home for user-scope state.
352
+ *
353
+ * Two properties must hold together, and pinning either one alone breaks the
354
+ * other (issue #4761):
355
+ *
356
+ * 1. **Provenance.** Bun overlays a checkout's `.env` into `process.env` before
357
+ * any module runs, so a repository can plant HOME/USERPROFILE and redirect
358
+ * user state — including the `.env` files `$credentialEnv` treats as trusted.
359
+ * When the platform-authoritative variable is indistinguishable from the
360
+ * value the project dotenv declares, the OS account database wins instead.
361
+ * 2. **Call-time resolution.** The trusted home is *derived*, never snapshotted
362
+ * at module load. A resolution frozen at import silently loses every
363
+ * user-scope location whenever the runtime home is established or changed
364
+ * after this module initializes — which is exactly how user-scope skill and
365
+ * MCP discovery regressed.
366
+ *
367
+ * `os.homedir()` is the runtime candidate: on POSIX it reflects HOME, on Windows
368
+ * USERPROFILE, and it falls back to the account database on its own. Reading it
369
+ * per call is what makes the contract call-time; the provenance comparison above
370
+ * is what keeps an untrusted mutable home from being honored.
371
+ */
372
+ function resolveTrustedHome(project: { values: Record<string, string>; dynamic: Set<string> }): string {
373
+ const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME";
374
+ const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey);
375
+ const declaredHome = project.values[declaredHomeKey];
376
+ // A relative or filesystem-root runtime home would anchor user state beneath
377
+ // the current directory (or at `/`), so it is not a usable candidate no matter
378
+ // how it was supplied. Validate it exactly as the account home is validated.
379
+ const runtimeHome = usableHome(os.homedir());
380
+ // Only the platform-authoritative variable can select the home. In particular,
381
+ // do not let the opposite platform variable (or a project dotenv value
382
+ // overlaid into it) redirect user state when this is absent.
383
+ const ambiguousHome =
384
+ declaredHome !== undefined && (project.dynamic.has(declaredHomeKey) || declaredHome === runtimeHome);
385
+ // The account lookup is consulted lazily. It can spawn the NSS front end, and
386
+ // this resolver runs on every directory access, so an unambiguous runtime home
387
+ // -- the ordinary CLI path -- must never pay for it.
388
+ if (!ambiguousHome && runtimeHome !== undefined) return runtimeHome;
389
+
390
+ const accountHome = accountHomeFromSystem();
391
+ if (ambiguousHome) {
392
+ // The account home is independent evidence only when it is not itself derived
393
+ // from the environment. An `os.userInfo()` fallback echoes `$HOME`, so a
394
+ // project-declared home would otherwise come back as its own justification.
395
+ // Fail closed: with no independent evidence the resolver yields a filesystem
396
+ // root, which `#homeAvailable` rejects, rather than honoring the declared
397
+ // home. Issue #4773 owns widening that fallback; do not weaken it here.
398
+ if (accountHome === undefined || accountHome.envDerived) return path.parse(process.cwd()).root;
399
+ return accountHome.home;
400
+ }
401
+ // No usable runtime home: fall back to whatever the account database reports.
402
+ if (accountHome !== undefined) return accountHome.home;
403
+ throw new Error("Unable to determine a trustworthy account home directory");
404
+ }
208
405
  export function getConfigAgentDirName(): string {
209
406
  return `${getConfigDirName()}/agent`;
210
407
  }
@@ -220,55 +417,100 @@ type XdgCategory = "data" | "state" | "cache";
220
417
  * variables are set, paths are redirected under $XDG_*_HOME/gjc/. A new
221
418
  * instance is created whenever the agent directory changes, which naturally
222
419
  * invalidates all cached paths.
420
+ *
421
+ * The trusted home is re-derived on each access (see {@link resolveTrustedHome})
422
+ * and every cached path is rebuilt when it changes, so a home established or
423
+ * mocked after module load is honored without weakening the provenance rule.
223
424
  */
224
425
  class DirResolver {
225
- readonly configRoot: string;
226
- readonly agentDir: string;
426
+ configRoot: string;
427
+ agentDir: string;
428
+ readonly #projectEnv: { values: Record<string, string>; dynamic: Set<string> };
429
+ #configDirName: string;
430
+ readonly #agentDirOverride: boolean;
431
+ #trustedHome: string;
432
+ /**
433
+ * Whether this resolver's agent directory may follow `$XDG_*_HOME`, decided
434
+ * once at construction and never re-derived from the path afterwards.
435
+ */
436
+ #xdgEligible: boolean;
227
437
 
228
438
  // Per-category base dirs. Without XDG, all three equal configRoot / agentDir.
229
439
  // With XDG on Linux, they point to $XDG_*_HOME/gjc/.
230
- readonly #rootDirs: Record<XdgCategory, string>;
231
- readonly #agentDirs: Record<XdgCategory, string>;
440
+ #rootDirs: Record<XdgCategory, string>;
441
+ #agentDirs: Record<XdgCategory, string>;
232
442
 
233
443
  readonly #rootCache = new Map<string, string>();
234
444
  readonly #agentCache = new Map<string, string>();
235
445
 
236
- constructor(agentDirOverride?: string) {
237
- this.configRoot = path.join(os.homedir(), getConfigDirName());
446
+ constructor(agentDirOverride?: string, snapshot = projectEnvSnapshot()) {
447
+ this.#projectEnv = snapshot;
448
+ this.#configDirName = resolveConfigDirName(snapshot);
449
+ this.#trustedHome = resolveTrustedHome(snapshot);
450
+ this.configRoot = path.join(this.#trustedHome, this.#configDirName);
238
451
 
239
452
  const defaultAgent = path.join(this.configRoot, "agent");
453
+ this.#agentDirOverride = Boolean(agentDirOverride);
240
454
  this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent;
455
+ // An agent directory equal to the home-derived default *is* the default
456
+ // profile, XDG categories included, however it arrived.
457
+ //
458
+ // Deciding this from override state instead was tried and reverted: it is
459
+ // unobservably wrong. `setAgentDir()` exports `GJC_CODING_AGENT_DIR`, so a
460
+ // child process inherits the same value the parent set programmatically and
461
+ // cannot tell the two apart. Treating the inherited form as "not default"
462
+ // put parent and child on different storage lanes for one logical profile --
463
+ // the parent reading `$XDG_STATE_HOME/gjc/python-gateway` while the child
464
+ // read `<agentDir>/python-gateway`. Splitting a live store in half is worse
465
+ // than the narrower complaint it was meant to answer.
241
466
  const isDefault = this.agentDir === defaultAgent;
467
+ // That decision is then *sticky*. Recomputing it later from path shape is
468
+ // what let a pinned agent directory silently change storage lane when a home
469
+ // refresh made it coincide with the new default: `getAgentDir()` looked
470
+ // unchanged while `agent.db` moved into `$XDG_DATA_HOME/gjc`.
471
+ this.#xdgEligible = isDefault;
472
+
473
+ this.#rootDirs = { data: this.configRoot, state: this.configRoot, cache: this.configRoot };
474
+ this.#agentDirs = { data: this.agentDir, state: this.agentDir, cache: this.agentDir };
475
+ this.refreshCategoryDirs(snapshot, isDefault);
476
+ }
242
477
 
243
- // XDG is a Linux convention. On other platforms, or for non-default
244
- // profiles, all categories resolve to the legacy paths.
478
+ /**
479
+ * `isDefault` decides whether the agent directory may follow `$XDG_*_HOME`.
480
+ *
481
+ * It is always supplied by the caller and never defaulted: the only correct
482
+ * value is the construction-time decision held in `#xdgEligible`, and
483
+ * re-deriving it from path shape is exactly the bug that let a directory
484
+ * change storage lane when a home refresh made its path coincide with the
485
+ * new default.
486
+ */
487
+ private refreshCategoryDirs(
488
+ snapshot: { values: Record<string, string>; dynamic: Set<string> },
489
+ isDefault: boolean,
490
+ ): void {
245
491
  let xdgData: string | undefined;
246
492
  let xdgState: string | undefined;
247
493
  let xdgCache: string | undefined;
248
494
  if ((process.platform === "linux" || process.platform === "darwin") && isDefault) {
249
495
  const resolveIf = (envVar: string) => {
250
- const value = process.env[envVar];
251
- if (value) {
252
- try {
253
- const joined = path.join(value, APP_NAME);
254
- if (fs.existsSync(joined)) {
255
- return joined;
256
- }
257
- } catch {}
496
+ const value = trustedValue(envVar, snapshot);
497
+ if (!value) return undefined;
498
+ try {
499
+ const joined = path.join(value, APP_NAME);
500
+ return fs.existsSync(joined) ? joined : undefined;
501
+ } catch {
502
+ return undefined;
258
503
  }
259
- return undefined;
260
504
  };
261
505
  xdgData = resolveIf("XDG_DATA_HOME");
262
506
  xdgState = resolveIf("XDG_STATE_HOME");
263
507
  xdgCache = resolveIf("XDG_CACHE_HOME");
264
508
  }
265
-
266
509
  this.#rootDirs = {
267
510
  data: xdgData ?? this.configRoot,
268
511
  state: xdgState ?? this.configRoot,
269
512
  cache: xdgCache ?? this.configRoot,
270
513
  };
271
- // XDG flattens the agent/ prefix: ~/.gjc/agent/sessions → $XDG_DATA_HOME/gjc/sessions
272
514
  this.#agentDirs = {
273
515
  data: xdgData ?? this.agentDir,
274
516
  state: xdgState ?? this.agentDir,
@@ -276,8 +518,47 @@ class DirResolver {
276
518
  };
277
519
  }
278
520
 
521
+ /**
522
+ * Re-derive the trusted home and the caller-supplied config-dir override
523
+ * without replacing the trust snapshot.
524
+ *
525
+ * Both inputs are call-time: the home comes from {@link resolveTrustedHome}
526
+ * (provenance-checked, never an import-time snapshot) and the config-dir name
527
+ * from the trusted-value rule. When either changes, the config root, the
528
+ * default agent dir, the XDG category dirs and both path caches are rebuilt
529
+ * so reads and writes cannot straddle two different homes.
530
+ */
531
+ refreshConfigDirOverride(): void {
532
+ const nextConfigDirName = resolveConfigDirName(this.#projectEnv);
533
+ const nextHome = resolveTrustedHome(this.#projectEnv);
534
+ if (nextConfigDirName === this.#configDirName && nextHome === this.#trustedHome) return;
535
+ const nextConfigRoot = path.join(nextHome, nextConfigDirName);
536
+ const nextAgentDir = this.#agentDirOverride ? this.agentDir : path.join(nextConfigRoot, "agent");
537
+ this.#trustedHome = nextHome;
538
+ this.#configDirName = nextConfigDirName;
539
+ this.configRoot = nextConfigRoot;
540
+ this.agentDir = nextAgentDir;
541
+ // Reuse the construction-time decision rather than re-deriving it, so an
542
+ // agent directory never changes storage lane just because a home refresh made
543
+ // its path coincide with (or diverge from) the new default.
544
+ this.refreshCategoryDirs(this.#projectEnv, this.#xdgEligible);
545
+ this.#rootCache.clear();
546
+ this.#agentCache.clear();
547
+ }
548
+
549
+ /** Whether the resolved home is a real directory rather than a filesystem root. */
550
+ get #homeAvailable(): boolean {
551
+ return this.#trustedHome !== path.parse(this.#trustedHome).root;
552
+ }
553
+
554
+ isProjectEnvDeclaration(name: string): boolean {
555
+ return Object.hasOwn(this.#projectEnv.values, canonicalEnvKey(name));
556
+ }
557
+
279
558
  /** Config-root subdirectory, with optional XDG override. */
280
559
  rootSubdir(subdir: string, xdg?: XdgCategory): string {
560
+ this.refreshConfigDirOverride();
561
+ if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
281
562
  const cached = this.#rootCache.get(subdir);
282
563
  if (cached) return cached;
283
564
  const base = xdg ? this.#rootDirs[xdg] : this.configRoot;
@@ -288,6 +569,8 @@ class DirResolver {
288
569
 
289
570
  /** Agent subdirectory, with optional XDG override. */
290
571
  agentSubdir(userAgentDir: string | undefined, subdir: string, xdg?: XdgCategory): string {
572
+ this.refreshConfigDirOverride();
573
+ if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
291
574
  if (!userAgentDir || userAgentDir === this.agentDir) {
292
575
  const cached = this.#agentCache.get(subdir);
293
576
  if (cached) return cached;
@@ -298,51 +581,28 @@ class DirResolver {
298
581
  }
299
582
  return path.join(userAgentDir, subdir);
300
583
  }
301
- }
302
584
 
303
- /**
304
- * Agent-directory override, rejected when it comes from the caller's project
305
- * `.env`.
306
- *
307
- * This directory selects the agent's own `.env`, which is one of the trusted
308
- * sources `$credentialEnv` consults. Bun loads `cwd/.env` into `process.env`
309
- * before any module runs, so a repository could otherwise point this at a
310
- * directory it ships and have its own `.env` treated as trusted — recovering
311
- * every redirect the credential boundary is meant to reject.
312
- *
313
- * `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
314
- * it applies the same conservative ambiguity rule directly: a value that matches
315
- * what the project `.env` sets is not honoured. An operator whose environment
316
- * happens to carry the identical value loses the override, which is the same
317
- * trade-off `resolveLiveCredentialEnvValue` already makes.
318
- */
319
- function trustedAgentDirOverrideFor(name: "GJC_CODING_AGENT_DIR" | "PI_CODING_AGENT_DIR"): string | undefined {
320
- const value = process.env[name];
321
- if (!value) return undefined;
322
- if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
323
- return value;
324
- }
325
-
326
- /**
327
- * Both spellings are honoured, mirroring `getConfigDirName`.
328
- *
329
- * `PI_CODING_AGENT_DIR` is the legacy alias this module's own header documents,
330
- * and parts of the product already resolve it (`gc-runtime.ts:370`,
331
- * `deep-interview-runtime.ts:384`). Reading only the `GJC_` spelling here split
332
- * the agent directory in two: `gjc gc` operated on the aliased directory while
333
- * everything reaching `getAgentDir()` stayed on the default.
334
- */
335
- function trustedAgentDirOverride(): string | undefined {
336
- return trustedAgentDirOverrideFor("GJC_CODING_AGENT_DIR") ?? trustedAgentDirOverrideFor("PI_CODING_AGENT_DIR");
585
+ get configDirName(): string {
586
+ return this.#configDirName;
587
+ }
588
+ get trustedHome(): string {
589
+ this.refreshConfigDirOverride();
590
+ return this.#trustedHome;
591
+ }
592
+ assertHomeAvailable(): void {
593
+ this.refreshConfigDirOverride();
594
+ if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
595
+ }
596
+ get trustSnapshot(): { values: Record<string, string>; dynamic: Set<string> } {
597
+ return this.#projectEnv;
598
+ }
337
599
  }
338
600
 
339
- let dirs = new DirResolver(trustedAgentDirOverride());
340
-
341
- // Anchor home for the resolver. Captured at module load to stay stable across
342
- // test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
343
- // production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
344
- // tests passing a temp HOME short-circuit to a deterministic path.
345
- const RESOLVER_HOME = os.homedir();
601
+ const INITIAL_PROJECT_SNAPSHOT = projectEnvSnapshot();
602
+ const trustedAgentOverride =
603
+ trustedValue("GJC_CODING_AGENT_DIR", INITIAL_PROJECT_SNAPSHOT) ??
604
+ trustedValue("PI_CODING_AGENT_DIR", INITIAL_PROJECT_SNAPSHOT);
605
+ let dirs = new DirResolver(trustedAgentOverride, INITIAL_PROJECT_SNAPSHOT);
346
606
 
347
607
  // =============================================================================
348
608
  // Root directories
@@ -350,19 +610,54 @@ const RESOLVER_HOME = os.homedir();
350
610
 
351
611
  /** Get the config root directory (~/.gjc). */
352
612
  export function getConfigRootDir(): string {
613
+ dirs.refreshConfigDirOverride();
614
+ dirs.assertHomeAvailable();
353
615
  return dirs.configRoot;
354
616
  }
355
617
 
356
- /** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
618
+ /**
619
+ * The authoritative home for user-scope state.
620
+ *
621
+ * Provenance-checked and resolved at call time: a home established or changed
622
+ * after this module loaded is honored, while a home the project dotenv could
623
+ * have planted is rejected in favor of the OS account database. See
624
+ * {@link resolveTrustedHome}.
625
+ */
626
+ export function getTrustedHomeDir(): string {
627
+ dirs.assertHomeAvailable();
628
+ return dirs.trustedHome;
629
+ }
630
+
631
+ /** Trusted config root, resolved at call time; preserves the configured nested config-dir name. */
632
+ export function getTrustedConfigRootDir(): string {
633
+ dirs.refreshConfigDirOverride();
634
+ dirs.assertHomeAvailable();
635
+ return dirs.configRoot;
636
+ }
637
+
638
+ /**
639
+ * Set the coding agent directory. Creates a fresh resolver, invalidating all
640
+ * cached paths.
641
+ *
642
+ * This also exports `GJC_CODING_AGENT_DIR`, so child processes inherit the same
643
+ * selection and resolve the same storage lane.
644
+ */
357
645
  export function setAgentDir(dir: string): void {
358
- dirs = new DirResolver(dir);
646
+ dirs = new DirResolver(dir, dirs.trustSnapshot);
359
647
  process.env.GJC_CODING_AGENT_DIR = dir;
360
648
  }
361
649
 
362
650
  /** Get the agent config directory (~/.gjc/agent). */
363
651
  export function getAgentDir(): string {
652
+ dirs.refreshConfigDirOverride();
653
+ dirs.assertHomeAvailable();
364
654
  return dirs.agentDir;
365
655
  }
656
+
657
+ export function getConfigDirName(): string {
658
+ dirs.refreshConfigDirOverride();
659
+ return dirs.configDirName;
660
+ }
366
661
  /**
367
662
  * Join a file under the provenance-checked agent directory, never the XDG
368
663
  * state category. Automatic crash relay must not follow `XDG_STATE_HOME`:
@@ -375,7 +670,7 @@ export function getTrustedAgentFile(filename: string): string {
375
670
 
376
671
  /** Whether the current checkout declares an environment key in its `.env`. */
377
672
  export function isProjectEnvDeclaration(name: string): boolean {
378
- return Object.hasOwn(parseEnvFile(path.join(process.cwd(), ".env")), name);
673
+ return dirs.isProjectEnvDeclaration(name);
379
674
  }
380
675
 
381
676
  /** Get the project-local config directory (.gjc). */
@@ -406,15 +701,24 @@ export function getLogPath(date = new Date()): string {
406
701
  * Get the plugins directory (~/.gjc/plugins or its XDG equivalent).
407
702
  *
408
703
  * No-arg form (production callers) goes through the XDG-aware DirResolver so
409
- * reads and writes always agree. The optional `home` parameter is for test
410
- * isolation: when it differs from `os.homedir()` it short-circuits the resolver
411
- * and returns `<home>/<configDir>/plugins` so tests with a temp HOME get a
412
- * deterministic path. Passing `os.homedir()` explicitly is identical to the
413
- * no-arg form — XDG semantics are preserved.
704
+ * reads and writes always agree. The optional `home` parameter names an explicit
705
+ * home: when it differs from the authoritative home resolved right now it
706
+ * short-circuits the resolver and returns `<home>/<configDir>/plugins`, giving
707
+ * callers that carry their own home (and tests with a temp HOME) a deterministic
708
+ * path. Passing the authoritative home explicitly is identical to the no-arg
709
+ * form — XDG semantics are preserved.
414
710
  */
415
711
  export function getPluginsDir(home?: string): string {
416
- if (home !== undefined && home !== RESOLVER_HOME) {
417
- return path.join(home, getConfigDirName(), "plugins");
712
+ if (home !== undefined) {
713
+ const explicitPath = () => path.join(home, resolveConfigDirName(dirs.trustSnapshot), "plugins");
714
+ try {
715
+ if (home !== dirs.trustedHome) return explicitPath();
716
+ } catch {
717
+ // An explicit home is the caller's documented escape hatch. If the
718
+ // authoritative home is unavailable, do not let its fail-closed resolver
719
+ // prevent a caller-owned plugin path from being returned.
720
+ return explicitPath();
721
+ }
418
722
  }
419
723
  return dirs.rootSubdir("plugins", "data");
420
724
  }
@@ -518,26 +822,6 @@ export function getStatsDbPath(): string {
518
822
  return dirs.rootSubdir("stats.db", "data");
519
823
  }
520
824
 
521
- /** Get the autoresearch state directory (~/.gjc/autoresearch). */
522
- export function getAutoresearchDir(): string {
523
- return dirs.rootSubdir("autoresearch", "state");
524
- }
525
-
526
- /** Get the per-project autoresearch state directory (~/.gjc/autoresearch/<encoded-project>). */
527
- export function getAutoresearchProjectDir(encodedProject: string): string {
528
- return path.join(getAutoresearchDir(), encodedProject);
529
- }
530
-
531
- /** Get the per-project autoresearch SQLite database path (~/.gjc/autoresearch/<encoded-project>.db). */
532
- export function getAutoresearchDbPath(encodedProject: string): string {
533
- return path.join(getAutoresearchDir(), `${encodedProject}.db`);
534
- }
535
-
536
- /** Get the per-run artifact directory (~/.gjc/autoresearch/<encoded-project>/runs/<runId>). */
537
- export function getAutoresearchRunDir(encodedProject: string, runId: number): string {
538
- return path.join(getAutoresearchProjectDir(encodedProject), "runs", String(runId).padStart(4, "0"));
539
- }
540
-
541
825
  // =============================================================================
542
826
  // Agent subdirectories (~/.gjc/agent/*)
543
827
  // =============================================================================
@@ -670,10 +954,17 @@ export function getProjectPluginOverridesPath(cwd: string = getProjectDir()): st
670
954
  // MCP config paths
671
955
  // =============================================================================
672
956
 
673
- /** Get the primary MCP config file path (first candidate). */
674
- export function getMCPConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string {
957
+ /**
958
+ * Get the primary MCP config file path (first candidate).
959
+ *
960
+ * User scope lives in the agent directory, so a profile override
961
+ * (`--agent-dir`, `GJC_CODING_AGENT_DIR`, `setAgentDir()`) moves it. Pass
962
+ * `agentDir` to resolve the scope of a session whose agent directory differs
963
+ * from the process-wide one.
964
+ */
965
+ export function getMCPConfigPath(scope: "user" | "project", cwd: string = getProjectDir(), agentDir?: string): string {
675
966
  if (scope === "user") {
676
- return path.join(getAgentDir(), "mcp.json");
967
+ return path.join(agentDir ?? getAgentDir(), "mcp.json");
677
968
  }
678
969
  return path.join(getProjectAgentDir(cwd), "mcp.json");
679
970
  }
package/src/env-file.ts CHANGED
@@ -110,7 +110,7 @@ export function parseShellEnvFile(filePath: string): Record<string, string> {
110
110
  * `filterCredentialInheritedEnv`) decide provenance by comparing
111
111
  * `process.env` against this parse, so the accepted syntax must be a superset
112
112
  * of what Bun's own dotenv loader honors in `cwd/.env`: `export KEY=value`,
113
- * whitespace around `=`, and `#` comments after unquoted values (quotes keep
113
+ * whitespace around `=` or `:`, and `#` comments after unquoted values (quotes keep
114
114
  * their `#`). Values that Bun would expand (`$VAR`, `${VAR}`, backticks,
115
115
  * command substitution) are kept as their literal text: the trust rule only
116
116
  * needs the parser to see the key at all, and an operator environment value
@@ -134,7 +134,7 @@ export function parseEnvFileContent(content: string): Record<string, string> {
134
134
  // Skip comments and blank lines
135
135
  if (!trimmed || trimmed.startsWith("#")) continue;
136
136
 
137
- const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(trimmed);
137
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|:)\s*(.*)$/.exec(trimmed);
138
138
  if (!match) continue;
139
139
 
140
140
  const key = match[1];
package/src/env.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { getAgentDir, getConfigRootDir } from "./dirs";
4
+ import { canonicalEnvKey, getAgentDir, getConfigRootDir, getTrustedHomeDir } from "./dirs";
5
5
  import { isSafeEnvName, isSafeEnvValue } from "./spawn-env";
6
6
 
7
7
  export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
@@ -11,9 +11,38 @@ import { parseEnvFile, parseEnvFileContent, parseShellEnvFile } from "./env-file
11
11
  // Re-exported so the public surface of this module is unchanged.
12
12
  export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
13
13
 
14
+ function loadProjectEnv(): { values: Record<string, string>; dynamic: Set<string> } {
15
+ const cwd = process.cwd();
16
+ const nodeEnv = process.env.NODE_ENV || Bun.env.NODE_ENV;
17
+ // Match Bun's dotenv precedence. Validate before interpolation so a hostile
18
+ // NODE_ENV cannot introduce separators or `..` path segments.
19
+ const validNodeEnv = nodeEnv && /^[A-Za-z0-9_-]+$/.test(nodeEnv) ? nodeEnv : undefined;
20
+ const files = [
21
+ ".env",
22
+ ...(validNodeEnv ? [`.env.${validNodeEnv}`] : []),
23
+ ...(validNodeEnv !== "test" ? [".env.local"] : []),
24
+ ...(validNodeEnv ? [`.env.${validNodeEnv}.local`] : []),
25
+ ];
26
+ const values: Record<string, string> = {};
27
+ const dynamic = new Set<string>();
28
+ for (const file of files) {
29
+ const parsed = parseEnvFile(path.join(cwd, file));
30
+ for (const [rawKey, value] of Object.entries(parsed)) {
31
+ // Windows environment names are case-insensitive, so the guard lookups
32
+ // below must see the same key Bun loaded into `process.env`.
33
+ const key = canonicalEnvKey(rawKey);
34
+ values[key] = value;
35
+ // Track dynamic provenance only for the winning declaration.
36
+ if (/[$`]/.test(value)) dynamic.add(key);
37
+ else dynamic.delete(key);
38
+ }
39
+ }
40
+ return { values, dynamic };
41
+ }
42
+
14
43
  function resolveFileEnvValue(file: Record<string, string>, name: string): string | undefined {
15
44
  if (!isSafeEnvName(name)) return undefined;
16
- const value = file[name];
45
+ const value = file[canonicalEnvKey(name)];
17
46
  if (value === undefined || !isSafeEnvValue(value)) return undefined;
18
47
  const trimmed = value.trim();
19
48
  return trimmed.length > 0 ? trimmed : undefined;
@@ -25,7 +54,12 @@ type TrustedAgentEnvRead =
25
54
  | { status: "ok"; values: Record<string, string> };
26
55
 
27
56
  function readTrustedAgentEnv(): TrustedAgentEnvRead {
28
- const filePath = path.join(getAgentDir(), ".env");
57
+ let filePath: string;
58
+ try {
59
+ filePath = path.join(getAgentDir(), ".env");
60
+ } catch {
61
+ return { status: "unavailable", values: {} };
62
+ }
29
63
  let fileDescriptor: number | undefined;
30
64
  try {
31
65
  const linkStats = fs.lstatSync(filePath);
@@ -51,30 +85,66 @@ function filterCredentialInheritedEnv(env: Record<string, string | undefined>):
51
85
  if (!isSafeEnvName(key) || value === undefined || !isSafeEnvValue(value)) continue;
52
86
 
53
87
  // Bun may have already loaded cwd/.env before JS runs. It does not expose the
54
- // source of each entry, so an exact match with projectEnv is ambiguous. Use
55
- // the safer credential rule: ambiguous project matches are excluded from the
56
- // credential-only inherited snapshot, while remaining available through $env.
88
+ // source of each entry, so a matching project declaration is ambiguous. A
89
+ // dynamic dotenv declaration is also ambiguous even when expansion changes
90
+ // its runtime value. Exclude those from the credential-only snapshot while
91
+ // keeping them available through $env.
57
92
  const projectValue = resolveFileEnvValue(projectEnv, key);
58
- if (projectValue !== undefined && projectValue === value) continue;
93
+ if (projectValue !== undefined && (projectSnapshot.dynamic.has(canonicalEnvKey(key)) || projectValue === value))
94
+ continue;
59
95
 
60
96
  result[key] = value;
61
97
  }
62
98
  return result;
63
99
  }
64
100
 
65
- // Eagerly parse the user's $HOME/.env and the current project's .env (from cwd)
66
- const homeShellEnv = {
67
- ...parseShellEnvFile(path.join(os.homedir(), ".zshenv")),
68
- ...parseShellEnvFile(path.join(os.homedir(), ".zprofile")),
69
- ...parseShellEnvFile(path.join(os.homedir(), ".zshrc")),
70
- ...parseShellEnvFile(path.join(os.homedir(), ".bash_profile")),
71
- ...parseShellEnvFile(path.join(os.homedir(), ".bashrc")),
72
- };
73
- const homeEnv = parseEnvFile(path.join(os.homedir(), ".env"));
74
- const piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
75
- const agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
76
- const projectEnv = parseEnvFile(path.join(process.cwd(), ".env"));
101
+ // Parse the current project's .env first. Bun may have overlaid HOME from it
102
+ // before this module runs, so a declared HOME must never select user credential
103
+ // files for the credential-only snapshot.
104
+ const projectSnapshot = loadProjectEnv();
105
+ const projectEnv = projectSnapshot.values;
106
+ const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME";
107
+ const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey);
108
+ const declaredHome = projectEnv[declaredHomeKey];
109
+ const runtimeHome = process.env[authoritativeHomeKey];
110
+ const rejectProjectHome =
111
+ declaredHome !== undefined &&
112
+ runtimeHome !== undefined &&
113
+ (projectSnapshot.dynamic.has(declaredHomeKey) || declaredHome === runtimeHome);
114
+ let trustedEnvHome: string | undefined;
115
+ try {
116
+ trustedEnvHome = rejectProjectHome ? getTrustedHomeDir() : os.homedir();
117
+ } catch {
118
+ // No trustworthy account home means no user credential files are trusted.
119
+ trustedEnvHome = undefined;
120
+ }
121
+
122
+ // Eagerly parse the trusted user's env files and the project .env (from cwd)
123
+ const homeShellEnv = trustedEnvHome
124
+ ? {
125
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".zshenv")),
126
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".zprofile")),
127
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".zshrc")),
128
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".bash_profile")),
129
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".bashrc")),
130
+ }
131
+ : {};
132
+ const homeEnv =
133
+ trustedEnvHome && path.resolve(trustedEnvHome) !== path.resolve(process.cwd())
134
+ ? parseEnvFile(path.join(trustedEnvHome, ".env"))
135
+ : {};
136
+ let piEnv: Record<string, string> = {};
137
+ let agentEnv: Record<string, string> = {};
138
+ try {
139
+ piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
140
+ agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
141
+ } catch {
142
+ // Keep credential resolution fail-closed when trusted user state is unavailable.
143
+ }
77
144
  const initialTrustedAgentEnv = readTrustedAgentEnv();
145
+ const projectLoadedEnv: Record<string, string | undefined> = Object.fromEntries(
146
+ Object.keys(projectEnv).map(key => [key, Bun.env[key]]),
147
+ );
78
148
 
79
149
  const inheritedEnv = filterCredentialInheritedEnv(Bun.env);
80
150
  const rotatingAgentEnvNames = new Set(Object.keys(agentEnv));
@@ -100,11 +170,12 @@ function resolveLiveCredentialEnvValue(name: string): string | undefined {
100
170
  const trimmed = value.trim();
101
171
  if (trimmed.length === 0) return undefined;
102
172
 
103
- const projectValue = resolveFileEnvValue(projectEnv, name);
104
173
  if (
105
- projectValue !== undefined &&
106
- projectValue === trimmed &&
107
- resolveFileEnvValue(inheritedEnv, name) === undefined
174
+ Object.hasOwn(projectEnv, canonicalEnvKey(name)) &&
175
+ resolveFileEnvValue(inheritedEnv, name) === undefined &&
176
+ (projectSnapshot.dynamic.has(canonicalEnvKey(name)) ||
177
+ trimmed === resolveFileEnvValue(projectEnv, name) ||
178
+ trimmed === projectLoadedEnv[canonicalEnvKey(name)])
108
179
  ) {
109
180
  return undefined;
110
181
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Sanitize a value destined for an HTTP header.
3
+ *
4
+ * OS-derived components (kernel release, hostname, os.version) can contain
5
+ * non-ASCII characters — e.g. Android kernel releases such as
6
+ * `4.4.302-Minimal™-EAS-QTI_Haptic-R26` — which `Headers`/`fetch` reject
7
+ * before the request is ever sent. Strip everything outside printable
8
+ * ASCII so header construction can never throw on runtime-derived values.
9
+ */
10
+
11
+ const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g;
12
+
13
+ export function sanitizeHeaderComponent(value: string): string {
14
+ return value.replace(NON_PRINTABLE_ASCII, "");
15
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./format";
12
12
  export * from "./frontmatter";
13
13
  export * from "./fs-error";
14
14
  export * from "./glob";
15
+ export * from "./header-value";
15
16
  export * from "./hook-fetch";
16
17
  export * from "./json";
17
18
  export * as logger from "./logger";
package/src/postmortem.ts CHANGED
@@ -177,6 +177,14 @@ function installProcessStdoutWriteClassifier(): void {
177
177
  encoding?: BufferEncoding | StdoutWriteCallback,
178
178
  callback?: StdoutWriteCallback,
179
179
  ): boolean => {
180
+ // Bun 1.4 stopped surfacing a broken stdout pipe as a synchronous throw
181
+ // from `write()`; it now rejects asynchronously (via the write's own
182
+ // callback and an `unhandledRejection`) with an error object that lacks
183
+ // the `fd`/`syscall` fields the fallback attribution path relies on.
184
+ // Always attach an internal callback — even when the caller passed none —
185
+ // so `markDirectProcessStdoutWriteError` still runs before that async
186
+ // rejection reaches `handleFatalError`. The synchronous `try/catch` below
187
+ // stays as a fallback for Bun/Node versions that still throw synchronously.
180
188
  try {
181
189
  if (typeof encoding === "function") return originalWrite(chunk, markCallback(encoding));
182
190
  if (callback) {
@@ -184,8 +192,16 @@ function installProcessStdoutWriteClassifier(): void {
184
192
  ? originalWrite(chunk, encoding, markCallback(callback))
185
193
  : originalWrite(chunk, markCallback(callback));
186
194
  }
187
- if (encoding === undefined) return originalWrite(chunk);
188
- return typeof chunk === "string" ? originalWrite(chunk, encoding) : originalWrite(chunk);
195
+ return typeof chunk === "string"
196
+ ? originalWrite(
197
+ chunk,
198
+ encoding,
199
+ markCallback(() => {}),
200
+ )
201
+ : originalWrite(
202
+ chunk,
203
+ markCallback(() => {}),
204
+ );
189
205
  } catch (error) {
190
206
  stdoutEpipeClassifier.markDirectProcessStdoutWriteError(error);
191
207
  throw error;
@@ -28,6 +28,18 @@ export function sanitizeText(text: string): string {
28
28
  return sanitizeWellFormedText(text);
29
29
  }
30
30
 
31
+ /**
32
+ * Sanitize untrusted text that must occupy exactly one rendered row.
33
+ *
34
+ * {@link sanitizeText} deliberately preserves `\n`, and width-based truncation
35
+ * treats it as zero-width, so a value carrying line breaks can still inject
36
+ * extra rows and evade a single-line width budget. Flatten every CR/LF run to a
37
+ * single space before the usual control/ANSI strip.
38
+ */
39
+ export function sanitizeDisplayLine(text: string): string {
40
+ return sanitizeText(text.replace(/[\r\n]+/gu, " "));
41
+ }
42
+
31
43
  function sanitizeWellFormedText(text: string): string {
32
44
  CONTROL_RE.lastIndex = 0;
33
45
  if (CONTROL_RE.exec(text) === null) return text;