@linxiraos/pi-utils 1.1.15 → 1.1.17

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/THIRD-PARTY-NOTICES.txt +54 -25
  3. package/dist/types/async.d.ts +18 -0
  4. package/dist/types/browsers.d.ts +2 -30
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +18 -1
  7. package/dist/types/env.d.ts +12 -2
  8. package/dist/types/executable.d.ts +4 -0
  9. package/dist/types/fetch-retry.d.ts +8 -2
  10. package/dist/types/file-lock.d.ts +6 -0
  11. package/dist/types/format.d.ts +7 -0
  12. package/dist/types/index.d.ts +2 -1
  13. package/dist/types/mime.d.ts +1 -0
  14. package/dist/types/path.d.ts +6 -0
  15. package/dist/types/peek-file.d.ts +2 -3
  16. package/dist/types/postmortem.d.ts +26 -5
  17. package/dist/types/procmgr.d.ts +2 -4
  18. package/dist/types/snowflake.d.ts +1 -0
  19. package/dist/types/sqlite.d.ts +27 -7
  20. package/dist/types/stream.d.ts +76 -1
  21. package/dist/types/which.d.ts +8 -2
  22. package/dist/types/yaml-config.d.ts +2 -0
  23. package/package.json +2 -2
  24. package/src/acp/transport.ts +37 -3
  25. package/src/async.ts +31 -0
  26. package/src/browsers.ts +10 -169
  27. package/src/color.ts +1 -1
  28. package/src/dirs.ts +28 -6
  29. package/src/env.ts +81 -34
  30. package/src/executable.ts +17 -0
  31. package/src/fetch-retry.ts +72 -13
  32. package/src/file-lock.ts +28 -0
  33. package/src/format.ts +16 -0
  34. package/src/index.ts +2 -1
  35. package/src/json.ts +12 -1
  36. package/src/logger/rotating-file.ts +40 -3
  37. package/src/logger.ts +16 -4
  38. package/src/mime.ts +3 -7
  39. package/src/path.ts +14 -0
  40. package/src/peek-file.ts +17 -67
  41. package/src/postmortem.ts +95 -47
  42. package/src/procmgr.ts +2 -12
  43. package/src/ptree.ts +36 -5
  44. package/src/snowflake.ts +12 -1
  45. package/src/sqlite.ts +242 -7
  46. package/src/stream.ts +167 -35
  47. package/src/which.ts +38 -14
  48. package/src/xml.ts +16 -0
  49. package/src/yaml-config.ts +8 -0
  50. package/dist/types/glob.d.ts +0 -28
  51. package/src/glob.ts +0 -189
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linxiraos/pi-utils",
3
- "version": "1.1.15",
3
+ "version": "1.1.17",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "utilities",
@@ -54,7 +54,7 @@
54
54
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
55
55
  },
