@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.
- package/dist/types/crash-journal.d.ts +9 -1
- package/dist/types/dirs.d.ts +42 -18
- package/dist/types/env-file.d.ts +1 -1
- package/dist/types/header-value.d.ts +10 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/sanitize-text.d.ts +9 -0
- package/package.json +3 -3
- package/src/crash-journal.ts +32 -5
- package/src/dirs.ts +398 -107
- package/src/env-file.ts +2 -2
- package/src/env.ts +94 -23
- package/src/header-value.ts +15 -0
- package/src/index.ts +1 -0
- package/src/postmortem.ts +18 -2
- package/src/sanitize-text.ts +12 -0
|
@@ -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;
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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
|
-
/**
|
|
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
|
|
84
|
-
*
|
|
85
|
-
* and returns `<home>/<configDir>/plugins
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
/**
|
|
193
|
-
|
|
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;
|
package/dist/types/env-file.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
44
|
+
"bun": ">=1.4.0"
|
|
45
45
|
},
|
|
46
46
|
"files": [
|
|
47
47
|
"src",
|
package/src/crash-journal.ts
CHANGED
|
@@ -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 === "
|
|
113
|
-
? {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
149
|
-
process.chdir(
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
202
|
-
sanitizeConfigDirName(
|
|
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
|
-
/**
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
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 =
|
|
251
|
-
if (value)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
410
|
-
*
|
|
411
|
-
* and returns `<home>/<configDir>/plugins
|
|
412
|
-
*
|
|
413
|
-
*
|
|
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
|
|
417
|
-
|
|
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
|
-
/**
|
|
674
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
55
|
-
//
|
|
56
|
-
// credential-only
|
|
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)
|
|
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
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
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
|
-
|
|
188
|
-
|
|
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;
|
package/src/sanitize-text.ts
CHANGED
|
@@ -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;
|