@xynogen/pix-runtime 0.10.1 → 0.12.2

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.
@@ -6,6 +6,7 @@
6
6
  * Headless hosts get a notify summary instead of the overlay.
7
7
  */
8
8
 
9
+ import { dirname } from "node:path";
9
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
11
  import {
11
12
  Key,
@@ -15,6 +16,7 @@ import {
15
16
  visibleWidth,
16
17
  wrapTextWithAnsi,
17
18
  } from "@earendil-works/pi-tui";
19
+ import { createBinariesTab } from "./binaries-tab.ts";
18
20
  import { icon } from "./icon-catalog.ts";
19
21
  import type { PixRuntime } from "./runtime.ts";
20
22
  import type { DeepPartial, SectionHandle } from "./schema.ts";
@@ -184,6 +186,13 @@ const SETTINGS: SettingRow<unknown>[] = [
184
186
  }),
185
187
  ];
186
188
 
189
+ /** Env whose agent dir is the folder holding pix.json (binary.json lives beside it). */
190
+ function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
191
+ return { ...process.env, PI_CODING_AGENT_DIR: dirname(runtime.path) };
192
+ }
193
+
194
+ const TABS = ["Settings", "Binaries"] as const;
195
+
187
196
  function buildSummary(runtime: PixRuntime): string {
188
197
  const lines = [`Pix Settings (${runtime.path})`, ""];
189
198
  let lastSection = "";
@@ -197,6 +206,12 @@ function buildSummary(runtime: PixRuntime): string {
197
206
  const isDefault = value === row.read(row.handle.defaults);
198
207
  lines.push(` ${row.label}: ${value}${isDefault ? "" : " *"}`);
199
208
  }
209
+ const binaries = createBinariesTab({
210
+ env: binariesEnv(runtime),
211
+ theme: { fg: (_c, t) => t, bold: (t) => t },
212
+ requestRender: () => {},
213
+ });
214
+ lines.push("", "[Binaries]", ...binaries.summary());
200
215
  return lines.join("\n");
201
216
  }
202
217
 
@@ -236,6 +251,29 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
236
251
  let visibleBodyLines = 1;
237
252
  let maxBodyOffset = 0;
238
253
  let inspectingPage = false;
254
+ let tab: (typeof TABS)[number] = "Settings";
255
+ let binaries: ReturnType<typeof createBinariesTab> | undefined;
256
+ const binariesTab = () => {
257
+ binaries ??= createBinariesTab({
258
+ env: binariesEnv(runtime),
259
+ theme,
260
+ requestRender: () => tui.requestRender(),
261
+ });
262
+ return binaries;
263
+ };
264
+ const tabBar = () =>
265
+ TABS.map((name) =>
266
+ name === tab
267
+ ? theme.fg("accent", theme.bold(` ${name} `))
268
+ : theme.fg("muted", ` ${name} `),
269
+ ).join(theme.fg("muted", "│"));
270
+ const switchTab = (direction: -1 | 1) => {
271
+ const next = (TABS.indexOf(tab) + direction + TABS.length) % TABS.length;
272
+ tab = TABS[next] ?? "Settings";
273
+ bodyOffset = 0;
274
+ inspectingPage = false;
275
+ if (tab === "Binaries") binariesTab().refresh();
276
+ };
239
277
 