56
56
  "dependencies": {
57
- "@linxiraos/pi-natives": "1.1.15"
57
+ "@linxiraos/pi-natives": "1.1.17"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/bun": "^1.3.14"
@@ -108,10 +108,15 @@ function createStandardError(
108
108
  type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
109
109
  type Pending = { resolve(value: unknown): void; reject(reason: unknown): void };
110
110
 
111
+ /** Bound on clean-EOF inbound drain so `closed` cannot hang if a handler never settles. */
112
+ const INBOUND_DRAIN_TIMEOUT_MS = 30_000;
113
+
111
114
  /** Correlated bidirectional JSON-RPC connection. */
112
115
  export class RpcConnection {
113
116
  #nextId = 0;
114
117
  #pending = new Map<JsonRpcId, Pending>();
118
+ #inbound = new Set<Promise<void>>();
119
+ #openInboundIds = new Set<JsonRpcId>();
115
120
  #writable: WritableStream<AnyMessage>;
116
121
  #writeTail: Promise<void> = Promise.resolve();
117
122
  #abort = new AbortController();
@@ -179,8 +184,9 @@ export class RpcConnection {
179
184
  while (true) {
180
185
  const next = await reader.read();
181
186
  if (next.done) break;
182
- void this.#handle(next.value).catch(error => this.close(error));
187
+ this.#dispatch(next.value);
183
188
  }
189
+ await this.#drainInbound();
184
190
  this.close();
185
191
  } catch (error) {
186
192
  this.close(error);
@@ -189,6 +195,34 @@ export class RpcConnection {
189
195
  }
190
196
  }
191
197
 
198
+ #dispatch(message: AnyMessage): void {
199
+ if ("method" in message && "id" in message) this.#openInboundIds.add(message.id);
200
+ const task = this.#handle(message).catch(error => this.close(error));
201
+ this.#inbound.add(task);
202
+ void task.finally(() => this.#inbound.delete(task));
203
+ }
204
+
205
+ async #drainInbound(): Promise<void> {
206
+ if (this.#inbound.size === 0) return;
207
+ const drained = Promise.allSettled(this.#inbound).then(() => {});
208
+ let timer: Timer | undefined;
209
+ const timedOut = await Promise.race([
210
+ drained.then(() => false),
211
+ new Promise<boolean>(resolve => {
212
+ timer = setTimeout(() => resolve(true), INBOUND_DRAIN_TIMEOUT_MS);
213
+ }),
214
+ ]);
215
+ if (timer !== undefined) clearTimeout(timer);
216
+ if (!timedOut) return;
217
+ const error = RequestError.internalError(undefined, "Inbound request drain timed out").toErrorResponse();
218
+ await Promise.allSettled([...this.#openInboundIds].map(id => this.#respond(id, { error })));
219
+ }
220
+
221
+ #respond(id: JsonRpcId, body: { result: unknown } | { error: ErrorResponse }): Promise<void> {
222
+ if (!this.#openInboundIds.delete(id)) return Promise.resolve();
223
+ return this.#write({ jsonrpc: "2.0", id, ...body });
224
+ }
225
+
192
226
  async #handle(message: AnyMessage): Promise<void> {
193
227
  if ("id" in message && !("method" in message)) {
194
228
  const pending = this.#pending.get(message.id);
@@ -210,13 +244,13 @@ export class RpcConnection {
210
244
  }
211
245
  try {
212
246
  const result = await this.#dispatcher(message.method, message.params, false);
213
- await this.#write({ jsonrpc: "2.0", id: message.id, result: result ?? {} });
247
+ await this.#respond(message.id, { result: result ?? {} });
214
248
  } catch (error) {
215
249
  const protocolError =
216
250
  error instanceof RequestError
217
251
  ? error
218
252
  : RequestError.internalError({ details: error instanceof Error ? error.message : String(error) });
219
- await this.#write({ jsonrpc: "2.0", id: message.id, error: protocolError.toErrorResponse() });
253
+ await this.#respond(message.id, { error: protocolError.toErrorResponse() });
220
254
  }
221
255
  }
222
256
  }
package/src/async.ts CHANGED
@@ -1,3 +1,34 @@
1
+ import { scheduler } from "node:timers/promises";
2
+
3
+ /**
4
+ * Largest delay `setTimeout` (and `timers/promises` `scheduler.wait`)
5
+ * accepts without 32-bit signed overflow: larger values wrap and fire
6
+ * almost immediately instead of sleeping. Chunk day-scale provider waits
7
+ * (e.g. a monthly quota reset parsed from an error hint) so the full
8
+ * duration elapses instead of overflowing the timer.
9
+ */
10
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
11
+
12
+ /**
13
+ * Abortable sleep for arbitrarily long delays. Waits longer than
14
+ * {@link MAX_TIMER_DELAY_MS} chunk the sleep into back-to-back timer waits
15
+ * so no single timer overflows; an abort during any chunk rejects like
16
+ * `scheduler.wait`.
17
+ *
18
+ * Uses a monotonic deadline so a timer that wakes prematurely is re-armed for
19
+ * the unelapsed duration instead of shortening the requested sleep.
20
+ */
21
+ export async function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void> {
22
+ signal?.throwIfAborted();
23
+ const deadline = performance.now() + delayMs;
24
+ while (true) {
25
+ const remaining = deadline - performance.now();
26
+ if (!(remaining > 0)) return;
27
+ await scheduler.wait(Math.min(remaining, MAX_TIMER_DELAY_MS), { signal });
28
+ signal?.throwIfAborted();
29
+ }
30
+ }
31
+
1
32
  /**
2
33
  * Wrap a promise with a timeout and optional abort signal.
3
34
  * Rejects with the given error or a new error containing the given message if
package/src/browsers.ts CHANGED
@@ -1,6 +1,4 @@
1
- /** Behavior-compatible reimplementation of @puppeteer/browsers' used surface. */
2
-
3
- import type * as fs from "node:fs";
1
+ /** Managed Chrome-for-Testing installation with bounded downloads and atomic publication. */
4
2
  import * as fsp from "node:fs/promises";
5
3
  import * as os from "node:os";
6
4
  import * as path from "node:path";
@@ -8,7 +6,6 @@ import { type ArchiveLimits, extractArchive } from "./ar";
8
6
  import { withFileLock } from "./file-lock";
9
7
 
10
8
  const CHROME_FOR_TESTING_BASE_URL = "https://storage.googleapis.com/chrome-for-testing-public";
11
- const CHROME_METADATA_BASE_URL = "https://googlechromelabs.github.io/chrome-for-testing";
12
9
  // Installation is outside browser/Eval operation deadlines, but a stalled CDN
13
10
  // must still release the cached install promise and permit a later attempt.
14
11
  const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
@@ -25,15 +22,6 @@ const BROWSER_ARCHIVE_LIMITS: Partial<ArchiveLimits> = {
25
22
  maxInMemorySize: 1024 * 1024 * 1024,
26
23
  };
27
24
 
28
- /** Supported browser products. */
29
- export enum Browser {
30
- CHROME = "chrome",
31
- CHROMEHEADLESSSHELL = "chrome-headless-shell",
32
- CHROMIUM = "chromium",
33
- FIREFOX = "firefox",
34
- CHROMEDRIVER = "chromedriver",
35
- }
36
-
37
25
  /** Browser download platform identifiers. */
38
26
  export enum BrowserPlatform {
39
27
  LINUX = "linux",
@@ -51,34 +39,6 @@ function requireChromeForTestingPlatform(platform: BrowserPlatform): ChromeForTe
51
39
  return platform;
52
40
  }
53
41
 
54
- const BROWSERS = [
55
- Browser.CHROME,
56
- Browser.CHROMEHEADLESSSHELL,
57
- Browser.CHROMIUM,
58
- Browser.FIREFOX,
59
- Browser.CHROMEDRIVER,
60
- ] as const;
61
- const BROWSER_PLATFORMS = [
62
- BrowserPlatform.LINUX,
63
- BrowserPlatform.LINUX_ARM,
64
- BrowserPlatform.MAC,
65
- BrowserPlatform.MAC_ARM,
66
- BrowserPlatform.WIN32,
67
- BrowserPlatform.WIN64,
68
- ] as const;
69
-
70
- /** Chrome-for-Testing release channel tags accepted by {@link resolveBuildId}. */
71
- export enum BrowserTag {
72
- CANARY = "canary",
73
- NIGHTLY = "nightly",
74
- BETA = "beta",
75
- DEV = "dev",
76
- DEVEDITION = "devedition",
77
- STABLE = "stable",
78
- ESR = "esr",
79
- LATEST = "latest",
80
- }
81
-
82
42
  /** Download progress reported while a browser archive is streamed to disk. */
83
43
  export interface BrowserDownloadProgress {
84
44
  downloadedBytes: number;
@@ -87,7 +47,6 @@ export interface BrowserDownloadProgress {
87
47
 
88
48
  /** Inputs used to locate an installed browser executable. */
89
49
  export interface ComputeExecutablePathOptions {
90
- browser: Browser;
91
50
  buildId: string;
92
51
  cacheDir: string;
93
52
  platform?: BrowserPlatform;
@@ -99,27 +58,14 @@ export interface InstallOptions extends ComputeExecutablePathOptions {
99
58
  downloadProgressCallback?: (progress: BrowserDownloadProgress) => void;
100
59
  }
101
60
 
102
- /** Metadata for one browser installation found in a Puppeteer cache. */
61
+ /** Metadata for a managed Chrome installation in Puppeteer's cache layout. */
103
62
  export interface InstalledBrowser {
104
- browser: Browser;
105
63
  buildId: string;
106
64
  platform: BrowserPlatform;
107
65
  path: string;
108
66
  executablePath: string;
109
67
  }
110
68
 
111
- interface LastKnownGoodVersions {
112
- channels: Record<string, { version: string }>;
113
- }
114
-
115
- interface MilestoneVersions {
116
- milestones: Record<string, { version: string }>;
117
- }
118
-
119
- interface PatchVersions {
120
- builds: Record<string, { version: string }>;
121
- }
122
-
123
69
  /** Detect the current host's Puppeteer browser platform. */
124
70
  export function detectBrowserPlatform(): BrowserPlatform | undefined {
125
71
  const platform = os.platform();
@@ -130,43 +76,8 @@ export function detectBrowserPlatform(): BrowserPlatform | undefined {
130
76
  return undefined;
131
77
  }
132
78
 
133
- /** Resolve a Chrome-for-Testing channel, milestone, or build prefix to a full build ID. */
134
- export async function resolveBuildId(
135
- browser: Browser,
136
- _platform: BrowserPlatform,
137
- tag: string | BrowserTag,
138
- ): Promise<string> {
139
- if (browser !== Browser.CHROME && browser !== Browser.CHROMEHEADLESSSHELL && browser !== Browser.CHROMEDRIVER) {
140
- return tag;
141
- }
142
- if (/^\d+\.\d+\.\d+\.\d+$/.test(tag)) return tag;
143
-
144
- const channel = tag === BrowserTag.LATEST ? "Canary" : chromeChannelName(tag);
145
- if (channel) {
146
- const metadata = await fetchMetadata<LastKnownGoodVersions>("last-known-good-versions.json");
147
- const version = metadata.channels[channel]?.version;
148
- if (!version) throw new Error(`Chrome channel ${tag} was not found in Chrome-for-Testing metadata`);
149
- return version;
150
- }
151
- if (/^\d+$/.test(tag)) {
152
- const metadata = await fetchMetadata<MilestoneVersions>("latest-versions-per-milestone.json");
153
- return metadata.milestones[tag]?.version ?? tag;
154
- }
155
- if (/^\d+\.\d+\.\d+$/.test(tag)) {
156
- const metadata = await fetchMetadata<PatchVersions>("latest-patch-versions-per-build.json");
157
- return metadata.builds[tag]?.version ?? tag;
158
- }
159
- return tag;
160
- }
161
-
162
79
  /** Return the Chrome-for-Testing archive URL for a browser build. */
163
- export function getDownloadUrl(
164
- browser: Browser,
165
- platform: BrowserPlatform,
166
- buildId: string,
167
- baseUrl = CHROME_FOR_TESTING_BASE_URL,
168
- ): URL {
169
- if (browser !== Browser.CHROME) throw new Error(`Unsupported browser download: ${browser}`);
80
+ export function getDownloadUrl(platform: BrowserPlatform, buildId: string, baseUrl = CHROME_FOR_TESTING_BASE_URL): URL {
170
81
  const archivePlatform = chromeArchivePlatform(platform);
171
82
  const root = baseUrl.replace(/\/$/, "");
172
83
  return new URL(`${root}/${buildId}/${archivePlatform}/chrome-${archivePlatform}.zip`);
@@ -176,9 +87,8 @@ export function getDownloadUrl(
176
87
  export function computeExecutablePath(options: ComputeExecutablePathOptions): string {
177
88
  const detectedPlatform = options.platform ?? detectBrowserPlatform();
178
89
  if (!detectedPlatform) throw new Error("Cannot determine a browser platform for this host");
179
- if (options.browser !== Browser.CHROME) throw new Error(`Unsupported browser executable: ${options.browser}`);
180
90
  const platform = requireChromeForTestingPlatform(detectedPlatform);
181
- const installDir = installationDir(options.cacheDir, options.browser, platform, options.buildId);
91
+ const installDir = installationDir(options.cacheDir, platform, options.buildId);
182
92
  switch (platform) {
183
93
  case BrowserPlatform.LINUX:
184
94
  return path.join(installDir, "chrome-linux64", "chrome");
@@ -207,52 +117,14 @@ export function computeExecutablePath(options: ComputeExecutablePathOptions): st
207
117
  }
208
118
  }
209
119
 
210
- /** Scan a Puppeteer cache for browser installation directories. */
211
- export async function getInstalledBrowsers(options: { cacheDir: string }): Promise<InstalledBrowser[]> {
212
- const installed: InstalledBrowser[] = [];
213
- for (const browser of BROWSERS) {
214
- const browserDir = path.join(options.cacheDir, browser);
215
- let entries: fs.Dirent[];
216
- try {
217
- entries = await fsp.readdir(browserDir, { withFileTypes: true });
218
- } catch (error) {
219
- if (isMissingPath(error)) continue;
220
- throw error;
221
- }
222
- for (const entry of entries) {
223
- if (!entry.isDirectory()) continue;
224
- const parsed = parseInstallationName(entry.name);
225
- if (!parsed) continue;
226
- const installPath = path.join(browserDir, entry.name);
227
- try {
228
- installed.push({
229
- browser,
230
- buildId: parsed.buildId,
231
- platform: parsed.platform,
232
- path: installPath,
233
- executablePath: computeExecutablePath({
234
- browser,
235
- buildId: parsed.buildId,
236
- cacheDir: options.cacheDir,
237
- platform: parsed.platform,
238
- }),
239
- });
240
- } catch {
241
- // Other browser products are not part of the surface used by OMP.
242
- }
243
- }
244
- }
245
- return installed;
246
- }
247
-
248
120
  /** Download and unpack Chrome into Puppeteer's existing cache layout. */
249
121
  export async function install(options: InstallOptions): Promise<InstalledBrowser> {
250
122
  const platform = options.platform ?? detectBrowserPlatform();
251
123
  if (!platform) throw new Error("Cannot determine a browser platform for this host");
252
124
  const executablePath = computeExecutablePath({ ...options, platform });
253
- const installPath = installationDir(options.cacheDir, options.browser, platform, options.buildId);
125
+ const installPath = installationDir(options.cacheDir, platform, options.buildId);
254
126
  if (await pathExists(executablePath)) {
255
- return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
127
+ return { buildId: options.buildId, platform, path: installPath, executablePath };
256
128
  }
257
129
 
258
130
  await fsp.mkdir(path.dirname(installPath), { recursive: true });
@@ -267,7 +139,7 @@ export async function install(options: InstallOptions): Promise<InstalledBrowser
267
139
  const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
268
140
  try {
269
141
  await downloadArchive(
270
- getDownloadUrl(options.browser, platform, options.buildId, options.baseUrl),
142
+ getDownloadUrl(platform, options.buildId, options.baseUrl),
271
143
  archivePath,
272
144
  options.downloadProgressCallback,
273
145
  );
@@ -284,34 +156,12 @@ export async function install(options: InstallOptions): Promise<InstalledBrowser
284
156
  ]);
285
157
  }
286
158
  }
287
- return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
159
+ return { buildId: options.buildId, platform, path: installPath, executablePath };
288
160
  },
289
161
  { retries: Math.ceil(DOWNLOAD_TIMEOUT_MS / 100) + 1, retryDelayMs: 100 },
290
162
  );
291
163
  }
292
164
 
293
- function chromeChannelName(tag: string): string | undefined {
294
- switch (tag) {
295
- case BrowserTag.STABLE:
296
- return "Stable";
297
- case BrowserTag.BETA:
298
- return "Beta";
299
- case BrowserTag.DEV:
300
- return "Dev";
301
- case BrowserTag.CANARY:
302
- return "Canary";
303
- default:
304
- return undefined;
305
- }
306
- }
307
-
308
- async function fetchMetadata<T>(filename: string): Promise<T> {
309
- const response = await fetch(`${CHROME_METADATA_BASE_URL}/${filename}`, { signal: AbortSignal.timeout(30_000) });
310
- if (!response.ok)
311
- throw new Error(`Failed to fetch Chrome-for-Testing metadata (${response.status} ${response.statusText})`);
312
- return (await response.json()) as T;
313
- }
314
-
315
165
  function chromeArchivePlatform(platform: BrowserPlatform): string {
316
166
  switch (requireChromeForTestingPlatform(platform)) {
317
167
  case BrowserPlatform.LINUX:
@@ -327,17 +177,8 @@ function chromeArchivePlatform(platform: BrowserPlatform): string {
327
177
  }
328
178
  }
329
179
 
330
- function installationDir(cacheDir: string, browser: Browser, platform: BrowserPlatform, buildId: string): string {
331
- return path.join(cacheDir, browser, `${platform}-${buildId}`);
332
- }
333
-
334
- function parseInstallationName(name: string): { platform: BrowserPlatform; buildId: string } | undefined {
335
- for (const platform of BROWSER_PLATFORMS) {
336
- const prefix = `${platform}-`;
337
- if (name.startsWith(prefix) && name.length > prefix.length)
338
- return { platform, buildId: name.slice(prefix.length) };
339
- }
340
- return undefined;
180
+ function installationDir(cacheDir: string, platform: BrowserPlatform, buildId: string): string {
181
+ return path.join(cacheDir, "chrome", `${platform}-${buildId}`);
341
182
  }
342
183
 
343
184
  function isMissingPath(error: unknown): boolean {
package/src/color.ts CHANGED
@@ -239,7 +239,7 @@ const ANSI_16: readonly (readonly [number, number, number])[] = [
239
239
  const CUBE_STEPS = [0, 95, 135, 175, 215, 255] as const;
240
240
 
241
241
  /** Parse a 256-color palette index (0–255) to RGB (0..255). */
242
- function paletteToRgb(index: number): RGB | undefined {
242
+ export function paletteToRgb(index: number): RGB | undefined {
243
243
  if (!Number.isInteger(index) || index < 0 || index > 255) return undefined;
244
244
  if (index < 16) {
245
245
  const rgb = ANSI_16[index];
package/src/dirs.ts CHANGED
@@ -104,7 +104,7 @@ function readProfileFromEnvSafe(): string | undefined {
104
104
  }
105
105
  }
106
106
 
107
- /** Profile-independent config root (~/.zeta), shared by every omp profile. */
107
+ /** Profile-independent config root (~/.zeta), shared by every zeta profile. */
108
108
  export function getBaseConfigRoot(): string {
109
109
  return path.join(os.homedir(), getConfigDirName());
110
110
  }
@@ -154,19 +154,31 @@ export function normalizePathForComparison(inputPath: string): string {
154
154
  return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
155
155
  }
156
156
 
157
+ /**
158
+ * Compare paths already normalized by {@link normalizePathForComparison}.
159
+ *
160
+ * Returns the relative path (an empty string when the paths are equal), or
161
+ * `null` when the candidate is outside the root. Callers classifying one
162
+ * candidate against several static roots can normalize each side once and
163
+ * reuse the public helpers' containment semantics without repeating realpath
164
+ * work.
165
+ */
166
+ export function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null {
167
+ const relative = path.relative(normalizedRoot, normalizedCandidate);
168
+ if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) return null;
169
+ return relative;
170
+ }
171
+
157
172
  export function pathIsWithin(root: string, candidate: string): boolean {
158
173
  const normalizedRoot = normalizePathForComparison(root);
159
174
  const normalizedCandidate = normalizePathForComparison(candidate);
160
- const relative = path.relative(normalizedRoot, normalizedCandidate);
161
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
175
+ return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
162
176
  }
163
177
 
164
178
  export function relativePathWithinRoot(root: string, candidate: string): string | null {
165
- if (!pathIsWithin(root, candidate)) return null;
166
179
  const normalizedRoot = normalizePathForComparison(root);
167
180
  const normalizedCandidate = normalizePathForComparison(candidate);
168
- const relative = path.relative(normalizedRoot, normalizedCandidate);
169
- return relative || null;
181
+ return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) || null;
170
182
  }
171
183
 
172
184
  let projectDir: string | undefined;
@@ -899,6 +911,16 @@ export function getTerminalSessionsDir(agentDir?: string): string {
899
911
  return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
900
912
  }
901
913
 
914
+ /**
915
+ * Get the persistent registry of custom session files
916
+ * (~/.zeta/agent/custom-session-files). Each `--session-dir`/`--session`
917
+ * transcript is recorded here as one marker file so storage GC can scan its
918
+ * exact path after its terminal breadcrumb is overwritten by a later session.
919
+ */
920
+ export function getCustomSessionFilesDir(agentDir?: string): string {
921
+ return dirs.agentSubdir(agentDir, "custom-session-files", "state");
922
+ }
923
+
902
924
  /** Get the crash log path (~/.zeta/agent/zeta-crash.log). */
903
925
  export function getCrashLogPath(agentDir?: string): string {
904
926
  return dirs.agentSubdir(agentDir, "zeta-crash.log", "state");
package/src/env.ts CHANGED
@@ -1,6 +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 { parseEnv } from "node:util";
4
5
  import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
5
6
 
6
7
  export * from "./worker-host";
@@ -64,6 +65,48 @@ export function filterProcessEnv(env: Record<string, string | undefined>): Recor
64
65
  }
65
66
  return result;
66
67
  }
68
+ /**
69
+ * Git variables that pin a repository location. They describe the checkout the
70
+ * agent process itself was launched from (git hooks, `git --git-dir` wrappers),
71
+ * so forwarding them to a child shell makes `git` ignore the command's `cwd`
72
+ * and mutate the wrong worktree or index. Stripped from child shell envs so git
73
+ * rediscovers the repository from the working directory. Mirrors the
74
+ * `env_remove` list in `crates/pi-vcs/src/git/cli.rs`.
75
+ */
76
+ const GIT_REPO_LOCATION_ENV_NAMES = [
77
+ "GIT_DIR",
78
+ "GIT_COMMON_DIR",
79
+ "GIT_WORK_TREE",
80
+ "GIT_INDEX_FILE",
81
+ "GIT_OBJECT_DIRECTORY",
82
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
83
+ ] as const;
84
+
85
+ /**
86
+ * Removes {@link GIT_REPO_LOCATION_ENV_NAMES} from a copied child env in place.
87
+ *
88
+ * Windows environment lookups are case-insensitive, so a block that spells a
89
+ * variable `git_dir` is just as binding there; match case-insensitively on
90
+ * win32 and exactly elsewhere (POSIX env names are case-sensitive).
91
+ */
92
+ export function stripGitRepoLocationEnv(
93
+ env: Record<string, string>,
94
+ platform: NodeJS.Platform = process.platform,
95
+ ): void {
96
+ if (platform !== "win32") {
97
+ for (const name of GIT_REPO_LOCATION_ENV_NAMES) {
98
+ delete env[name];
99
+ }
100
+ return;
101
+ }
102
+ const folded = new Set<string>(GIT_REPO_LOCATION_ENV_NAMES.map(name => name.toLowerCase()));
103
+ for (const key of Object.keys(env)) {
104
+ if (folded.has(key.toLowerCase())) {
105
+ delete env[key];
106
+ }
107
+ }
108
+ }
109
+
67
110
  // Bun autoloads the project's dotenv files into `process.env` before user code
68
111
  // runs — including inside `bun build --compile` binaries — so a snapshot of
69
112
  // `Bun.env` is only pre-dotenv when autoloading was explicitly disabled. Linux
@@ -107,10 +150,10 @@ function expandDotenvValues(values: Record<string, string>, env: Record<string,
107
150
  return expanded;
108
151
  }
109
152
 
110
- /** Filters process env for child shells without launch-cwd dotenv values. */
111
- export function filterChildShellEnv(
153
+ function filterChildShellEnvInternal(
112
154
  env: Record<string, string | undefined>,
113
- cwd: string = getProjectDir(),
155
+ cwd: string,
156
+ onDotenvValue?: (value: string) => void,
114
157
  ): Record<string, string> {
115
158
  const runtimeLaunchEnvValues = env === Bun.env || env === process.env ? launchEnvValues : undefined;
116
159
  const result = filterProcessEnv(env);
@@ -147,6 +190,12 @@ export function filterChildShellEnv(
147
190
  }
148
191
  }
149
192
  const allLaunchEnv = fallbackLaunchEnv ? { ...launchEnv, ...fallbackLaunchEnv } : launchEnv;
193
+ if (onDotenvValue) {
194
+ // Every value the project's dotenv files define is dotenv-sourced, whether
195
+ // or not this process loaded it (a `--cwd` launch never did).
196
+ for (const key in allLaunchEnv) onDotenvValue(allLaunchEnv[key]!);
197
+ for (const key in expandedLaunchEnv) onDotenvValue(expandedLaunchEnv[key]!);
198
+ }
150
199
  for (const key in allLaunchEnv) {
151
200
  const launchValue = runtimeLaunchEnvValues?.get(key);
152
201
  if (launchValue !== undefined) {
@@ -168,6 +217,8 @@ export function filterChildShellEnv(
168
217
  // Strong provenance: the launch environment is known and this name is
169
218
  // absent from it, or OMP itself injected the value — either way it came
170
219
  // from a project dotenv file, not the parent shell.
220
+ const value = result[key];
221
+ if (value !== undefined) onDotenvValue?.(value);
171
222
  delete result[key];
172
223
  } else if (
173
224
  result[key] === launchEnv[key] ||
@@ -177,50 +228,46 @@ export function filterChildShellEnv(
177
228
  ) {
178
229
  // No launch-env snapshot (dotenv autoloaded without procfs): best-effort
179
230
  // value match against the Bun-parsed dotenv.
231
+ const value = result[key];
232
+ if (value !== undefined) onDotenvValue?.(value);
180
233
  delete result[key];
181
234
  }
182
235
  }
236
+ // Last, after dotenv merging: no source (inherited, launcher, or dotenv) may
237
+ // pin the child shell to the agent's own repository.
238
+ stripGitRepoLocationEnv(result);
183
239
  return result;
184
240
  }
185
241
 
186
- /**
187
- * Parse one dotenv line with Bun-compatible semantics: an optional `export`
188
- * prefix, full-line `#` comments, inline `#` comments after whitespace on
189
- * unquoted values, and single/double/backtick quoting (a `#` inside quotes
190
- * stays literal). Returns undefined for blank lines, comments, and malformed
191
- * names.
192
- */
193
- function parseEnvLine(line: string): { key: string; value: string } | undefined {
194
- const trimmed = line.trim();
195
- if (!trimmed || trimmed.startsWith("#")) return undefined;
196
- const eqIndex = trimmed.indexOf("=");
197
- if (eqIndex === -1) return undefined;
198
- let key = trimmed.slice(0, eqIndex).trim();
199
- const exported = key.match(/^export[ \t]+(.*)$/);
200
- if (exported) key = exported[1].trim();
201
- if (!isValidEnvName(key)) return undefined;
202
- const raw = trimmed.slice(eqIndex + 1).replace(/^[ \t]+/, "");
203
- const quote = raw[0];
204
- if (quote === '"' || quote === "'" || quote === "`") {
205
- let close = raw.indexOf(quote, 1);
206
- while (close !== -1 && raw[close - 1] === "\\") close = raw.indexOf(quote, close + 1);
207
- return { key, value: close === -1 ? raw.slice(1) : raw.slice(1, close) };
208
- }
209
- const commentIndex = raw.search(/[ \t]#/);
210
- return { key, value: (commentIndex === -1 ? raw : raw.slice(0, commentIndex)).trimEnd() };
242
+ /** Filters process env for child shells without launch-cwd dotenv values. */
243
+ export function filterChildShellEnv(
244
+ env: Record<string, string | undefined>,
245
+ cwd: string = getProjectDir(),
246
+ ): Record<string, string> {
247
+ return filterChildShellEnvInternal(env, cwd);
248
+ }
249
+
250
+ /** Return every value defined by `cwd`'s dotenv files, plus environment values that came from them. */
251
+ export function getDotenvEnvValues(
252
+ cwd: string = getProjectDir(),
253
+ env: Record<string, string | undefined> = process.env,
254
+ ): string[] {
255
+ const values = new Set<string>();
256
+ filterChildShellEnvInternal(env, cwd, value => values.add(value));
257
+ return [...values];
211
258
  }
212
259
 
213
260
  /**
214
- * Parses a .env file synchronously into key-value string pairs using
215
- * {@link parseEnvLine} for Bun-compatible line semantics.
261
+ * Parses a complete .env file with the runtime's dotenv grammar, then retains
262
+ * only shell-identifier names and spawn-safe values.
216
263
  */
217
264
  export function parseEnvFile(filePath: string): Record<string, string> {
218
265
  const result: Record<string, string> = {};
219
266
  try {
220
- const content = fs.readFileSync(filePath, "utf-8");
221
- for (const line of content.split("\n")) {
222
- const parsed = parseEnvLine(line);
223
- if (parsed && isSafeEnvValue(parsed.value)) result[parsed.key] = parsed.value;
267
+ const parsed = parseEnv(fs.readFileSync(filePath, "utf-8"));
268
+ for (const key in parsed) {
269
+ const value = parsed[key];
270
+ if (value !== undefined && isValidEnvName(key) && isSafeEnvValue(value)) result[key] = value;
224
271
  }
225
272
  } catch {
226
273
  // File doesn't exist or can't be read - return empty result
@@ -0,0 +1,17 @@
1
+ import * as fs from "node:fs";
2
+
3
+ /**
4
+ * Check if a file path exists, is a regular file, and has effective execute permission.
5
+ */
6
+ export function isExecutable(filePath: string): boolean {
7
+ try {
8
+ const stat = fs.statSync(filePath);
9
+ if (!stat.isFile()) return false;
10
+ if (process.platform !== "win32") {
11
+ fs.accessSync(filePath, fs.constants.X_OK);
12
+ }
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }