@sayknow-cli/utils 0.7.11 → 0.7.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/broken-pipe.d.ts +4 -3
- package/dist/types/canonical-log-dir.d.ts +20 -0
- package/dist/types/cli-metadata.d.ts +33 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/crash-fingerprint.d.ts +82 -0
- package/dist/types/crash-journal.d.ts +84 -0
- package/dist/types/crash-redaction.d.ts +20 -0
- package/dist/types/dirs.d.ts +127 -36
- package/dist/types/env-file.d.ts +59 -0
- package/dist/types/env.d.ts +19 -1
- package/dist/types/error-classification.d.ts +12 -0
- package/dist/types/fetch-retry.d.ts +6 -3
- package/dist/types/header-value.d.ts +10 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/logger.d.ts +22 -8
- package/dist/types/native-process.d.ts +12 -0
- package/dist/types/procmgr.d.ts +1 -35
- package/dist/types/safe-error.d.ts +20 -0
- package/dist/types/sanitize-text.d.ts +10 -0
- package/dist/types/shell-config.d.ts +58 -0
- package/dist/types/stream.d.ts +2 -1
- package/dist/types/which.d.ts +1 -1
- package/package.json +2 -2
- package/src/broken-pipe.ts +16 -4
- package/src/canonical-log-dir.ts +59 -0
- package/src/cli-metadata.ts +74 -0
- package/src/cli.ts +30 -2
- package/src/crash-fingerprint.ts +294 -0
- package/src/crash-journal.ts +292 -0
- package/src/crash-redaction.ts +558 -0
- package/src/dirs.ts +583 -165
- package/src/env-file.ts +326 -20
- package/src/env.ts +321 -35
- package/src/error-classification.ts +31 -0
- package/src/fetch-retry.ts +23 -10
- package/src/frontmatter.ts +28 -2
- package/src/glob.ts +13 -10
- package/src/header-value.ts +15 -0
- package/src/index.ts +6 -0
- package/src/logger.ts +252 -97
- package/src/native-process.ts +19 -0
- package/src/postmortem.ts +1 -55
- package/src/procmgr.ts +16 -212
- package/src/ptree.ts +3 -2
- package/src/safe-error.ts +40 -0
- package/src/sanitize-text.ts +14 -0
- package/src/shell-config.ts +329 -0
- package/src/stream.ts +32 -1
- package/src/which.ts +6 -5
package/src/dirs.ts
CHANGED
|
@@ -5,83 +5,37 @@
|
|
|
5
5
|
* config root and SKC_CODING_AGENT_DIR (legacy alias PI_CODING_AGENT_DIR) to
|
|
6
6
|
* override the agent directory.
|
|
7
7
|
*
|
|
8
|
-
* On Linux,
|
|
9
|
-
*
|
|
10
|
-
* $XDG_*_HOME/skc
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* On Linux and macOS, a category (data, state, cache) is redirected to
|
|
9
|
+
* $XDG_*_HOME/skc/ only when the trusted XDG_*_HOME variable is set, the
|
|
10
|
+
* $XDG_*_HOME/skc directory already exists (`skc config init-xdg` creates it),
|
|
11
|
+
* and the agent directory is the default. Otherwise paths stay under the config
|
|
12
|
+
* root. `init-xdg` only creates the directories; existing data is not moved.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import * as os from "node:os";
|
|
17
17
|
import * as path from "node:path";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
18
|
+
import { resolveCanonicalLogsDir } from "./canonical-log-dir";
|
|
19
|
+
import { APP_NAME } from "./cli-metadata";
|
|
20
|
+
import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "./env-file";
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Entry metadata lives in the pure `cli-metadata` leaf so the actual CLI entry can
|
|
24
|
+
* import it without loading this module's load-time directory resolution. These
|
|
25
|
+
* re-exports keep the existing `@sayknow-cli/utils/dirs` surface unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export { APP_NAME, formatBunRuntimeError, MIN_BUN_VERSION, VERSION } from "./cli-metadata";
|
|
28
|
+
// The provenance snapshot and its key fold live in the leaf `env-file` module so
|
|
29
|
+
// `scripts/test-preload.ts` can share this exact logic without importing this
|
|
30
|
+
// module (whose load-time resolver construction would freeze state before the
|
|
31
|
+
// preload sets its isolation variables). Re-exported here because `env.ts` and
|
|
32
|
+
// `test/env-provenance.windows.test.ts` import `canonicalEnvKey` from this
|
|
33
|
+
// module's public surface.
|
|
34
|
+
export { canonicalEnvKey };
|
|
23
35
|
|
|
24
36
|
/** Config directory name (e.g. ".skc") */
|
|
25
37
|
export const CONFIG_DIR_NAME: string = ".skc";
|
|
26
38
|
|
|
27
|
-
/** Version (e.g. "1.0.0") */
|
|
28
|
-
export const VERSION: string = version;
|
|
29
|
-
|
|
30
|
-
/** Minimum Bun version */
|
|
31
|
-
export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, "");
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Build the diagnostic shown when the Bun runtime executing `skc` is older
|
|
35
|
-
* than {@link MIN_BUN_VERSION}. This is the most common Windows native-install
|
|
36
|
-
* failure (issue #525): `bun install -g sayknow-cli` probes a recent Bun while
|
|
37
|
-
* the `skc` launcher resolves an older Bun still on PATH. The message names the
|
|
38
|
-
* exact detected runtime path and gives a platform-specific upgrade + PATH fix
|
|
39
|
-
* instead of a bare `bun upgrade`.
|
|
40
|
-
*
|
|
41
|
-
* Pure and platform-parameterized so it can be unit-tested cross-platform.
|
|
42
|
-
*/
|
|
43
|
-
export function formatBunRuntimeError(opts: {
|
|
44
|
-
currentVersion: string;
|
|
45
|
-
minVersion: string;
|
|
46
|
-
execPath?: string;
|
|
47
|
-
platform?: NodeJS.Platform;
|
|
48
|
-
}): string {
|
|
49
|
-
const platform = opts.platform ?? process.platform;
|
|
50
|
-
const lines = [
|
|
51
|
-
`error: ${APP_NAME} requires Bun >= ${opts.minVersion}, but the running Bun is v${opts.currentVersion}.`,
|
|
52
|
-
];
|
|
53
|
-
if (opts.execPath) {
|
|
54
|
-
lines.push(` detected Bun runtime: ${opts.execPath}`);
|
|
55
|
-
}
|
|
56
|
-
if (platform === "win32") {
|
|
57
|
-
lines.push(
|
|
58
|
-
"",
|
|
59
|
-
"The 'skc' launcher is using an older Bun than the one used to install it.",
|
|
60
|
-
"Upgrade Bun, then restart your terminal so PATH and the runtime refresh:",
|
|
61
|
-
"",
|
|
62
|
-
' powershell -c "irm bun.sh/install.ps1|iex"',
|
|
63
|
-
"",
|
|
64
|
-
"After restarting the terminal, verify both versions match:",
|
|
65
|
-
" bun --version",
|
|
66
|
-
" skc --version",
|
|
67
|
-
"",
|
|
68
|
-
"If 'skc' still loads the old runtime, make sure %USERPROFILE%\\.bun\\bin is",
|
|
69
|
-
"first on PATH and remove any stale Bun installs shadowing it.",
|
|
70
|
-
);
|
|
71
|
-
} else {
|
|
72
|
-
lines.push(
|
|
73
|
-
"",
|
|
74
|
-
"Upgrade Bun, then restart your terminal:",
|
|
75
|
-
" bun upgrade",
|
|
76
|
-
"",
|
|
77
|
-
"Then verify:",
|
|
78
|
-
" bun --version",
|
|
79
|
-
" skc --version",
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
return `${lines.join("\n")}\n`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
39
|
// =============================================================================
|
|
86
40
|
// Project directory
|
|
87
41
|
// =============================================================================
|
|
@@ -90,7 +44,7 @@ export function formatBunRuntimeError(opts: {
|
|
|
90
44
|
* On macOS, strip /private prefix only when both paths resolve to the same location.
|
|
91
45
|
* This preserves aliases like /private/tmp -> /tmp without rewriting unrelated paths.
|
|
92
46
|
*/
|
|
93
|
-
function standardizeMacOSPath(p: string): string {
|
|
47
|
+
export function standardizeMacOSPath(p: string): string {
|
|
94
48
|
if (process.platform !== "darwin" || !p.startsWith("/private/")) return p;
|
|
95
49
|
const stripped = p.slice("/private".length);
|
|
96
50
|
try {
|
|
@@ -110,6 +64,25 @@ export function resolveEquivalentPath(inputPath: string): string {
|
|
|
110
64
|
}
|
|
111
65
|
}
|
|
112
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Return a stable key for comparing existing filesystem paths.
|
|
69
|
+
*
|
|
70
|
+
* Windows path spelling is not a safe identity: most directories are
|
|
71
|
+
* case-insensitive, while directories can opt into case-sensitive names. Use
|
|
72
|
+
* the filesystem object's identity when it is available instead of folding
|
|
73
|
+
* path text. If the path does not exist or the filesystem has no usable inode,
|
|
74
|
+
* retain the resolved spelling so distinct case-sensitive paths never alias.
|
|
75
|
+
*/
|
|
76
|
+
export function pathIdentityKey(inputPath: string): string {
|
|
77
|
+
const resolvedPath = resolveEquivalentPath(inputPath);
|
|
78
|
+
if (process.platform !== "win32") return resolvedPath;
|
|
79
|
+
try {
|
|
80
|
+
const stats = fs.statSync(resolvedPath, { bigint: true });
|
|
81
|
+
if (stats.ino !== 0n) return JSON.stringify(["win32-file-id", stats.dev.toString(), stats.ino.toString()]);
|
|
82
|
+
} catch {}
|
|
83
|
+
return resolvedPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
113
86
|
export function normalizePathForComparison(inputPath: string, platform: NodeJS.Platform = process.platform): string {
|
|
114
87
|
const pathApi = platform === "win32" ? path.win32 : path;
|
|
115
88
|
const resolvedPath = platform === process.platform ? resolveEquivalentPath(inputPath) : pathApi.resolve(inputPath);
|
|
@@ -145,8 +118,9 @@ export function getProjectDir(): string {
|
|
|
145
118
|
|
|
146
119
|
/** Set the project directory. */
|
|
147
120
|
export function setProjectDir(dir: string): void {
|
|
148
|
-
|
|
149
|
-
process.chdir(
|
|
121
|
+
const resolved = standardizeMacOSPath(path.resolve(dir));
|
|
122
|
+
process.chdir(resolved);
|
|
123
|
+
projectDir = standardizeMacOSPath(process.cwd());
|
|
150
124
|
}
|
|
151
125
|
|
|
152
126
|
/**
|
|
@@ -169,9 +143,9 @@ function sanitizeConfigDirName(value: string | undefined): string | undefined {
|
|
|
169
143
|
return trimmed;
|
|
170
144
|
}
|
|
171
145
|
|
|
172
|
-
/** Get the config directory name relative to home (e.g. ".skc" or PI_CONFIG_DIR override). */
|
|
173
146
|
/**
|
|
174
|
-
*
|
|
147
|
+
* Resolve an environment value only when it is not supplied by the caller's
|
|
148
|
+
* project dotenv (or when the inherited value is observably distinct).
|
|
175
149
|
*
|
|
176
150
|
* The name is joined with the home directory to build the config root, and that
|
|
177
151
|
* root plus the agent directory beneath it supply two of the `.env` files
|
|
@@ -181,28 +155,190 @@ function sanitizeConfigDirName(value: string | undefined): string | undefined {
|
|
|
181
155
|
* recovering every endpoint and credential redirect the boundary rejects.
|
|
182
156
|
*
|
|
183
157
|
* `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
|
|
184
|
-
* it applies the same conservative ambiguity rule directly
|
|
185
|
-
* `
|
|
158
|
+
* it applies the same conservative ambiguity rule directly: a value that matches
|
|
159
|
+
* what the project `.env` sets is not honoured. An operator whose environment
|
|
160
|
+
* happens to carry the identical value loses the override, which is the same
|
|
161
|
+
* trade-off `resolveLiveCredentialEnvValue` already makes.
|
|
186
162
|
*/
|
|
187
|
-
function
|
|
163
|
+
function trustedValue(name: string, project: ProjectEnvSnapshot): string | undefined {
|
|
188
164
|
const value = process.env[name];
|
|
189
165
|
if (!value) return undefined;
|
|
190
|
-
|
|
166
|
+
const key = canonicalEnvKey(name);
|
|
167
|
+
const projectValue = project.values[key];
|
|
168
|
+
if (projectValue !== undefined && (project.dynamic.has(key) || projectValue === value)) return undefined;
|
|
191
169
|
return value;
|
|
192
170
|
}
|
|
193
171
|
|
|
194
|
-
|
|
195
|
-
// Both guards apply: the value must come from a trusted source (not the
|
|
196
|
-
// caller's project `.env`), and it must still be a single name that stays
|
|
197
|
-
// beneath home once joined.
|
|
172
|
+
function resolveConfigDirName(project: ProjectEnvSnapshot): string {
|
|
198
173
|
return (
|
|
199
|
-
sanitizeConfigDirName(
|
|
200
|
-
sanitizeConfigDirName(
|
|
174
|
+
sanitizeConfigDirName(trustedValue("SKC_CONFIG_DIR", project)) ??
|
|
175
|
+
sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", project)) ??
|
|
201
176
|
CONFIG_DIR_NAME
|
|
202
177
|
);
|
|
203
178
|
}
|
|
204
179
|
|
|
205
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* A home directory is usable only when it is absolute and resolves to somewhere
|
|
182
|
+
* strictly below a filesystem root. A relative value would anchor user state
|
|
183
|
+
* beneath whatever the current directory happens to be, and a root would place
|
|
184
|
+
* it at `/.skc`.
|
|
185
|
+
*
|
|
186
|
+
* The root test normalizes first, because a root has many spellings: `/.`, `//`,
|
|
187
|
+
* `/foo/..` and `C:\x\..` are all roots that a raw string comparison against
|
|
188
|
+
* `path.parse(home).root` misses, and `path.join(home, ".skc")` would happily
|
|
189
|
+
* produce `/.skc` from every one of them.
|
|
190
|
+
*
|
|
191
|
+
* The **original spelling** is returned, never the normalized form. Provenance
|
|
192
|
+
* compares the declared dotenv value against this result, and both sides must
|
|
193
|
+
* stay in the same spelling: canonicalizing only this side would make
|
|
194
|
+
* `HOME=/tmp/base/../attacker` compare unequal to its own declaration and let a
|
|
195
|
+
* project-planted home through as if it were operator-supplied.
|
|
196
|
+
*/
|
|
197
|
+
function usableHome(home: string | undefined): string | undefined {
|
|
198
|
+
if (!home || !path.isAbsolute(home)) return undefined;
|
|
199
|
+
const normalized = path.resolve(home);
|
|
200
|
+
return normalized === path.parse(normalized).root ? undefined : home;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The account home for the running uid, read through the operating system's own
|
|
205
|
+
* account database.
|
|
206
|
+
*
|
|
207
|
+
* On Linux this must go through NSS rather than parsing `/etc/passwd`: LDAP and
|
|
208
|
+
* SSSD accounts have no local passwd entry, and a direct file read would miss
|
|
209
|
+
* them and fall through to an environment-derived value. `getent passwd` is the
|
|
210
|
+
* NSS front end, so it resolves local and directory-backed accounts alike.
|
|
211
|
+
*
|
|
212
|
+
* Only an **environment-independent** result is memoized, and only on success.
|
|
213
|
+
* The cache is keyed by the effective account identity, not by process lifetime:
|
|
214
|
+
* a setuid or container identity transition must never reuse another uid's home.
|
|
215
|
+
* The NSS answer cannot change during one identity's process lifetime, so
|
|
216
|
+
* per-identity caching is safe.
|
|
217
|
+
* The `os.userInfo()` fallback is different: Bun derives `homedir` from `$HOME`,
|
|
218
|
+
* so caching it would freeze one side of the independence comparison in
|
|
219
|
+
* {@link resolveTrustedHome}. A planted home that was live at first resolution
|
|
220
|
+
* would stay cached, and once the runtime home moved it would no longer *equal*
|
|
221
|
+
* the runtime home -- passing the echo check and being promoted to independent
|
|
222
|
+
* evidence. Provenance is carried with the value so that can never happen.
|
|
223
|
+
*/
|
|
224
|
+
type AccountHome = { home: string; envDerived: boolean };
|
|
225
|
+
type AccountIdentity = { key: string; uid: number };
|
|
226
|
+
|
|
227
|
+
const accountHomeCache = new Map<string, AccountHome>();
|
|
228
|
+
|
|
229
|
+
function accountIdentity(info: os.UserInfo<string>): AccountIdentity {
|
|
230
|
+
const uid = process.platform === "win32" ? info.uid : (process.geteuid?.() ?? info.uid);
|
|
231
|
+
return {
|
|
232
|
+
key: `${process.platform}:uid=${uid}:user=${info.username}`,
|
|
233
|
+
uid,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The uid's home field from the NSS account database, or undefined. */
|
|
238
|
+
function nssAccountHome(uid: number): string | undefined {
|
|
239
|
+
try {
|
|
240
|
+
// Spawned with an empty environment so nothing the caller controls (HOME,
|
|
241
|
+
// NSS module configuration, locale) can steer the answer.
|
|
242
|
+
const result = Bun.spawnSync({
|
|
243
|
+
cmd: ["getent", "passwd", String(uid)],
|
|
244
|
+
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" },
|
|
245
|
+
stdout: "pipe",
|
|
246
|
+
stderr: "ignore",
|
|
247
|
+
});
|
|
248
|
+
if (result.exitCode !== 0) return undefined;
|
|
249
|
+
// `getent` echoes passwd-format records; the home directory is field 6.
|
|
250
|
+
const line = new TextDecoder().decode(result.stdout).split("\n")[0];
|
|
251
|
+
return usableHome(line?.split(":")[5]);
|
|
252
|
+
} catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function accountHomeFromSystem(): AccountHome | undefined {
|
|
258
|
+
try {
|
|
259
|
+
const info = os.userInfo();
|
|
260
|
+
const identity = accountIdentity(info);
|
|
261
|
+
const cached = accountHomeCache.get(identity.key);
|
|
262
|
+
if (cached !== undefined) return cached;
|
|
263
|
+
if (process.platform === "linux") {
|
|
264
|
+
const nss = nssAccountHome(identity.uid);
|
|
265
|
+
if (nss !== undefined) {
|
|
266
|
+
// NSS is environment-independent and stable: safe to memoize.
|
|
267
|
+
const result = { home: nss, envDerived: false };
|
|
268
|
+
accountHomeCache.set(identity.key, result);
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// `os.userInfo().homedir` is the portable path for macOS and Windows, and on
|
|
273
|
+
// Linux is reached only when NSS is unavailable. Bun derives it from `$HOME`,
|
|
274
|
+
// so it is re-read every time and never cached, and it is flagged so the
|
|
275
|
+
// caller can refuse to treat it as independent evidence.
|
|
276
|
+
const fallback = usableHome(info.homedir);
|
|
277
|
+
if (fallback !== undefined) return { home: fallback, envDerived: true };
|
|
278
|
+
} catch {
|
|
279
|
+
// Do not retain or consult a prior identity's result when the current
|
|
280
|
+
// identity cannot be observed. An unavailable uid is not evidence for any
|
|
281
|
+
// other uid and must fail closed instead of inheriting stale state.
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve the authoritative home for user-scope state.
|
|
289
|
+
*
|
|
290
|
+
* Two properties must hold together, and pinning either one alone breaks the
|
|
291
|
+
* other (issue #4761):
|
|
292
|
+
*
|
|
293
|
+
* 1. **Provenance.** Bun overlays a checkout's `.env` into `process.env` before
|
|
294
|
+
* any module runs, so a repository can plant HOME/USERPROFILE and redirect
|
|
295
|
+
* user state — including the `.env` files `$credentialEnv` treats as trusted.
|
|
296
|
+
* When the platform-authoritative variable is indistinguishable from the
|
|
297
|
+
* value the project dotenv declares, the OS account database wins instead.
|
|
298
|
+
* 2. **Call-time resolution.** The trusted home is *derived*, never snapshotted
|
|
299
|
+
* at module load. A resolution frozen at import silently loses every
|
|
300
|
+
* user-scope location whenever the runtime home is established or changed
|
|
301
|
+
* after this module initializes — which is exactly how user-scope skill and
|
|
302
|
+
* MCP discovery regressed.
|
|
303
|
+
*
|
|
304
|
+
* `os.homedir()` is the runtime candidate: on POSIX it reflects HOME, on Windows
|
|
305
|
+
* USERPROFILE, and it falls back to the account database on its own. Reading it
|
|
306
|
+
* per call is what makes the contract call-time; the provenance comparison above
|
|
307
|
+
* is what keeps an untrusted mutable home from being honored.
|
|
308
|
+
*/
|
|
309
|
+
function resolveTrustedHome(project: ProjectEnvSnapshot): string {
|
|
310
|
+
const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME";
|
|
311
|
+
const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey);
|
|
312
|
+
const declaredHome = project.values[declaredHomeKey];
|
|
313
|
+
// A relative or filesystem-root runtime home would anchor user state beneath
|
|
314
|
+
// the current directory (or at `/`), so it is not a usable candidate no matter
|
|
315
|
+
// how it was supplied. Validate it exactly as the account home is validated.
|
|
316
|
+
const runtimeHome = usableHome(os.homedir());
|
|
317
|
+
// Only the platform-authoritative variable can select the home. In particular,
|
|
318
|
+
// do not let the opposite platform variable (or a project dotenv value
|
|
319
|
+
// overlaid into it) redirect user state when this is absent.
|
|
320
|
+
const ambiguousHome =
|
|
321
|
+
declaredHome !== undefined && (project.dynamic.has(declaredHomeKey) || declaredHome === runtimeHome);
|
|
322
|
+
// The account lookup is consulted lazily. It can spawn the NSS front end, and
|
|
323
|
+
// this resolver runs on every directory access, so an unambiguous runtime home
|
|
324
|
+
// -- the ordinary CLI path -- must never pay for it.
|
|
325
|
+
if (!ambiguousHome && runtimeHome !== undefined) return runtimeHome;
|
|
326
|
+
|
|
327
|
+
const accountHome = accountHomeFromSystem();
|
|
328
|
+
if (ambiguousHome) {
|
|
329
|
+
// The account home is independent evidence only when it is not itself derived
|
|
330
|
+
// from the environment. An `os.userInfo()` fallback echoes `$HOME`, so a
|
|
331
|
+
// project-declared home would otherwise come back as its own justification.
|
|
332
|
+
// Fail closed: with no independent evidence the resolver yields a filesystem
|
|
333
|
+
// root, which `#homeAvailable` rejects, rather than honoring the declared
|
|
334
|
+
// home. Issue #4773 owns widening that fallback; do not weaken it here.
|
|
335
|
+
if (accountHome === undefined || accountHome.envDerived) return path.parse(process.cwd()).root;
|
|
336
|
+
return accountHome.home;
|
|
337
|
+
}
|
|
338
|
+
// No usable runtime home: fall back to whatever the account database reports.
|
|
339
|
+
if (accountHome !== undefined) return accountHome.home;
|
|
340
|
+
throw new Error("Unable to determine a trustworthy account home directory");
|
|
341
|
+
}
|
|
206
342
|
export function getConfigAgentDirName(): string {
|
|
207
343
|
return `${getConfigDirName()}/agent`;
|
|
208
344
|
}
|
|
@@ -218,64 +354,158 @@ type XdgCategory = "data" | "state" | "cache";
|
|
|
218
354
|
* variables are set, paths are redirected under $XDG_*_HOME/skc/. A new
|
|
219
355
|
* instance is created whenever the agent directory changes, which naturally
|
|
220
356
|
* invalidates all cached paths.
|
|
357
|
+
*
|
|
358
|
+
* The trusted home is re-derived on each access (see {@link resolveTrustedHome})
|
|
359
|
+
* and every cached path is rebuilt when it changes, so a home established or
|
|
360
|
+
* mocked after module load is honored without weakening the provenance rule.
|
|
221
361
|
*/
|
|
222
362
|
class DirResolver {
|
|
223
|
-
|
|
224
|
-
|
|
363
|
+
configRoot: string;
|
|
364
|
+
agentDir: string;
|
|
365
|
+
readonly #projectEnv: ProjectEnvSnapshot;
|
|
366
|
+
#configDirName: string;
|
|
367
|
+
readonly #agentDirOverride: boolean;
|
|
368
|
+
#trustedHome: string;
|
|
369
|
+
/**
|
|
370
|
+
* Whether this resolver's agent directory may follow `$XDG_*_HOME`, decided
|
|
371
|
+
* once at construction and never re-derived from the path afterwards.
|
|
372
|
+
*/
|
|
373
|
+
#xdgEligible: boolean;
|
|
225
374
|
|
|
226
375
|
// Per-category base dirs. Without XDG, all three equal configRoot / agentDir.
|
|
227
376
|
// With XDG on Linux, they point to $XDG_*_HOME/skc/.
|
|
228
|
-
|
|
229
|
-
|
|
377
|
+
#rootDirs: Record<XdgCategory, string>;
|
|
378
|
+
#defaultRootDirs: Record<XdgCategory, string>;
|
|
379
|
+
#agentDirs: Record<XdgCategory, string>;
|
|
230
380
|
|
|
231
381
|
readonly #rootCache = new Map<string, string>();
|
|
382
|
+
readonly #defaultRootCache = new Map<string, string>();
|
|
232
383
|
readonly #agentCache = new Map<string, string>();
|
|
233
384
|
|
|
234
|
-
constructor(agentDirOverride?: string) {
|
|
235
|
-
this
|
|
385
|
+
constructor(agentDirOverride?: string, snapshot = projectEnvSnapshot()) {
|
|
386
|
+
this.#projectEnv = snapshot;
|
|
387
|
+
this.#configDirName = resolveConfigDirName(snapshot);
|
|
388
|
+
this.#trustedHome = resolveTrustedHome(snapshot);
|
|
389
|
+
this.configRoot = path.join(this.#trustedHome, this.#configDirName);
|
|
236
390
|
|
|
237
391
|
const defaultAgent = path.join(this.configRoot, "agent");
|
|
392
|
+
this.#agentDirOverride = Boolean(agentDirOverride);
|
|
238
393
|
this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent;
|
|
239
|
-
|
|
394
|
+
// An agent directory equal to the home-derived default *is* the default
|
|
395
|
+
// profile, XDG categories included, however it arrived.
|
|
396
|
+
//
|
|
397
|
+
// Deciding this from override state instead was tried and reverted: it is
|
|
398
|
+
// unobservably wrong. `setAgentDir()` exports `SKC_CODING_AGENT_DIR`, so a
|
|
399
|
+
// child process inherits the same value the parent set programmatically and
|
|
400
|
+
// cannot tell the two apart. Treating the inherited form as "not default"
|
|
401
|
+
// put parent and child on different storage lanes for one logical profile --
|
|
402
|
+
// the parent reading `$XDG_STATE_HOME/skc/python-gateway` while the child
|
|
403
|
+
// read `<agentDir>/python-gateway`. Splitting a live store in half is worse
|
|
404
|
+
// than the narrower complaint it was meant to answer.
|
|
405
|
+
const isDefault = normalizePathForComparison(this.agentDir) === normalizePathForComparison(defaultAgent);
|
|
406
|
+
// That decision is then *sticky*. Recomputing it later from path shape is
|
|
407
|
+
// what let a pinned agent directory silently change storage lane when a home
|
|
408
|
+
// refresh made it coincide with the new default: `getAgentDir()` looked
|
|
409
|
+
// unchanged while `agent.db` moved into `$XDG_DATA_HOME/skc`.
|
|
410
|
+
this.#xdgEligible = isDefault;
|
|
411
|
+
|
|
412
|
+
this.#rootDirs = { data: this.configRoot, state: this.configRoot, cache: this.configRoot };
|
|
413
|
+
this.#defaultRootDirs = { data: this.configRoot, state: this.configRoot, cache: this.configRoot };
|
|
414
|
+
this.#agentDirs = { data: this.agentDir, state: this.agentDir, cache: this.agentDir };
|
|
415
|
+
this.refreshCategoryDirs(snapshot, isDefault);
|
|
416
|
+
}
|
|
240
417
|
|
|
241
|
-
|
|
242
|
-
|
|
418
|
+
get profileAuthority(): "default" | "custom" {
|
|
419
|
+
return this.#xdgEligible ? "default" : "custom";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* `isDefault` decides whether the agent directory may follow `$XDG_*_HOME`.
|
|
424
|
+
*
|
|
425
|
+
* It is always supplied by the caller and never defaulted: the only correct
|
|
426
|
+
* value is the construction-time decision held in `#xdgEligible`, and
|
|
427
|
+
* re-deriving it from path shape is exactly the bug that let a directory
|
|
428
|
+
* change storage lane when a home refresh made its path coincide with the
|
|
429
|
+
* new default.
|
|
430
|
+
*/
|
|
431
|
+
private refreshCategoryDirs(snapshot: ProjectEnvSnapshot, isDefault: boolean): void {
|
|
243
432
|
let xdgData: string | undefined;
|
|
244
433
|
let xdgState: string | undefined;
|
|
245
434
|
let xdgCache: string | undefined;
|
|
246
|
-
if (
|
|
435
|
+
if (process.platform === "linux" || process.platform === "darwin") {
|
|
247
436
|
const resolveIf = (envVar: string) => {
|
|
248
|
-
const value =
|
|
249
|
-
if (value)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
} catch {}
|
|
437
|
+
const value = trustedValue(envVar, snapshot);
|
|
438
|
+
if (!value) return undefined;
|
|
439
|
+
try {
|
|
440
|
+
const joined = path.join(value, APP_NAME);
|
|
441
|
+
return fs.existsSync(joined) ? joined : undefined;
|
|
442
|
+
} catch {
|
|
443
|
+
return undefined;
|
|
256
444
|
}
|
|
257
|
-
return undefined;
|
|
258
445
|
};
|
|
259
446
|
xdgData = resolveIf("XDG_DATA_HOME");
|
|
260
447
|
xdgState = resolveIf("XDG_STATE_HOME");
|
|
261
448
|
xdgCache = resolveIf("XDG_CACHE_HOME");
|
|
262
449
|
}
|
|
263
|
-
|
|
264
|
-
this.#rootDirs = {
|
|
450
|
+
this.#defaultRootDirs = {
|
|
265
451
|
data: xdgData ?? this.configRoot,
|
|
266
452
|
state: xdgState ?? this.configRoot,
|
|
267
453
|
cache: xdgCache ?? this.configRoot,
|
|
268
454
|
};
|
|
269
|
-
|
|
455
|
+
this.#rootDirs = {
|
|
456
|
+
data: isDefault ? (xdgData ?? this.configRoot) : this.configRoot,
|
|
457
|
+
state: isDefault ? (xdgState ?? this.configRoot) : this.configRoot,
|
|
458
|
+
cache: isDefault ? (xdgCache ?? this.configRoot) : this.configRoot,
|
|
459
|
+
};
|
|
270
460
|
this.#agentDirs = {
|
|
271
|
-
data: xdgData ?? this.agentDir,
|
|
272
|
-
state: xdgState ?? this.agentDir,
|
|
273
|
-
cache: xdgCache ?? this.agentDir,
|
|
461
|
+
data: isDefault ? (xdgData ?? this.agentDir) : this.agentDir,
|
|
462
|
+
state: isDefault ? (xdgState ?? this.agentDir) : this.agentDir,
|
|
463
|
+
cache: isDefault ? (xdgCache ?? this.agentDir) : this.agentDir,
|
|
274
464
|
};
|
|
275
465
|
}
|
|
276
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Re-derive the trusted home and the caller-supplied config-dir override
|
|
469
|
+
* without replacing the trust snapshot.
|
|
470
|
+
*
|
|
471
|
+
* Both inputs are call-time: the home comes from {@link resolveTrustedHome}
|
|
472
|
+
* (provenance-checked, never an import-time snapshot) and the config-dir name
|
|
473
|
+
* from the trusted-value rule. When either changes, the config root, the
|
|
474
|
+
* default agent dir, the XDG category dirs and both path caches are rebuilt
|
|
475
|
+
* so reads and writes cannot straddle two different homes.
|
|
476
|
+
*/
|
|
477
|
+
refreshConfigDirOverride(): void {
|
|
478
|
+
const nextConfigDirName = resolveConfigDirName(this.#projectEnv);
|
|
479
|
+
const nextHome = resolveTrustedHome(this.#projectEnv);
|
|
480
|
+
if (nextConfigDirName === this.#configDirName && nextHome === this.#trustedHome) return;
|
|
481
|
+
const nextConfigRoot = path.join(nextHome, nextConfigDirName);
|
|
482
|
+
const nextAgentDir = this.#agentDirOverride ? this.agentDir : path.join(nextConfigRoot, "agent");
|
|
483
|
+
this.#trustedHome = nextHome;
|
|
484
|
+
this.#configDirName = nextConfigDirName;
|
|
485
|
+
this.configRoot = nextConfigRoot;
|
|
486
|
+
this.agentDir = nextAgentDir;
|
|
487
|
+
// Reuse the construction-time decision rather than re-deriving it, so an
|
|
488
|
+
// agent directory never changes storage lane just because a home refresh made
|
|
489
|
+
// its path coincide with (or diverge from) the new default.
|
|
490
|
+
this.refreshCategoryDirs(this.#projectEnv, this.#xdgEligible);
|
|
491
|
+
this.#rootCache.clear();
|
|
492
|
+
this.#defaultRootCache.clear();
|
|
493
|
+
this.#agentCache.clear();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Whether the resolved home is a real directory rather than a filesystem root. */
|
|
497
|
+
get #homeAvailable(): boolean {
|
|
498
|
+
return this.#trustedHome !== path.parse(this.#trustedHome).root;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
isProjectEnvDeclaration(name: string): boolean {
|
|
502
|
+
return Object.hasOwn(this.#projectEnv.values, canonicalEnvKey(name));
|
|
503
|
+
}
|
|
504
|
+
|
|
277
505
|
/** Config-root subdirectory, with optional XDG override. */
|
|
278
506
|
rootSubdir(subdir: string, xdg?: XdgCategory): string {
|
|
507
|
+
this.refreshConfigDirOverride();
|
|
508
|
+
if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
|
|
279
509
|
const cached = this.#rootCache.get(subdir);
|
|
280
510
|
if (cached) return cached;
|
|
281
511
|
const base = xdg ? this.#rootDirs[xdg] : this.configRoot;
|
|
@@ -286,7 +516,9 @@ class DirResolver {
|
|
|
286
516
|
|
|
287
517
|
/** Agent subdirectory, with optional XDG override. */
|
|
288
518
|
agentSubdir(userAgentDir: string | undefined, subdir: string, xdg?: XdgCategory): string {
|
|
289
|
-
|
|
519
|
+
this.refreshConfigDirOverride();
|
|
520
|
+
if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
|
|
521
|
+
if (!userAgentDir || normalizePathForComparison(userAgentDir) === normalizePathForComparison(this.agentDir)) {
|
|
290
522
|
const cached = this.#agentCache.get(subdir);
|
|
291
523
|
if (cached) return cached;
|
|
292
524
|
const base = xdg ? this.#agentDirs[xdg] : this.agentDir;
|
|
@@ -296,72 +528,137 @@ class DirResolver {
|
|
|
296
528
|
}
|
|
297
529
|
return path.join(userAgentDir, subdir);
|
|
298
530
|
}
|
|
299
|
-
}
|
|
300
531
|
|
|
301
|
-
/**
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
532
|
+
/** Config-root subdirectory for the resolver-owned default profile. */
|
|
533
|
+
rootSubdirForDefaultProfile(subdir: string, xdg?: XdgCategory): string {
|
|
534
|
+
this.refreshConfigDirOverride();
|
|
535
|
+
if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
|
|
536
|
+
const cacheKey = `${xdg ?? "config"}:${subdir}`;
|
|
537
|
+
const cached = this.#defaultRootCache.get(cacheKey);
|
|
538
|
+
if (cached) return cached;
|
|
539
|
+
const base = xdg ? this.#defaultRootDirs[xdg] : this.configRoot;
|
|
540
|
+
const result = path.join(base, subdir);
|
|
541
|
+
this.#defaultRootCache.set(cacheKey, result);
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
get configDirName(): string {
|
|
546
|
+
return this.#configDirName;
|
|
547
|
+
}
|
|
548
|
+
get trustedHome(): string {
|
|
549
|
+
this.refreshConfigDirOverride();
|
|
550
|
+
return this.#trustedHome;
|
|
551
|
+
}
|
|
552
|
+
assertHomeAvailable(): void {
|
|
553
|
+
this.refreshConfigDirOverride();
|
|
554
|
+
if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
|
|
555
|
+
}
|
|
556
|
+
get trustSnapshot(): ProjectEnvSnapshot {
|
|
557
|
+
return this.#projectEnv;
|
|
558
|
+
}
|
|
322
559
|
}
|
|
323
560
|
|
|
561
|
+
const INITIAL_PROJECT_SNAPSHOT = projectEnvSnapshot();
|
|
562
|
+
const trustedAgentOverride =
|
|
563
|
+
trustedValue("SKC_CODING_AGENT_DIR", INITIAL_PROJECT_SNAPSHOT) ??
|
|
564
|
+
trustedValue("PI_CODING_AGENT_DIR", INITIAL_PROJECT_SNAPSHOT);
|
|
565
|
+
let dirs = new DirResolver(trustedAgentOverride, INITIAL_PROJECT_SNAPSHOT);
|
|
566
|
+
|
|
324
567
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* `PI_CODING_AGENT_DIR` is the legacy alias this module's own header documents,
|
|
328
|
-
* and parts of the product already resolve it (`gc-runtime.ts:370`,
|
|
329
|
-
* `deep-interview-runtime.ts:384`). Reading only the `SKC_` spelling here split
|
|
330
|
-
* the agent directory in two: `skc gc` operated on the aliased directory while
|
|
331
|
-
* everything reaching `getAgentDir()` stayed on the default.
|
|
568
|
+
* Project dotenv as captured when this module loaded.
|
|
569
|
+
* A later edit or delete of those files does not change the returned provenance.
|
|
332
570
|
*/
|
|
333
|
-
function
|
|
334
|
-
|
|
571
|
+
export function loadedProjectEnvSnapshot(): ProjectEnvSnapshot {
|
|
572
|
+
const snapshot = dirs.trustSnapshot;
|
|
573
|
+
return { values: { ...snapshot.values }, dynamic: new Set(snapshot.dynamic) };
|
|
335
574
|
}
|
|
336
575
|
|
|
337
|
-
let dirs = new DirResolver(trustedAgentDirOverride());
|
|
338
|
-
|
|
339
|
-
// Anchor home for the resolver. Captured at module load to stay stable across
|
|
340
|
-
// test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
|
|
341
|
-
// production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
|
|
342
|
-
// tests passing a temp HOME short-circuit to a deterministic path.
|
|
343
|
-
const RESOLVER_HOME = os.homedir();
|
|
344
|
-
|
|
345
576
|
// =============================================================================
|
|
346
577
|
// Root directories
|
|
347
578
|
// =============================================================================
|
|
348
579
|
|
|
349
580
|
/** Get the config root directory (~/.skc). */
|
|
350
581
|
export function getConfigRootDir(): string {
|
|
582
|
+
dirs.refreshConfigDirOverride();
|
|
583
|
+
dirs.assertHomeAvailable();
|
|
351
584
|
return dirs.configRoot;
|
|
352
585
|
}
|
|
353
586
|
|
|
354
|
-
/**
|
|
587
|
+
/**
|
|
588
|
+
* The authoritative home for user-scope state.
|
|
589
|
+
*
|
|
590
|
+
* Provenance-checked and resolved at call time: a home established or changed
|
|
591
|
+
* after this module loaded is honored, while a home the project dotenv could
|
|
592
|
+
* have planted is rejected in favor of the OS account database. See
|
|
593
|
+
* {@link resolveTrustedHome}.
|
|
594
|
+
*/
|
|
595
|
+
export function getTrustedHomeDir(): string {
|
|
596
|
+
dirs.assertHomeAvailable();
|
|
597
|
+
return dirs.trustedHome;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Trusted config root, resolved at call time; preserves the configured nested config-dir name. */
|
|
601
|
+
export function getTrustedConfigRootDir(): string {
|
|
602
|
+
dirs.refreshConfigDirOverride();
|
|
603
|
+
dirs.assertHomeAvailable();
|
|
604
|
+
return dirs.configRoot;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Set the coding agent directory. Creates a fresh resolver, invalidating all
|
|
609
|
+
* cached paths.
|
|
610
|
+
*
|
|
611
|
+
* This also exports `SKC_CODING_AGENT_DIR`, so child processes inherit the same
|
|
612
|
+
* selection and resolve the same storage lane.
|
|
613
|
+
*/
|
|
355
614
|
export function setAgentDir(dir: string): void {
|
|
356
|
-
dirs = new DirResolver(dir);
|
|
615
|
+
dirs = new DirResolver(dir, dirs.trustSnapshot);
|
|
357
616
|
process.env.SKC_CODING_AGENT_DIR = dir;
|
|
358
617
|
}
|
|
359
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Rebuild the resolver from the current trusted environment. Callers that
|
|
621
|
+
* temporarily used {@link setAgentDir} must first restore the original
|
|
622
|
+
* SKC_CODING_AGENT_DIR / PI_CODING_AGENT_DIR values, then call this function;
|
|
623
|
+
* an originally absent override remains absent and keeps following HOME.
|
|
624
|
+
*/
|
|
625
|
+
export function resetAgentDirFromEnvironment(): void {
|
|
626
|
+
const snapshot = dirs.trustSnapshot;
|
|
627
|
+
const override = trustedValue("SKC_CODING_AGENT_DIR", snapshot) ?? trustedValue("PI_CODING_AGENT_DIR", snapshot);
|
|
628
|
+
dirs = new DirResolver(override, snapshot);
|
|
629
|
+
}
|
|
630
|
+
|
|
360
631
|
/** Get the agent config directory (~/.skc/agent). */
|
|
361
632
|
export function getAgentDir(): string {
|
|
633
|
+
dirs.refreshConfigDirOverride();
|
|
634
|
+
dirs.assertHomeAvailable();
|
|
362
635
|
return dirs.agentDir;
|
|
363
636
|
}
|
|
364
637
|
|
|
638
|
+
/** Resolver-owned profile classification, stable across HOME refreshes. */
|
|
639
|
+
export function getAgentProfileAuthority(): "default" | "custom" {
|
|
640
|
+
return dirs.profileAuthority;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function getConfigDirName(): string {
|
|
644
|
+
dirs.refreshConfigDirOverride();
|
|
645
|
+
return dirs.configDirName;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Join a file under the provenance-checked agent directory, never the XDG
|
|
649
|
+
* state category. Automatic crash relay must not follow `XDG_STATE_HOME`:
|
|
650
|
+
* a checkout `.env` can set that variable and create `$XDG_STATE_HOME/skc`,
|
|
651
|
+
* which the ordinary state resolver would then treat as the crash store.
|
|
652
|
+
*/
|
|
653
|
+
export function getTrustedAgentFile(filename: string): string {
|
|
654
|
+
return path.join(getAgentDir(), filename);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Whether the current checkout declares an environment key in its `.env`. */
|
|
658
|
+
export function isProjectEnvDeclaration(name: string): boolean {
|
|
659
|
+
return dirs.isProjectEnvDeclaration(name);
|
|
660
|
+
}
|
|
661
|
+
|
|
365
662
|
/** Get the project-local config directory (.skc). */
|
|
366
663
|
export function getProjectAgentDir(cwd: string = getProjectDir()): string {
|
|
367
664
|
return path.join(cwd, CONFIG_DIR_NAME);
|
|
@@ -378,31 +675,98 @@ export function getReportsDir(): string {
|
|
|
378
675
|
|
|
379
676
|
/** Get the logs directory (~/.skc/logs). */
|
|
380
677
|
export function getLogsDir(): string {
|
|
381
|
-
|
|
678
|
+
const home = getTrustedHomeDir();
|
|
679
|
+
return resolveCanonicalLogsDir({
|
|
680
|
+
home,
|
|
681
|
+
env: {
|
|
682
|
+
SKC_CONFIG_DIR: process.env.SKC_CONFIG_DIR,
|
|
683
|
+
PI_CONFIG_DIR: process.env.PI_CONFIG_DIR,
|
|
684
|
+
XDG_STATE_HOME: process.env.XDG_STATE_HOME,
|
|
685
|
+
},
|
|
686
|
+
projectEnv: dirs.trustSnapshot,
|
|
687
|
+
xdgEligible: dirs.profileAuthority === "default",
|
|
688
|
+
pathExists: fs.existsSync,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Dated log file name, shared by the canonical and effective log paths so they cannot drift. */
|
|
693
|
+
function datedLogFileName(date: Date): string {
|
|
694
|
+
return `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`;
|
|
382
695
|
}
|
|
383
696
|
|
|
384
697
|
/** Get the path to a dated log file (~/.skc/logs/skc.YYYY-MM-DD.log). */
|
|
385
698
|
export function getLogPath(date = new Date()): string {
|
|
386
|
-
return path.join(getLogsDir(),
|
|
699
|
+
return path.join(getLogsDir(), datedLogFileName(date));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The logs directory the process actually reads and writes.
|
|
704
|
+
*
|
|
705
|
+
* A trusted `SKC_LOG_DIR` redirects the sink — the test preload pins it to a
|
|
706
|
+
* per-process temp directory so `bun test` stops appending fixture
|
|
707
|
+
* `level:error` records to the operator's shared log (issue #5618) — and
|
|
708
|
+
* everything else falls back to {@link getLogsDir}.
|
|
709
|
+
*
|
|
710
|
+
* The override is provenance-checked through the same {@link trustedValue} rule
|
|
711
|
+
* as the config and agent directories: Bun loads `cwd/.env` into `process.env`
|
|
712
|
+
* before any module runs, so a repository could otherwise redirect where the
|
|
713
|
+
* operator's production logs are written. A dynamic (`$`/backtick) declaration
|
|
714
|
+
* is rejected there too, which fails closed onto the canonical path.
|
|
715
|
+
*
|
|
716
|
+
* This is deliberately *not* folded into {@link getLogsDir}: that function's
|
|
717
|
+
* config-root semantics are pinned by tests, and the preload sets `SKC_LOG_DIR`
|
|
718
|
+
* for every test process, so folding the override in would move it under every
|
|
719
|
+
* test. Writers and readers must both come through here instead — the split
|
|
720
|
+
* between the two is what let the transport and the log readers disagree.
|
|
721
|
+
*/
|
|
722
|
+
export function getEffectiveLogsDir(): string {
|
|
723
|
+
const override = trustedValue("SKC_LOG_DIR", dirs.trustSnapshot)?.trim();
|
|
724
|
+
return override || getLogsDir();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Get the dated log file under {@link getEffectiveLogsDir}. */
|
|
728
|
+
export function getEffectiveLogPath(date = new Date()): string {
|
|
729
|
+
return path.join(getEffectiveLogsDir(), datedLogFileName(date));
|
|
387
730
|
}
|
|
388
731
|
|
|
389
732
|
/**
|
|
390
733
|
* Get the plugins directory (~/.skc/plugins or its XDG equivalent).
|
|
391
734
|
*
|
|
392
735
|
* No-arg form (production callers) goes through the XDG-aware DirResolver so
|
|
393
|
-
* reads and writes always agree. The optional `home` parameter
|
|
394
|
-
*
|
|
395
|
-
* and returns `<home>/<configDir>/plugins
|
|
396
|
-
*
|
|
397
|
-
*
|
|
736
|
+
* reads and writes always agree. The optional `home` parameter names an explicit
|
|
737
|
+
* home: when it differs from the authoritative home resolved right now it
|
|
738
|
+
* short-circuits the resolver and returns `<home>/<configDir>/plugins`, giving
|
|
739
|
+
* callers that carry their own home (and tests with a temp HOME) a deterministic
|
|
740
|
+
* path. Passing the authoritative home explicitly is identical to the no-arg
|
|
741
|
+
* form — XDG semantics are preserved.
|
|
398
742
|
*/
|
|
399
743
|
export function getPluginsDir(home?: string): string {
|
|
400
|
-
if (home !== undefined
|
|
401
|
-
|
|
744
|
+
if (home !== undefined) {
|
|
745
|
+
const explicitPath = () => path.join(home, resolveConfigDirName(dirs.trustSnapshot), "plugins");
|
|
746
|
+
try {
|
|
747
|
+
const canonical = (value: string): string => {
|
|
748
|
+
try {
|
|
749
|
+
return fs.realpathSync.native(value);
|
|
750
|
+
} catch {
|
|
751
|
+
return path.resolve(value);
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
if (canonical(home) !== canonical(dirs.trustedHome)) return explicitPath();
|
|
755
|
+
} catch {
|
|
756
|
+
// An explicit home is the caller's documented escape hatch. If the
|
|
757
|
+
// authoritative home is unavailable, do not let its fail-closed resolver
|
|
758
|
+
// prevent a caller-owned plugin path from being returned.
|
|
759
|
+
return explicitPath();
|
|
760
|
+
}
|
|
402
761
|
}
|
|
403
762
|
return dirs.rootSubdir("plugins", "data");
|
|
404
763
|
}
|
|
405
764
|
|
|
765
|
+
/** Get the default-profile plugin directory without ambient profile authority. */
|
|
766
|
+
export function getDefaultPluginsDir(): string {
|
|
767
|
+
return dirs.rootSubdirForDefaultProfile("plugins", "data");
|
|
768
|
+
}
|
|
769
|
+
|
|
406
770
|
/** Where npm installs packages (~/.skc/plugins/node_modules). */
|
|
407
771
|
export function getPluginsNodeModules(): string {
|
|
408
772
|
return path.join(getPluginsDir(), "node_modules");
|
|
@@ -473,6 +837,8 @@ export function getWorktreeDir(segment: string): string {
|
|
|
473
837
|
|
|
474
838
|
/** Get the GPU cache path (~/.skc/gpu_cache.json). */
|
|
475
839
|
export function getGpuCachePath(): string {
|
|
840
|
+
const defaultAgentDir = path.join(dirs.trustedHome, dirs.configDirName, "agent");
|
|
841
|
+
if (path.resolve(dirs.agentDir) !== path.resolve(defaultAgentDir)) return path.join(dirs.agentDir, "gpu_cache.json");
|
|
476
842
|
return dirs.rootSubdir("gpu_cache.json", "cache");
|
|
477
843
|
}
|
|
478
844
|
|
|
@@ -482,11 +848,16 @@ export function getGpuCachePath(): string {
|
|
|
482
848
|
* cache file without touching the rest of the config root.
|
|
483
849
|
*/
|
|
484
850
|
export function getGithubCacheDbPath(): string {
|
|
485
|
-
const override =
|
|
851
|
+
const override = trustedValue("SKC_GITHUB_CACHE_DB", dirs.trustSnapshot)?.trim();
|
|
486
852
|
if (override) return override;
|
|
487
853
|
return dirs.rootSubdir(path.join("cache", "github-cache.db"), "cache");
|
|
488
854
|
}
|
|
489
855
|
|
|
856
|
+
/** Get the durable tool-choice capability cache path. */
|
|
857
|
+
export function getToolChoiceCapabilityCachePath(): string {
|
|
858
|
+
return dirs.rootSubdir(path.join("cache", "tool-choice-capabilities.db"), "cache");
|
|
859
|
+
}
|
|
860
|
+
|
|
490
861
|
/** Get the resolved Claude Code client-version cache path (~/.skc/cache/claude-code-version.json). */
|
|
491
862
|
export function getClaudeCodeVersionCachePath(): string {
|
|
492
863
|
return dirs.rootSubdir(path.join("cache", "claude-code-version.json"), "cache");
|
|
@@ -497,6 +868,11 @@ export function getNativesDir(): string {
|
|
|
497
868
|
return dirs.rootSubdir("natives", "cache");
|
|
498
869
|
}
|
|
499
870
|
|
|
871
|
+
/** Get the local tool-issue report database path (~/.skc/tool-issues.db). */
|
|
872
|
+
export function getToolIssuesDbPath(): string {
|
|
873
|
+
return dirs.rootSubdir("tool-issues.db", "data");
|
|
874
|
+
}
|
|
875
|
+
|
|
500
876
|
/** Get the stats database path (~/.skc/stats.db). */
|
|
501
877
|
export function getStatsDbPath(): string {
|
|
502
878
|
return dirs.rootSubdir("stats.db", "data");
|
|
@@ -551,6 +927,16 @@ export function getBlobsDir(agentDir?: string): string {
|
|
|
551
927
|
return dirs.agentSubdir(agentDir, "blobs", "data");
|
|
552
928
|
}
|
|
553
929
|
|
|
930
|
+
/** Get the resident-text cache root for a profile agent directory. */
|
|
931
|
+
export function getResidentCacheRootDir(profileAgentDir: string): string {
|
|
932
|
+
return dirs.agentSubdir(profileAgentDir, "resident-cache", "cache");
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Get the managed cold-history sidecar cache root for a profile agent directory. */
|
|
936
|
+
export function getSidecarCacheRootDir(profileAgentDir: string): string {
|
|
937
|
+
return dirs.agentSubdir(profileAgentDir, "sidecar-cache", "cache");
|
|
938
|
+
}
|
|
939
|
+
|
|
554
940
|
/** Get the custom themes directory (~/.skc/agent/themes). */
|
|
555
941
|
export function getCustomThemesDir(agentDir?: string): string {
|
|
556
942
|
return dirs.agentSubdir(agentDir, "themes");
|
|
@@ -591,6 +977,31 @@ export function getCrashLogPath(agentDir?: string): string {
|
|
|
591
977
|
return dirs.agentSubdir(agentDir, "skc-crash.log", "state");
|
|
592
978
|
}
|
|
593
979
|
|
|
980
|
+
/** Get the crash event journal path (~/.skc/agent/skc-crash-events.jsonl). */
|
|
981
|
+
export function getCrashEventsPath(agentDir?: string): string {
|
|
982
|
+
return dirs.agentSubdir(agentDir, "skc-crash-events.jsonl", "state");
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Get the compacted crash signature index path (~/.skc/agent/skc-crash-index.json). */
|
|
986
|
+
export function getCrashIndexPath(agentDir?: string): string {
|
|
987
|
+
return dirs.agentSubdir(agentDir, "skc-crash-index.json", "state");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** Get the handled error log path (~/.skc/agent/skc-error.log). */
|
|
991
|
+
export function getHandledErrorLogPath(agentDir?: string): string {
|
|
992
|
+
return dirs.agentSubdir(agentDir, "skc-error.log", "state");
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Get the handled error event journal path (~/.skc/agent/skc-error-events.jsonl). */
|
|
996
|
+
export function getHandledErrorEventsPath(agentDir?: string): string {
|
|
997
|
+
return dirs.agentSubdir(agentDir, "skc-error-events.jsonl", "state");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Get the compacted handled error signature index path (~/.skc/agent/skc-error-index.json). */
|
|
1001
|
+
export function getHandledErrorIndexPath(agentDir?: string): string {
|
|
1002
|
+
return dirs.agentSubdir(agentDir, "skc-error-index.json", "state");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
594
1005
|
/** Get the debug log path (~/.skc/agent/skc-debug.log). */
|
|
595
1006
|
export function getDebugLogPath(agentDir?: string): string {
|
|
596
1007
|
return dirs.agentSubdir(agentDir, `${APP_NAME}-debug.log`, "state");
|
|
@@ -619,18 +1030,25 @@ export function getProjectPluginOverridesPath(cwd: string = getProjectDir()): st
|
|
|
619
1030
|
// MCP config paths
|
|
620
1031
|
// =============================================================================
|
|
621
1032
|
|
|
622
|
-
/**
|
|
623
|
-
|
|
1033
|
+
/**
|
|
1034
|
+
* Get the primary MCP config file path (first candidate).
|
|
1035
|
+
*
|
|
1036
|
+
* User scope lives in the agent directory, so a profile override
|
|
1037
|
+
* (`--agent-dir`, `SKC_CODING_AGENT_DIR`, `setAgentDir()`) moves it. Pass
|
|
1038
|
+
* `agentDir` to resolve the scope of a session whose agent directory differs
|
|
1039
|
+
* from the process-wide one.
|
|
1040
|
+
*/
|
|
1041
|
+
export function getMCPConfigPath(scope: "user" | "project", cwd: string = getProjectDir(), agentDir?: string): string {
|
|
624
1042
|
if (scope === "user") {
|
|
625
|
-
return path.join(getAgentDir(), "mcp.json");
|
|
1043
|
+
return path.join(agentDir ?? getAgentDir(), "mcp.json");
|
|
626
1044
|
}
|
|
627
1045
|
return path.join(getProjectAgentDir(cwd), "mcp.json");
|
|
628
1046
|
}
|
|
629
1047
|
|
|
630
1048
|
/** Get the SSH config file path. */
|
|
631
|
-
export function getSSHConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string {
|
|
1049
|
+
export function getSSHConfigPath(scope: "user" | "project", cwd: string = getProjectDir(), agentDir?: string): string {
|
|
632
1050
|
if (scope === "user") {
|
|
633
|
-
return path.join(getAgentDir(), "ssh.json");
|
|
1051
|
+
return path.join(agentDir ?? getAgentDir(), "ssh.json");
|
|
634
1052
|
}
|
|
635
1053
|
return path.join(getProjectAgentDir(cwd), "ssh.json");
|
|
636
1054
|
}
|