240
278
  const cycle = (direction: -1 | 1) => {
241
279
  const row = SETTINGS[selected];
@@ -274,6 +312,40 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
274
312
 
275
313
  return {
276
314
  render: (width: number) => {
315
+ const frame = {
316
+ width,
317
+ maxHeight: modalHeight(
318
+ tui.terminal?.rows,
319
+ runtime.get(prettySection).maxRenderHeight,
320
+ ),
321
+ title: `${icon("settings")} Pix Settings`,
322
+ titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
323
+ color: (s: string) => theme.fg("accent", s),
324
+ bg: (s: string) => theme.bg("customMessageBg", s),
325
+ };
326
+ const tabGuide = guide("tab", "switch tab");
327
+ if (tab === "Binaries") {
328
+ const bt = binariesTab();
329
+ const view = bt.view(width);
330
+ const hints = view.footer.map(([key, action]) => guide(key, action)).join(guideSep);
331
+ const result = frameModal({
332
+ ...frame,
333
+ header: [tabBar(), "", ...view.header],
334
+ body: view.body,
335
+ footer: [
336
+ "",
337
+ bt.editing
338
+ ? hints
339
+ : hints + guideSep + tabGuide + guideSep + guide("esc", "close"),
340
+ ],
341
+ bodyOffset,
342
+ selectedBodyLine: inspectingPage ? undefined : view.selectedBodyLine,
343
+ });
344
+ bodyOffset = result.bodyOffset;
345
+ visibleBodyLines = Math.max(1, result.visibleBodyLines);
346
+ maxBodyOffset = result.maxBodyOffset;
347
+ return result.lines;
348
+ }
277
349
  const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
278
350
  const body: string[] = [];
279
351
  const settingBodyLines: number[] = [];
@@ -295,14 +367,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
295
367
  body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
296
368
  }
297
369
  const result = frameModal({
298
- width,
299
- maxHeight: modalHeight(
300
- tui.terminal?.rows,
301
- runtime.get(prettySection).maxRenderHeight,
302
- ),
303
- title: `${icon("settings")} Pix Settings`,
304
- titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
305
- header: [""],
370
+ ...frame,
371
+ header: [tabBar(), ""],
306
372
  body,
307
373
  footer: [
308
374
  "",
@@ -312,12 +378,12 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
312
378
  guideSep +
313
379
  guide("PgUp/PgDn", "inspect") +
314
380
  guideSep +
381
+ tabGuide +
382
+ guideSep +
315
383
  guide("esc", "close"),
316
384
  ],
317
385
  bodyOffset,
318
386
  selectedBodyLine: inspectingPage ? undefined : settingBodyLines[selected],
319
- color: (s: string) => theme.fg("accent", s),
320
- bg: (s: string) => theme.bg("customMessageBg", s),
321
387
  });
322
388
  bodyOffset = result.bodyOffset;
323
389
  visibleBodyLines = Math.max(1, result.visibleBodyLines);
@@ -326,6 +392,24 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
326
392
  },
327
393
  invalidate: () => {},
328
394
  handleInput: (data: string) => {
395
+ if (tab === "Binaries" && binariesTab().editing) {
396
+ // The filter input takes ↑↓ to move through its matches.
397
+ const nav = !binariesTab().editingPath;
398
+ binariesTab().handleInput(data, {
399
+ up: nav && kb.matches(data, "tui.select.up"),
400
+ down: nav && kb.matches(data, "tui.select.down"),
401
+ enter: false,
402
+ });
403
+ inspectingPage = false;
404
+ tui.requestRender();
405
+ return;
406
+ }
407
+ const shiftTab = matchesKey(data, Key.shift(Key.tab));
408
+ if (shiftTab || matchesKey(data, Key.tab)) {
409
+ switchTab(shiftTab ? -1 : 1);
410
+ tui.requestRender();
411
+ return;
412
+ }
329
413
  const pageUp = kb?.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp);
330
414
  const pageDown =
331
415
  kb?.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown);
@@ -340,9 +424,25 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
340
424
  // for letters and special keys alike — raw string compares like
341
425
  // `data === "k"` silently fail under the Kitty keyboard protocol.
342
426
  if (kb.matches(data, "tui.select.cancel")) {
427
+ if (tab === "Binaries" && binariesTab().clearFilter()) {
428
+ tui.requestRender();
429
+ return;
430
+ }
343
431
  done(null);
344
432
  return;
345
433
  }
434
+ if (tab === "Binaries") {
435
+ const handled = binariesTab().handleInput(data, {
436
+ up: kb.matches(data, "tui.select.up"),
437
+ down: kb.matches(data, "tui.select.down"),
438
+ enter: kb.matches(data, "tui.select.confirm"),
439
+ });
440
+ if (handled) {
441
+ inspectingPage = false;
442
+ tui.requestRender();
443
+ }
444
+ return;
445
+ }
346
446
  if (kb.matches(data, "tui.select.up")) move(-1);
347
447
  else if (kb.matches(data, "tui.select.down")) move(1);
348
448
  else if (matchesKey(data, Key.left)) cycle(-1);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * platform.ts — one host description for binary selection and OS branches.
3
+ *
4
+ * Pure except for two cached probes (glibc via `process.report`, WSL via
5
+ * `/proc/version`); every input is injectable so tests cover all hosts.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+
10
+ export type HostOs = "win32" | "linux" | "darwin" | "android";
11
+ export type HostArch = "x64" | "arm64";
12
+
13
+ export interface HostPlatform {
14
+ os: HostOs;
15
+ arch: HostArch;
16
+ /** Linux C library; undefined elsewhere. */
17
+ libc?: "glibc" | "musl";
18
+ /** Linux kernel under Windows Subsystem for Linux. */
19
+ wsl: boolean;
20
+ /** Termux on Android. */
21
+ termux: boolean;
22
+ /** Executable suffix. */
23
+ exe: "" | ".exe";
24
+ }
25
+
26
+ export interface HostProbe {
27
+ platform?: NodeJS.Platform;
28
+ arch?: string;
29
+ env?: NodeJS.ProcessEnv;
30
+ /** glibc runtime version, or undefined on musl. */
31
+ glibcVersion?: () => string | undefined;
32
+ /** Contents of `/proc/version`, or undefined when unreadable. */
33
+ procVersion?: () => string | undefined;
34
+ }
35
+
36
+ function defaultGlibc(): string | undefined {
37
+ try {
38
+ const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } };
39
+ return report?.header?.glibcVersionRuntime;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ function defaultProcVersion(): string | undefined {
46
+ try {
47
+ return readFileSync("/proc/version", "utf-8");
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /** Describe the current host (or an injected one). */
54
+ export function hostPlatform(probe: HostProbe = {}): HostPlatform {
55
+ const platform = probe.platform ?? process.platform;
56
+ const env = probe.env ?? process.env;
57
+ const arch: HostArch = (probe.arch ?? process.arch) === "arm64" ? "arm64" : "x64";
58
+ const termux = Boolean(env.TERMUX_VERSION) || platform === "android";
59
+ if (platform === "win32") return { os: "win32", arch, wsl: false, termux: false, exe: ".exe" };
60
+ if (platform === "darwin") return { os: "darwin", arch, wsl: false, termux: false, exe: "" };
61
+ if (termux) return { os: "android", arch, wsl: false, termux: true, exe: "" };
62
+ const glibc = (probe.glibcVersion ?? defaultGlibc)();
63
+ const wsl =
64
+ Boolean(env.WSL_DISTRO_NAME || env.WSLENV) ||
65
+ /microsoft|wsl/i.test((probe.procVersion ?? defaultProcVersion)() ?? "");
66
+ return { os: "linux", arch, libc: glibc ? "glibc" : "musl", wsl, termux: false, exe: "" };
67
+ }
68
+
69
+ let cached: HostPlatform | undefined;
70
+
71
+ /** Cached {@link hostPlatform} for the running process. */
72
+ export function currentPlatform(): HostPlatform {
73
+ cached ??= hostPlatform();
74
+ return cached;
75
+ }
package/src/runtime.ts CHANGED
@@ -6,7 +6,6 @@
6
6
  * after a lazy load; writes are serialized and atomic.
7
7
  */
8
8
 
9
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
9
  import { DiagnosticSink } from "./diagnostics.ts";
11
10
  import {
12
11
  type ConfigChange,
@@ -18,8 +17,9 @@ import {
18
17
  type SubscribeOptions,
19
18
  } from "./events.ts";
20
19
  import { importOptimizerSidecar, migrate } from "./migrations.ts";
20
+ import { agentDir } from "./paths.ts";
21
21
  import {
22
- ConfigWriteError,
22
+ ConfigParseError,
23
23
  FileStorage,
24
24
  parseRawDocument,
25
25
  type StorageAdapter,
@@ -99,7 +99,7 @@ class RuntimeImpl implements PixRuntime {
99
99
  private readOnly = false;
100
100
 
101
101
  constructor(adapters: RuntimeAdapters = {}) {
102
- this.agentDir = adapters.agentDir ?? getAgentDir();
102
+ this.agentDir = adapters.agentDir ?? agentDir();
103
103
  this.storage = adapters.storage ?? new FileStorage(this.agentDir);
104
104
  this.registry = adapters.registry ?? new SectionRegistry();
105
105
  }
@@ -139,16 +139,45 @@ class RuntimeImpl implements PixRuntime {
139
139
  try {
140
140
  doc = parseRawDocument(this.storage.readRaw());
141
141
  } catch (err) {
142
- this.sink.push({
143
- code: "READ_FAILED",
144
- severity: "warning",
145
- message: "config read failed; using defaults",
146
- cause: err,
147
- });
142
+ if (err instanceof ConfigParseError) this.parseFailed(err);
143
+ else
144
+ this.sink.push({
145
+ code: "READ_FAILED",
146
+ severity: "warning",
147
+ message: "config read failed; using defaults",
148
+ cause: err,
149
+ });
148
150
  }
149
151
  return this.publish(this.resolve(doc));
150
152
  }
151
153
 
154
+ /** A malformed pix.json stays untouched: defaults apply, writes are refused. */
155
+ private parseFailed(err: ConfigParseError, path?: string): void {
156
+ this.sink.push({
157
+ code: "PARSE_ERROR",
158
+ severity: "error",
159
+ path,
160
+ message: `${this.storage.path}: ${err.message}. File left unchanged; fix it to save settings.`,
161
+ cause: err,
162
+ });
163
+ }
164
+
165
+ /** Migration + sidecar import for one raw file read. Pure except `ctx`. */
166
+ private planInit(raw: string | undefined, ctx: ParseContext) {
167
+ const doc = parseRawDocument(raw);
168
+ const migrated = migrate(doc, ctx);
169
+ const readOnly = migrated.document.$version !== CONFIG_FORMAT_VERSION;
170
+ let needsWrite = migrated.changed;
171
+ let archive: (() => void) | undefined;
172
+ if (!readOnly) {
173
+ const sidecar = importOptimizerSidecar(migrated.document, this.agentDir, ctx);
174
+ if (sidecar.changed) needsWrite = true;
175
+ archive = sidecar.archive;
176
+ }
177
+ // Do not normalize a future-version file.
178
+ return { working: readOnly ? doc : migrated.document, readOnly, needsWrite, archive };
179
+ }
180
+
152
181
  snapshot(): ConfigSnapshot {
153
182
  return this.current ?? this.lazyLoad();
154
183
  }
@@ -182,36 +211,38 @@ class RuntimeImpl implements PixRuntime {
182
211
  const hadLazy = this.current !== null;
183
212
  const previous = this.current;
184
213
  this.storage.ensureDir();
185
- const doc = parseRawDocument(this.storage.readRaw());
186
- const ctx = this.parseContext();
187
-
188
- const migrated = migrate(doc, ctx);
189
- this.readOnly = migrated.document.$version !== CONFIG_FORMAT_VERSION;
190
-
191
- let working = migrated.document;
192
- let needsWrite = migrated.changed;
193
- let archive: (() => void) | undefined;
194
-
195
- if (!this.readOnly) {
196
- const sidecar = importOptimizerSidecar(working, this.agentDir, ctx);
197
- if (sidecar.changed) needsWrite = true;
198
- archive = sidecar.archive;
199
- }
200
-
201
- if (needsWrite && !this.readOnly) {
202
- try {
203
- this.persist(working);
204
- archive?.();
205
- } catch (err) {
206
- this.sink.push({
207
- code: "WRITE_FAILED",
208
- severity: "error",
209
- message: "initial migration write failed",
210
- cause: err,
211
- });
214
+ let working: RawDocument = {};
215
+ let needsWrite = false;
216
+ try {
217
+ // Unlocked probe: most sessions need no write, so they never take the lock.
218
+ let plan = this.planInit(this.storage.readRaw(), this.parseContext());
219
+ this.readOnly = plan.readOnly;
220
+ working = plan.working;
221
+ if (plan.needsWrite && !plan.readOnly) {
222
+ try {
223
+ // Re-plan under the lock so a concurrent writer's change survives.
224
+ this.storage.transact((raw) => {
225
+ plan = this.planInit(raw, { diagnostic: () => {} });
226
+ working = plan.working;
227
+ needsWrite = plan.needsWrite && !plan.readOnly;
228
+ return needsWrite ? serializeRawDocument(plan.working) : undefined;
229
+ });
230
+ if (needsWrite) plan.archive?.();
231
+ } catch (err) {
232
+ needsWrite = false;
233
+ if (err instanceof ConfigParseError) throw err;
234
+ this.sink.push({
235
+ code: "WRITE_FAILED",
236
+ severity: "error",
237
+ message: "initial migration write failed",
238
+ cause: err,
239
+ });
240
+ }
212
241
  }
213
- } else if (this.readOnly) {
214
- working = doc; // do not normalize a future-version file
242
+ } catch (err) {
243
+ if (!(err instanceof ConfigParseError)) throw err;
244
+ this.parseFailed(err);
245
+ working = {};
215
246
  }
216
247
 
217
248
  const values = this.resolve(working);
@@ -242,6 +273,10 @@ class RuntimeImpl implements PixRuntime {
242
273
  }
243
274
  return snapshot;
244
275
  });
276
+ // A failed init (unreadable agent dir) must not stay cached: the next call retries.
277
+ this.initPromise.catch(() => {
278
+ this.initPromise = null;
279
+ });
245
280
  return this.initPromise;
246
281
  }
247
282
 
@@ -267,10 +302,6 @@ class RuntimeImpl implements PixRuntime {
267
302
  return stripDefaults(copy, defaults) > 0 ? copy : undefined;
268
303
  }
269
304
 
270
- private persist(doc: RawDocument): void {
271
- this.storage.writeAtomic(serializeRawDocument(doc));
272
- }
273
-
274
305
  private changedPaths(prev: ConfigSnapshot, next: ConfigSnapshot): string[] {
275
306
  const out: string[] = [];
276
307
  for (const section of this.registry.all()) {
@@ -339,49 +370,56 @@ class RuntimeImpl implements PixRuntime {
339
370
  }
340
371
 
341
372
  const previous = this.snapshot();
342
- const base = parseRawDocument(this.storage.readRaw());
343
373
  const ctx = this.parseContext();
374
+ let values: Map<string, unknown> | undefined;
344
375
 
345
- // Re-resolve all sections from the latest on-disk doc so unknown fields
346
- // and sibling sections survive.
347
- const values = new Map<string, unknown>();
348
- for (const s of this.registry.all()) values.set(s.key, s.parse(base[s.key], ctx));
349
-
350
- // Guard against a version-skewed second runtime copy whose registry
351
- // lacks this section (e.g. an old npm copy handling a new handle). The
352
- // functional updater would otherwise crash the whole process on
353
- // `undefined`. Fall back to the handle's own parse of the raw doc.
354
- const currentValue = (
355
- values.has(section.key)
356
- ? values.get(section.key)
357
- : section.__section.parse(base[section.key], ctx)
358
- ) as Readonly<T>;
359
- const mergedValue =
360
- typeof updater === "function"
361
- ? (updater as (c: Readonly<T>) => T)(currentValue)
362
- : deepMerge(currentValue as T, updater);
363
-
364
- // Re-validate through the section's own parse so a bad patch (NaN,
365
- // Infinity, wrong type) can never enter the live snapshot.
366
- const nextValue = section.__section.parse(mergedValue, ctx) as T;
367
-
368
- if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
369
- values.set(section.key, nextValue);
370
-
371
- const doc = this.buildRawFromValues(values, base);
372
376
  try {
373
- this.persist(doc);
374
- } catch (err) {
375
- this.sink.push({
376
- code: "WRITE_FAILED",
377
- severity: "error",
378
- path: section.key,
379
- message: "config write failed; snapshot unchanged",
380
- cause: err,
377
+ // Read, patch, and write under one lock hold: a second Pi process cannot
378
+ // write between our read and our write, so its change is not lost.
379
+ this.storage.transact((raw) => {
380
+ const base = parseRawDocument(raw);
381
+
382
+ // Re-resolve all sections from the latest on-disk doc so unknown fields
383
+ // and sibling sections survive.
384
+ const next = new Map<string, unknown>();
385
+ for (const s of this.registry.all()) next.set(s.key, s.parse(base[s.key], ctx));
386
+
387
+ // Guard against a version-skewed second runtime copy whose registry
388
+ // lacks this section (e.g. an old npm copy handling a new handle). The
389
+ // functional updater would otherwise crash the whole process on
390
+ // `undefined`. Fall back to the handle's own parse of the raw doc.
391
+ const currentValue = (
392
+ next.has(section.key)
393
+ ? next.get(section.key)
394
+ : section.__section.parse(base[section.key], ctx)
395
+ ) as Readonly<T>;
396
+ const mergedValue =
397
+ typeof updater === "function"
398
+ ? (updater as (c: Readonly<T>) => T)(currentValue)
399
+ : deepMerge(currentValue as T, updater);
400
+
401
+ // Re-validate through the section's own parse so a bad patch (NaN,
402
+ // Infinity, wrong type) can never enter the live snapshot.
403
+ const nextValue = section.__section.parse(mergedValue, ctx) as T;
404
+
405
+ if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
406
+ next.set(section.key, nextValue);
407
+ values = next;
408
+ return serializeRawDocument(this.buildRawFromValues(next, base));
381
409
  });
382
- if (err instanceof ConfigWriteError) return undefined;
410
+ } catch (err) {
411
+ if (err instanceof ConfigParseError) this.parseFailed(err, section.key);
412
+ else
413
+ this.sink.push({
414
+ code: "WRITE_FAILED",
415
+ severity: "error",
416
+ path: section.key,
417
+ message: "config write failed; snapshot unchanged",
418
+ cause: err,
419
+ });
383
420
  return undefined;
384
421
  }
422
+ if (!values) return undefined;
385
423
 
386
424
  const snapshot = this.publish(values);
387
425
  const change: ConfigChange = {
@@ -401,7 +439,15 @@ class RuntimeImpl implements PixRuntime {
401
439
  reload(options: ReloadOptions = {}): Promise<ConfigChange | undefined> {
402
440
  return this.queue.run(async () => {
403
441
  const previous = this.snapshot();
404
- const doc = parseRawDocument(this.storage.readRaw());
442
+ let doc: RawDocument;
443
+ try {
444
+ doc = parseRawDocument(this.storage.readRaw());
445
+ } catch (err) {
446
+ // Keep the last good snapshot. Do not reset live settings to defaults.
447
+ if (!(err instanceof ConfigParseError)) throw err;
448
+ this.parseFailed(err);
449
+ return undefined;
450
+ }
405
451
  const values = this.resolve(doc);
406
452
  const snapshot = this.publish(values);
407
453
  const changed = this.changedPaths(previous, snapshot);
@@ -0,0 +1,101 @@
1
+ /**
2
+ * safe-path.ts — pre-flight check before a tool writes to a model-chosen path.
3
+ *
4
+ * The rules differ per OS: system dirs, path separators, and case. Callers get
5
+ * one call and a reason they can show to the model.
6
+ */
7
+
8
+ import { constants as fsConstants } from "node:fs";
9
+ import { access, lstat } from "node:fs/promises";
10
+ import { posix, win32 } from "node:path";
11
+ import { homeDir } from "./paths.ts";
12
+ import { currentPlatform, type HostPlatform } from "./platform.ts";
13
+
14
+ export type PathValidation = { ok: true; path: string } | { ok: false; reason: string };
15
+
16
+ export interface SafePathOptions {
17
+ env?: NodeJS.ProcessEnv;
18
+ host?: HostPlatform;
19
+ }
20
+
21
+ /** Absolute prefixes that never get tool output, for this host. */
22
+ export function sensitivePrefixes(opts: SafePathOptions = {}): string[] {
23
+ const host = opts.host ?? currentPlatform();
24
+ const env = opts.env ?? process.env;
25
+ const p = host.os === "win32" ? win32 : posix;
26
+ const home = homeDir(env, host.os);
27
+ const secrets = [".ssh", ".aws", ".gnupg", p.join(".config", "gh")].map((d) => p.join(home, d));
28
+ if (host.os !== "win32") return ["/etc", "/proc", "/sys", "/boot", ...secrets];
29
+ const sys = [env.SystemRoot ?? "C:\\Windows", env.ProgramFiles, env["ProgramFiles(x86)"]];
30
+ const appData = env.APPDATA ?? p.join(home, "AppData", "Roaming");
31
+ return [...sys, p.join(appData, "GitHub CLI"), ...secrets].filter((d): d is string => !!d);
32
+ }
33
+
34
+ /** True when `path` is `prefix` or inside it. Case-insensitive on Windows. */
35
+ function isUnder(path: string, prefix: string, host: HostPlatform): boolean {
36
+ const p = host.os === "win32" ? win32 : posix;
37
+ // win32.relative already compares case-insensitively.
38
+ const rel = p.relative(p.resolve(prefix), p.resolve(path));
39
+ return rel !== ".." && !rel.startsWith(`..${p.sep}`) && !p.isAbsolute(rel);
40
+ }
41
+
42
+ /**
43
+ * Rejects null bytes, sensitive system locations, symlinks (target or any
44
+ * existing parent — Windows junctions count), existing directories, and paths
45
+ * with no writable existing ancestor.
46
+ */
47
+ export async function validateOutputPath(
48
+ absPath: string,
49
+ opts: SafePathOptions = {},
50
+ ): Promise<PathValidation> {
51
+ const host = opts.host ?? currentPlatform();
52
+ const p = host.os === "win32" ? win32 : posix;
53
+ if (absPath.includes("\0")) return { ok: false, reason: "path contains a null byte" };
54
+ // `\\?\C:\Windows\x` and `\\.\C:\...` reach the same files as `C:\Windows\x`, but
55
+ // win32.relative() sees another root, so isUnder() would miss them. Normal
56
+ // output paths never need a device or namespace prefix.
57
+ if (host.os === "win32" && /^[\\/]{2}[?.][\\/]|^\\\?\?\\/.test(absPath))
58
+ return { ok: false, reason: `refusing a Windows device or namespace path: ${absPath}` };
59
+
60
+ for (const prefix of sensitivePrefixes({ ...opts, host })) {
61
+ if (isUnder(absPath, prefix, host))
62
+ return { ok: false, reason: `refusing to write under ${prefix}` };
63
+ }
64
+
65
+ // lstat does not follow symlinks. That is the point here.
66
+ try {
67
+ const st = await lstat(absPath);
68
+ if (st.isSymbolicLink()) return { ok: false, reason: `target is a symlink: ${absPath}` };
69
+ if (st.isDirectory())
70
+ return { ok: false, reason: `target is an existing directory: ${absPath}` };
71
+ } catch (err) {
72
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT")
73
+ return { ok: false, reason: `cannot stat target: ${(err as Error).message}` };
74
+ }
75
+
76
+ // Every existing ancestor must not be a symlink (stops a safe-looking dir → /etc
77
+ // redirect). The nearest one must be writable so mkdir -p works.
78
+ const parent = p.dirname(absPath);
79
+ let nearest: string | null = null;
80
+ for (let cursor = parent; cursor !== p.dirname(cursor); cursor = p.dirname(cursor)) {
81
+ let st: Awaited<ReturnType<typeof lstat>>;
82
+ try {
83
+ st = await lstat(cursor);
84
+ } catch (err) {
85
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
86
+ return { ok: false, reason: `cannot stat parent: ${(err as Error).message}` };
87
+ }
88
+ if (st.isSymbolicLink()) return { ok: false, reason: `parent is a symlink: ${cursor}` };
89
+ if (st.isDirectory() && nearest === null) nearest = cursor;
90
+ }
91
+ if (nearest === null)
92
+ return { ok: false, reason: `no existing ancestor directory for ${parent}` };
93
+ try {
94
+ // ponytail: W_OK reads only the read-only attribute on Windows, not ACLs.
95
+ // A denied ACL still fails later at mkdir/writeFile with a clear EPERM.
96
+ await access(nearest, fsConstants.W_OK);
97
+ } catch {
98
+ return { ok: false, reason: `no writable ancestor directory: ${nearest}` };
99
+ }
100
+ return { ok: true, path: absPath };
101
+ }
package/src/testing.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  * never touch the real agent directory or the process singleton.
4
4
  */
5
5
 
6
- import { mkdtempSync, rmSync } from "node:fs";
7
- import { tmpdir } from "node:os";
6
+ import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
8
7
  import { join } from "node:path";
8
+ import { tempDir } from "./paths.ts";
9
9
  import { FileStorage } from "./persistence.ts";
10
10
  import { createRuntime, type PixRuntime } from "./runtime.ts";
11
11
 
@@ -16,9 +16,25 @@ export interface IsolatedRuntime {
16
16
  cleanup(): void;
17
17
  }
18
18
 
19
+ /**
20
+ * True when this process may create symlinks. Windows allows it only in Developer
21
+ * Mode or as admin (else EPERM). Use it to skip symlink tests: `it.skipIf(!canSymlink())`.
22
+ */
23
+ export function canSymlink(): boolean {
24
+ const dir = mkdtempSync(join(tempDir(), "pix-symlink-probe-"));
25
+ try {
26
+ symlinkSync(dir, join(dir, "link"), "dir");
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ } finally {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ }
33
+ }
34
+
19
35
  /** Create a runtime backed by a fresh temp directory. */
20
36
  export function createIsolatedRuntime(): IsolatedRuntime {
21
- const agentDir = mkdtempSync(join(tmpdir(), "pix-runtime-"));
37
+ const agentDir = mkdtempSync(join(tempDir(), "pix-runtime-"));
22
38
  const runtime = createRuntime({ agentDir, storage: new FileStorage(agentDir) });
23
39
  return {
24
40
  runtime,