aegis-desktop 0.8.19 → 0.8.21

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.
@@ -39,6 +39,7 @@
39
39
  */
40
40
 
41
41
  const fs = require('node:fs');
42
+ const os = require('node:os');
42
43
  const path = require('node:path');
43
44
  const { spawnSync } = require('node:child_process');
44
45
 
@@ -80,6 +81,23 @@ function defaultProbe(cmd, args = [], opts = {}) {
80
81
  }
81
82
  }
82
83
 
84
+ function defaultDirExists(dir) {
85
+ try {
86
+ return fs.statSync(dir).isDirectory();
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** Read a text file; `null` for every failure (missing, unreadable, not text). */
93
+ function defaultReadText(file) {
94
+ try {
95
+ return fs.readFileSync(file, 'utf8');
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
83
101
  /** Run a command for its side effect. Never throws; `{ ok, error, output }`. */
84
102
  function defaultRun(cmd, args = [], opts = {}) {
85
103
  try {
@@ -398,6 +416,243 @@ function hasDisplay(options = {}) {
398
416
  return false;
399
417
  }
400
418
 
419
+ // ---------------------------------------------------------------------------
420
+ // homeDir / resolvePath
421
+ // ---------------------------------------------------------------------------
422
+
423
+ /**
424
+ * This user's home directory, `''` when nothing can say what it is.
425
+ *
426
+ * `USERPROFILE` leads on win32 and `HOME` on posix because that is the variable
427
+ * each OS actually sets; the other is accepted second (a Windows git-bash shell
428
+ * exports `HOME`, and a posix host inheriting `USERPROFILE` is harmless), and
429
+ * `os.homedir()` is the last word. Reading the env BEFORE `os.homedir()` is what
430
+ * lets a test point a whole tool run at a temp directory.
431
+ */
432
+ function homeDir(options = {}) {
433
+ const { platform, env, o } = seams(options);
434
+ // An explicit `home` is honoured AS GIVEN, including `''`. That is the seam
435
+ // for "this machine has no home directory" — a case the env/os fallbacks below
436
+ // can never produce on a developer's own box, and therefore one that could
437
+ // not be tested at all if an empty string fell through to them.
438
+ if (typeof o.home === 'string') return o.home.trim();
439
+ const keys = platform === 'win32' ? ['USERPROFILE', 'HOME'] : ['HOME', 'USERPROFILE'];
440
+ for (const key of keys) {
441
+ const value = envValue(env, key, platform);
442
+ if (value && String(value).trim()) return String(value).trim();
443
+ }
444
+ try {
445
+ return os.homedir() || '';
446
+ } catch {
447
+ return '';
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Do the permission bits in `fs.stat().mode` carry real information here?
453
+ *
454
+ * On Windows they do not. libuv has no NTFS ACL to report, so it SYNTHESISES the
455
+ * mode from a single bit — the read-only attribute — and hands back `0o666` for
456
+ * every writable file and `0o444` for every read-only one, directories included.
457
+ * Two consequences, and both were live bugs:
458
+ *
459
+ * - `(mode & 0o077) !== 0` — "is this readable by other users?" — is therefore
460
+ * ALWAYS TRUE on Windows, for every file, no matter how locked down it is.
461
+ * Code that refuses a secret on that test refuses it unconditionally there
462
+ * (tools/agent-gate.mjs would not accept an agent key at all), and code that
463
+ * reports it as exposure reports the user's whole data directory as exposed
464
+ * with no way to fix it (`/doctor` in cli/src/commands.js).
465
+ * - `chmodSync(f, 0o600)` cannot make that test pass, so a repair loop that
466
+ * re-reads the mode to confirm never converges.
467
+ *
468
+ * Where this returns false the boundary is the DIRECTORY's ACL — a per-user
469
+ * profile directory is already inaccessible to other non-admin accounts — which
470
+ * is what `client/authorship-key.js` has always done for the key file. The right
471
+ * behaviour is to skip the judgement and say so, never to assume the worst.
472
+ */
473
+ function hasPosixModes(options = {}) {
474
+ const { win32 } = seams(options);
475
+ return !win32;
476
+ }
477
+
478
+ /**
479
+ * This user's real Desktop directory, or `''` when none can be found.
480
+ *
481
+ * `~/Desktop` is a GUESS, and it is wrong on both of the hosts this function was
482
+ * added for:
483
+ *
484
+ * - Windows with OneDrive. Folder redirection moves the live Desktop to
485
+ * `%USERPROFILE%\OneDrive\Desktop` and commonly leaves an empty
486
+ * `%USERPROFILE%\Desktop` behind, so a write to the obvious path lands in a
487
+ * directory the user is not looking at. `%OneDrive%` (set while OneDrive
488
+ * runs) is consulted first for exactly this case.
489
+ * - A localised Linux desktop. XDG translates the directory ITSELF, not just
490
+ * its label: a Swedish session has `~/Skrivbord` and no `~/Desktop` at all.
491
+ * `$XDG_DESKTOP_DIR` answers it, and `~/.config/user-dirs.dirs` is where
492
+ * that variable is defined when the session did not export it.
493
+ *
494
+ * macOS is the one host where `~/Desktop` is simply correct.
495
+ *
496
+ * Seams: `dirExists` (default: a real statSync isDirectory probe) and `readText`
497
+ * (default: a real utf8 read, `null` on any failure), so all three branches are
498
+ * exercised on one host.
499
+ */
500
+ function desktopDir(options = {}) {
501
+ const { platform, env, win32, o } = seams(options);
502
+ const P = win32 ? path.win32 : path.posix;
503
+ const home = homeDir(options);
504
+ const dirExists = typeof o.dirExists === 'function' ? o.dirExists : defaultDirExists;
505
+ const readText = typeof o.readText === 'function' ? o.readText : defaultReadText;
506
+
507
+ const candidates = [];
508
+ const push = (value) => {
509
+ if (value && String(value).trim()) candidates.push(String(value).trim());
510
+ };
511
+
512
+ if (win32) {
513
+ const oneDrive = envValue(env, 'OneDrive', platform) || envValue(env, 'OneDriveConsumer', platform);
514
+ if (oneDrive) push(P.join(oneDrive, 'Desktop'));
515
+ if (home) push(P.join(home, 'OneDrive', 'Desktop'));
516
+ if (home) push(P.join(home, 'Desktop'));
517
+ } else if (platform === 'darwin') {
518
+ if (home) push(P.join(home, 'Desktop'));
519
+ } else {
520
+ push(envValue(env, 'XDG_DESKTOP_DIR', platform));
521
+ // user-dirs.dirs holds lines like: XDG_DESKTOP_DIR="$HOME/Skrivbord"
522
+ if (home) {
523
+ const conf = readText(P.join(home, '.config', 'user-dirs.dirs'));
524
+ const match = conf && /^\s*XDG_DESKTOP_DIR\s*=\s*"?([^"\n]+)"?\s*$/m.exec(conf);
525
+ if (match) push(match[1].replace(/\$HOME\b|\${HOME}/g, home));
526
+ }
527
+ if (home) push(P.join(home, 'Desktop'));
528
+ }
529
+
530
+ for (const candidate of candidates) {
531
+ if (dirExists(candidate)) return candidate;
532
+ }
533
+ // Nothing on disk. The conventional path is still a better answer than '' for
534
+ // a machine whose Desktop simply has not been created yet — but only where the
535
+ // name is not localised, which is the one case we would be guessing at.
536
+ if (home && (win32 || platform === 'darwin')) return P.join(home, 'Desktop');
537
+ return '';
538
+ }
539
+
540
+ /** `C:\…` / `C:/…` — a drive-qualified Windows path. */
541
+ const WIN_DRIVE_RE = /^[A-Za-z]:[\\/]/;
542
+ /** `C:` with nothing after it — drive-relative, and never what a model means. */
543
+ const WIN_BARE_DRIVE_RE = /^[A-Za-z]:$/;
544
+ /** `\\server\share` — a UNC path, which IS absolute and stays allowed. */
545
+ const WIN_UNC_RE = /^\\\\[^\\?.]/;
546
+
547
+ /**
548
+ * Turn a path a MODEL produced into a path this machine really has.
549
+ *
550
+ * { ok, path, error, expandedHome }
551
+ *
552
+ * Why this exists. The tool layer used to pass `file_path` straight to `fs`,
553
+ * which quietly made two very common model outputs write to the wrong place:
554
+ *
555
+ * - `~/Desktop/notes.txt`. Node does not expand `~`; nothing in the tool
556
+ * layer did either. `fs.mkdirSync(path.dirname(…))` therefore created a
557
+ * directory literally NAMED `~` under the process cwd and wrote inside it —
558
+ * and `writeFile` reported "Wrote 14 bytes to ~/Desktop/notes.txt", so the
559
+ * model told the user it had saved a file to their Desktop that was not
560
+ * there. A wrong write that reports success is worse than a refusal.
561
+ * - `/home/user/Desktop/notes.txt` ON WINDOWS. A leading-slash path there is
562
+ * relative to the current DRIVE, so that write succeeds — at
563
+ * `C:\home\user\Desktop\notes.txt`. Same silent success, same missing file.
564
+ * This is why the bug read as "the tools only work on Linux": on Linux a
565
+ * model's posix guess happens to be right, and off Linux it is accepted
566
+ * anyway. Windows is also where a model reaches for `~` most, because it
567
+ * cannot guess `C:\Users\<name>` and the prompt only names the home dir.
568
+ *
569
+ * So: `~` and `~/…` are expanded (`~other/…` is refused — another user's home
570
+ * is not ours to guess), a path that is absolute for a DIFFERENT OS than this
571
+ * one is refused with the correct form named, a relative path resolves against
572
+ * `cwd`, and anything already native is normalised. A refusal is the useful
573
+ * outcome: the model gets the real home directory back in the error and retries
574
+ * correctly in the next round, where before it got "success" and stopped.
575
+ *
576
+ * Idempotent — the output of one call is unchanged by the next — so a caller
577
+ * may normalise early (before a guard inspects the path) without worrying that
578
+ * a later layer will resolve it a second time.
579
+ *
580
+ * Path SEMANTICS follow the `platform` asked about, not the host, exactly as in
581
+ * `resolveExecutable` above: that is what lets test/platform.test.mjs prove the
582
+ * win32 branch on this Linux CI.
583
+ */
584
+ function resolvePath(input, options = {}) {
585
+ const { platform, win32, o } = seams(options);
586
+ const P = win32 ? path.win32 : path.posix;
587
+ const refuse = (error) => ({ ok: false, path: null, error, expandedHome: false });
588
+
589
+ if (typeof input !== 'string' || !input.trim()) {
590
+ return refuse(`a path is required (got ${JSON.stringify(input)})`);
591
+ }
592
+ const raw = input.trim();
593
+
594
+ // ── the home shorthand ────────────────────────────────────────────────────
595
+ let work = raw;
596
+ let expandedHome = false;
597
+ const tildeRoot = raw === '~' || raw.startsWith('~/') || (win32 && raw.startsWith('~\\'));
598
+ if (tildeRoot) {
599
+ const home = homeDir(options);
600
+ if (!home) {
601
+ return refuse(
602
+ `cannot expand "~" in ${JSON.stringify(raw)}: this machine reports no home directory. ` +
603
+ 'Pass a full absolute path instead.',
604
+ );
605
+ }
606
+ work = raw === '~' ? home : P.join(home, raw.slice(2));
607
+ expandedHome = true;
608
+ } else if (raw.startsWith('~')) {
609
+ return refuse(
610
+ `${JSON.stringify(raw)} names another user's home directory, which cannot be resolved from ` +
611
+ 'here. Use "~" for this user, or pass a full absolute path.',
612
+ );
613
+ }
614
+
615
+ // ── absolute-for-the-wrong-OS ─────────────────────────────────────────────
616
+ // Skipped after a `~` expansion: that result is native by construction.
617
+ if (!expandedHome) {
618
+ const home = homeDir(options);
619
+ const hint = home ? ` This machine's home directory is ${home}.` : '';
620
+ if (win32) {
621
+ // A leading slash on Windows is drive-relative, not absolute. Accepted
622
+ // only as a UNC share, which genuinely is absolute.
623
+ if ((work.startsWith('/') || work.startsWith('\\')) && !WIN_UNC_RE.test(work)) {
624
+ return refuse(
625
+ `${JSON.stringify(raw)} is not an absolute path on this machine, which runs ${platform}. ` +
626
+ `A leading slash here means "the root of the current drive", so writing to it would ` +
627
+ `silently land somewhere other than where you meant. Use a drive-qualified path ` +
628
+ `(C:\\Users\\you\\Desktop\\file.txt) or "~/…".${hint}`,
629
+ );
630
+ }
631
+ } else if (WIN_DRIVE_RE.test(work) || WIN_BARE_DRIVE_RE.test(work) || WIN_UNC_RE.test(work)) {
632
+ return refuse(
633
+ `${JSON.stringify(raw)} is a Windows path, but this machine runs ${platform}. ` +
634
+ `Use a posix path (/home/you/file.txt) or "~/…".${hint}`,
635
+ );
636
+ }
637
+ }
638
+
639
+ // ── relative → the working directory ──────────────────────────────────────
640
+ const cwd = typeof o.cwd === 'string' && o.cwd.trim() ? o.cwd.trim() : process.cwd();
641
+ const resolved = P.isAbsolute(work) ? P.resolve(work) : P.resolve(cwd, work);
642
+
643
+ // `P.resolve` falls back to the HOST's cwd when nothing it was given is
644
+ // absolute, which on a foreign-platform query yields a path of the wrong
645
+ // shape. Say so rather than hand back something unusable.
646
+ if (!P.isAbsolute(resolved)) {
647
+ return refuse(
648
+ `${JSON.stringify(raw)} is relative and the working directory (${JSON.stringify(cwd)}) is ` +
649
+ 'not absolute, so it cannot be resolved. Pass an absolute path.',
650
+ );
651
+ }
652
+
653
+ return { ok: true, path: resolved, error: null, expandedHome };
654
+ }
655
+
401
656
  const api = {
402
657
  IS_WIN32,
403
658
  resolveExecutable,
@@ -405,6 +660,10 @@ const api = {
405
660
  killTree,
406
661
  isAlive,
407
662
  hasDisplay,
663
+ homeDir,
664
+ desktopDir,
665
+ resolvePath,
666
+ hasPosixModes,
408
667
  };
409
668
 
410
669
  module.exports = api;