@1agh/maude 0.35.0 → 0.36.1

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.
@@ -32,6 +32,56 @@ import git from 'isomorphic-git';
32
32
  import http from 'isomorphic-git/http/node';
33
33
 
34
34
  const USE_SYSTEM_GIT = /^(1|true|on|yes)$/i.test(process.env.MAUDE_USE_SYSTEM_GIT ?? '');
35
+ // DDR-133 (DDR-107 end-state): auto-prefer a detected system `git` for the NETWORK
36
+ // paths (gitFetchRemote / remoteAheadBehind — native fetch is instant + uses the
37
+ // user's own credential helper / SSH agent) AND the READ paths (status / list-
38
+ // branches / log / diff / show / unpushed / current-branch). The pure-JS iso engine
39
+ // is genuinely slow on a real-world repo — and worse, `git.statusMatrix` /
40
+ // `git.listBranches` can throw on some trees ("No obj for …") and wedge the 10 s
41
+ // Bun.serve idle window, which is exactly what made the switcher's branch list
42
+ // vanish + the dropdown hang. System git's `status --porcelain` / `for-each-ref`
43
+ // are instant and don't have that failure mode. iso remains the fallback when no
44
+ // `git` is on PATH (the zero-setup promise) and still backs the WRITE paths
45
+ // (commit / checkout / branch / fold / push / pull) unless forced. MAUDE_USE_SYSTEM_GIT=1
46
+ // forces system git everywhere; MAUDE_NO_SYSTEM_GIT=1 pins everything to iso
47
+ // (escape hatch / deterministic test engine).
48
+ // Read live (not a module-load const) so a test can scope MAUDE_NO_SYSTEM_GIT around
49
+ // a single assertion to deterministically exercise the iso engine without leaking.
50
+ const noSystemGit = (): boolean => /^(1|true|on|yes)$/i.test(process.env.MAUDE_NO_SYSTEM_GIT ?? '');
51
+ let systemGitProbe: Promise<boolean> | undefined;
52
+ /** True when a usable `git` binary is on PATH. Memoized per process — the sidecar
53
+ * respawns per repo (DDR-132), so one `git --version` probe per process is correct
54
+ * and cheap. The probe NEVER relaxes the DDR-131 transport gate: callers classify
55
+ * the remote URL first; this only picks which engine runs an already-vetted op. */
56
+ function systemGitAvailable(): Promise<boolean> {
57
+ if (USE_SYSTEM_GIT) return Promise.resolve(true);
58
+ if (noSystemGit()) return Promise.resolve(false);
59
+ if (!systemGitProbe) {
60
+ systemGitProbe = runGit(process.cwd(), ['--version'], undefined, 4000)
61
+ .then((r) => r.code === 0 && /git version/i.test(r.stdout))
62
+ .catch(() => false);
63
+ }
64
+ return systemGitProbe;
65
+ }
66
+
67
+ const TIMED_OUT = Symbol('maude-git-timeout');
68
+ /** Race `p` against a timeout. Returns `TIMED_OUT` if the deadline wins; a late
69
+ * rejection from the losing promise is swallowed so it never surfaces as an
70
+ * unhandledRejection. */
71
+ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
72
+ p.catch(() => {});
73
+ let timer: ReturnType<typeof setTimeout>;
74
+ const t = new Promise<typeof TIMED_OUT>((res) => {
75
+ timer = setTimeout(() => res(TIMED_OUT), ms);
76
+ });
77
+ return Promise.race([p, t]).finally(() => clearTimeout(timer));
78
+ }
79
+
80
+ /** Bounds for the two unbounded network paths (DDR-133). A stalled remote must
81
+ * surface a fast, friendly result instead of hanging the popup / the unattended
82
+ * status poll. */
83
+ const FETCH_TIMEOUT_MS = 12_000;
84
+ const PROBE_TIMEOUT_MS = 8_000;
35
85
 
36
86
  /** One changed file as the Changes panel renders it. `status` maps to the M/A/D/U
37
87
  * badge (DDR-075 status hues): modified→M, added→A, deleted→D, untracked→U. */
@@ -214,13 +264,21 @@ interface RunResult {
214
264
  code: number;
215
265
  stdout: string;
216
266
  stderr: string;
267
+ /** True when `timeoutMs` elapsed and the child was killed (DDR-133). */
268
+ timedOut?: boolean;
217
269
  }
218
270
 
219
271
  /** Run `git <args>` in `dir`. `tokenRemote` (when given) replaces the `origin`
220
272
  * URL's userinfo with the token for this one invocation via `-c` config so the
221
273
  * PAT never lands in the on-disk remote URL or the process title's argv beyond
222
- * the ephemeral child. */
223
- function runGit(dir: string, args: string[], env?: Record<string, string>): Promise<RunResult> {
274
+ * the ephemeral child. `timeoutMs` (DDR-133) hard-kills a stalled child so the
275
+ * network paths can never hang the UI / the unattended poll. */
276
+ function runGit(
277
+ dir: string,
278
+ args: string[],
279
+ env?: Record<string, string>,
280
+ timeoutMs?: number
281
+ ): Promise<RunResult> {
224
282
  return new Promise((resolveRun) => {
225
283
  const child = spawn('git', args, {
226
284
  cwd: dir,
@@ -229,14 +287,28 @@ function runGit(dir: string, args: string[], env?: Record<string, string>): Prom
229
287
  });
230
288
  let stdout = '';
231
289
  let stderr = '';
290
+ let timedOut = false;
291
+ let timer: ReturnType<typeof setTimeout> | undefined;
292
+ if (timeoutMs && timeoutMs > 0) {
293
+ timer = setTimeout(() => {
294
+ timedOut = true;
295
+ child.kill('SIGKILL');
296
+ }, timeoutMs);
297
+ }
232
298
  child.stdout.on('data', (d) => {
233
299
  stdout += d.toString();
234
300
  });
235
301
  child.stderr.on('data', (d) => {
236
302
  stderr += d.toString();
237
303
  });
238
- child.on('error', (e) => resolveRun({ code: 127, stdout, stderr: String(e) }));
239
- child.on('close', (code) => resolveRun({ code: code ?? 1, stdout, stderr }));
304
+ child.on('error', (e) => {
305
+ if (timer) clearTimeout(timer);
306
+ resolveRun({ code: 127, stdout, stderr: String(e), timedOut });
307
+ });
308
+ child.on('close', (code) => {
309
+ if (timer) clearTimeout(timer);
310
+ resolveRun({ code: timedOut ? 124 : (code ?? 1), stdout, stderr, timedOut });
311
+ });
240
312
  });
241
313
  }
242
314
 
@@ -247,7 +319,9 @@ export async function gitStatus(dir: string, opts: GitStatusOpts = {}): Promise<
247
319
  return { repo: false, branch: null, files: [], clean: true, unpushed: 0 };
248
320
  }
249
321
  const prefix = normPrefix(opts.designPrefix);
250
- const result = USE_SYSTEM_GIT ? await statusSystem(dir, prefix) : await statusIso(dir, prefix);
322
+ const result = (await systemGitAvailable())
323
+ ? await statusSystem(dir, prefix)
324
+ : await statusIso(dir, prefix);
251
325
 
252
326
  // Local "saved but not published" count — no network (uses the cached
253
327
  // remote-tracking ref). Lets the panel offer Publish even on a clean tree.
@@ -434,28 +508,171 @@ export async function gitDiscard(
434
508
  // `.git/HEAD` watcher already turns into a Yjs flush + reload prompt (DDR-051) —
435
509
  // this module does NOT duplicate that.
436
510
 
511
+ /** The default remote a managed Maude project tracks (DDR-111 clone). */
512
+ const DEFAULT_REMOTE = 'origin';
513
+
514
+ /** How we'll transport-fetch a remote, derived from its URL:
515
+ * - `http` → isomorphic-git (HTTP-only) OR system git with a token header.
516
+ * - `ssh` → system git only (user's own ssh-agent creds), NEVER a token.
517
+ * Covers `ssh://` / `git://` / the scp-like `git@github.com:org/repo` form.
518
+ * - `none` → no remote configured; nothing to fetch (benign).
519
+ * - `unsafe` → REFUSE — never hand to the git binary. The `ext::` / `fd::` /
520
+ * `transport::` helpers make `git fetch` run an ARBITRARY SHELL COMMAND from the
521
+ * config URL. A poisoned `.git/config` (which rides a folder/clone and no
522
+ * file-review sees) would otherwise be RCE the moment the unattended status
523
+ * poll fires. Also refuses `file://` / local-path (local-read vector) and any
524
+ * unknown scheme. See DDR-131 hardening + the adversarial review of 75a2f0d. */
525
+ type RemoteTransport = 'http' | 'ssh' | 'none' | 'unsafe';
526
+
527
+ function classifyRemoteUrl(url: string): RemoteTransport {
528
+ const u = (url || '').trim();
529
+ if (!u) return 'none';
530
+ // Transport helpers embed `::` (ext::, fd::, transport::) → arbitrary command. Reject first.
531
+ if (u.includes('::')) return 'unsafe';
532
+ if (/^https?:\/\//i.test(u)) return 'http';
533
+ if (/^(?:ssh|git):\/\//i.test(u)) return 'ssh';
534
+ // scp-like `user@host:path` (no scheme) — the common `git@github.com:org/repo` form.
535
+ if (/^[\w.+-]+@[\w.-]+:[^/]/.test(u)) return 'ssh';
536
+ // file://, bare local paths, and anything else: REFUSE. A managed project tracks
537
+ // a github.com http/ssh remote; a local/file transport is both unusual and a
538
+ // local-read vector, so we don't hand it to the git binary at all.
539
+ return 'unsafe';
540
+ }
541
+
542
+ /** Read a remote's configured URL (empty string when missing). */
543
+ async function readRemoteUrl(dir: string, remote: string): Promise<string> {
544
+ return (await git.getConfig({ fs, dir, path: `remote.${remote}.url` }).catch(() => null)) || '';
545
+ }
546
+
547
+ /** The GitHub PAT (keychain) may ONLY be attached to a request bound for GitHub —
548
+ * never to an arbitrary HTTPS host an attacker put in `remote.origin.url` (PAT
549
+ * exfil / SSRF). HTTP(S) urls only; ssh carries no token regardless. */
550
+ function isTrustedTokenHost(url: string): boolean {
551
+ try {
552
+ const h = new URL(url).hostname.toLowerCase();
553
+ return h === 'github.com' || h.endsWith('.github.com');
554
+ } catch {
555
+ return false;
556
+ }
557
+ }
558
+
559
+ /** Defense-in-depth for any `runGit` that resolves a config remote URL: disable the
560
+ * command-EXECUTING transports at the git layer too (`classifyRemoteUrl` already
561
+ * refuses them before we spawn — this is the backstop). Deliberately does NOT
562
+ * block `file`/local object transfer (legitimate local-repo fetch), only the
563
+ * shell-spawning helpers. */
564
+ const HARDENED_REMOTE_FLAGS = ['-c', 'protocol.ext.allow=never', '-c', 'protocol.fd.allow=never'];
565
+
566
+ /** Non-empty, trimmed lines of a git stdout block. */
567
+ function splitLines(stdout: string): string[] {
568
+ return stdout
569
+ .split('\n')
570
+ .map((s) => s.trim())
571
+ .filter(Boolean);
572
+ }
573
+
574
+ /** Fold a remote-tracking ref into the merged draft map: a name already seen
575
+ * locally becomes `both` (recents = the newer of the two commit times); a name
576
+ * seen only on the remote becomes a `remote`-only draft. */
577
+ function mergeRemote(merged: Map<string, GitBranch>, name: string, updatedAt: number): void {
578
+ const existing = merged.get(name);
579
+ if (existing) {
580
+ merged.set(name, {
581
+ ...existing,
582
+ where: 'both',
583
+ updatedAt: Math.max(existing.updatedAt, updatedAt),
584
+ });
585
+ } else {
586
+ merged.set(name, { name, current: false, updatedAt, where: 'remote' });
587
+ }
588
+ }
589
+
437
590
  export interface GitBranch {
438
591
  name: string;
439
592
  current: boolean;
593
+ /** Last-commit time on this branch, unix seconds — drives the "recents" sort in
594
+ * the switcher. 0 when unknown (resolution failed / empty branch). */
595
+ updatedAt: number;
596
+ /** Where this draft lives. `local` = only here, `remote` = only on the team's
597
+ * remote (not downloaded yet — switching creates a tracking branch), `both` =
598
+ * present in both. The UI labels `remote` drafts "from your team". */
599
+ where: 'local' | 'remote' | 'both';
440
600
  }
441
601
 
442
- /** List local branches (drafts). Returns [] when `dir` isn't a repo. */
602
+ /** List drafts (branches) LOCAL plus the remote-tracking refs already on disk
603
+ * (populated by the original clone / a prior fetch; a fresh teammate draft only
604
+ * appears after `gitFetchRemote`). Each carries its last-commit time so the UI
605
+ * can sort by recents, and a `where` tag so it can mark remote-only drafts.
606
+ * Returns [] when `dir` isn't a repo. */
443
607
  export async function gitListBranches(dir: string): Promise<GitBranch[]> {
444
608
  if (!isRepo(dir)) return [];
445
609
  try {
446
- if (USE_SYSTEM_GIT) {
610
+ const merged = new Map<string, GitBranch>();
611
+ if (await systemGitAvailable()) {
447
612
  const cur = (await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
448
- const r = await runGit(dir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
449
- if (r.code !== 0) return [];
450
- return r.stdout
451
- .split('\n')
452
- .map((s) => s.trim())
453
- .filter(Boolean)
454
- .map((name) => ({ name, current: name === cur }));
613
+ // Tab-separated so a branch name (no tabs/newlines, charset-guarded) can't
614
+ // collide with the delimiter; committerdate:unix is the recents key.
615
+ const fmt = '--format=%(refname:short)%09%(committerdate:unix)';
616
+ const local = await runGit(dir, ['for-each-ref', fmt, 'refs/heads']);
617
+ if (local.code !== 0) return [];
618
+ for (const line of splitLines(local.stdout)) {
619
+ const [name, ts] = line.split('\t');
620
+ merged.set(name, {
621
+ name,
622
+ current: name === cur,
623
+ updatedAt: Number(ts) || 0,
624
+ where: 'local',
625
+ });
626
+ }
627
+ // Remote refs come back as "origin/<name>" — strip the prefix, skip origin/HEAD.
628
+ // NB: `%(refname:short)` collapses the symbolic ref refs/remotes/origin/HEAD to
629
+ // the bare remote name ("origin"), so skip that too or it shows as a phantom branch.
630
+ const remote = await runGit(dir, ['for-each-ref', fmt, `refs/remotes/${DEFAULT_REMOTE}`]);
631
+ if (remote.code === 0) {
632
+ for (const line of splitLines(remote.stdout)) {
633
+ const [full, ts] = line.split('\t');
634
+ if (!full || full === DEFAULT_REMOTE) continue; // origin/HEAD short form
635
+ const name = full.startsWith(`${DEFAULT_REMOTE}/`)
636
+ ? full.slice(DEFAULT_REMOTE.length + 1)
637
+ : full;
638
+ if (!name || name === 'HEAD') continue;
639
+ mergeRemote(merged, name, Number(ts) || 0);
640
+ }
641
+ }
642
+ return [...merged.values()];
455
643
  }
644
+ // iso engine (default): merge local heads with refs/remotes/<remote>/*.
456
645
  const cur = (await git.currentBranch({ fs, dir, fullname: false })) ?? null;
457
- const names = await git.listBranches({ fs, dir });
458
- return names.map((name) => ({ name, current: name === cur }));
646
+ const at = async (ref: string): Promise<number> => {
647
+ try {
648
+ const oid = await git.resolveRef({ fs, dir, ref });
649
+ const { commit } = await git.readCommit({ fs, dir, oid });
650
+ return commit.committer?.timestamp || 0;
651
+ } catch {
652
+ return 0; // empty / unresolvable ref — sorts last
653
+ }
654
+ };
655
+ const localNames = await git.listBranches({ fs, dir });
656
+ await Promise.all(
657
+ localNames.map(async (name) => {
658
+ merged.set(name, {
659
+ name,
660
+ current: name === cur,
661
+ updatedAt: await at(name),
662
+ where: 'local',
663
+ });
664
+ })
665
+ );
666
+ // Remote enumeration is best-effort: a repo with no remote yields [].
667
+ const remoteNames = (
668
+ await git.listBranches({ fs, dir, remote: DEFAULT_REMOTE }).catch(() => [])
669
+ ).filter((n) => n && n !== 'HEAD');
670
+ await Promise.all(
671
+ remoteNames.map(async (name) => {
672
+ mergeRemote(merged, name, await at(`refs/remotes/${DEFAULT_REMOTE}/${name}`));
673
+ })
674
+ );
675
+ return [...merged.values()];
459
676
  } catch {
460
677
  return [];
461
678
  }
@@ -498,21 +715,43 @@ export async function gitCheckout(dir: string, name: string): Promise<GitBranchR
498
715
  if (!isSafeGitPositional(name)) return { ok: false, error: 'Invalid draft name.' };
499
716
  try {
500
717
  if (USE_SYSTEM_GIT) {
718
+ // System git DWIMs `git checkout <name>` into a tracking branch when <name>
719
+ // exists on exactly one remote, so the local + remote-only cases share a path.
501
720
  const r = await runGit(dir, ['checkout', name]);
502
721
  if (r.code !== 0) {
503
722
  const blob = `${r.stderr} ${r.stdout}`.toLowerCase();
504
723
  if (blob.includes('would be overwritten') || blob.includes('local changes'))
505
724
  return { ok: false, error: 'Save your changes before switching drafts.' };
725
+ if (
726
+ blob.includes('did not match') ||
727
+ blob.includes('pathspec') ||
728
+ blob.includes('invalid reference')
729
+ )
730
+ return { ok: false, error: "Couldn't find that draft — try Refresh." };
506
731
  return { ok: false, error: r.stderr.trim() || 'Could not switch drafts.' };
507
732
  }
508
733
  } else {
509
- await git.checkout({ fs, dir, ref: name });
734
+ // iso-git does NOT DWIM: a local ref checks out directly, but a remote-only
735
+ // draft must be created as a tracking branch from refs/remotes/<remote>/<name>.
736
+ const localNames = await git.listBranches({ fs, dir });
737
+ if (localNames.includes(name)) {
738
+ await git.checkout({ fs, dir, ref: name });
739
+ } else {
740
+ const remoteNames = await git
741
+ .listBranches({ fs, dir, remote: DEFAULT_REMOTE })
742
+ .catch(() => []);
743
+ if (!remoteNames.includes(name))
744
+ return { ok: false, error: "Couldn't find that draft — try Refresh." };
745
+ await git.checkout({ fs, dir, ref: name, remote: DEFAULT_REMOTE, track: true });
746
+ }
510
747
  }
511
748
  return { ok: true, branch: name };
512
749
  } catch (e) {
513
750
  const msg = errMsg(e);
514
751
  if (/overwrit|local change|conflict/i.test(msg))
515
752
  return { ok: false, error: 'Save your changes before switching drafts.' };
753
+ if (/not ?found|did not match|resolve/i.test(msg))
754
+ return { ok: false, error: "Couldn't find that draft — try Refresh." };
516
755
  return { ok: false, error: msg };
517
756
  }
518
757
  }
@@ -543,6 +782,7 @@ export async function gitFoldDraft(
543
782
  ): Promise<GitFoldResult> {
544
783
  if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
545
784
  if (!isSafeGitPositional(draftName)) return { ok: false, error: 'Invalid draft name.' };
785
+ invalidateRemoteProbe(dir); // a fold changes ahead/behind — re-probe next status
546
786
  const remote = opts.remote || 'origin';
547
787
  const branches = await gitListBranches(dir);
548
788
  const shared = branches.find((b) => SHARED_BRANCHES.has(b.name))?.name;
@@ -635,6 +875,7 @@ export async function gitPush(
635
875
  opts: { remote?: string; ref?: string } = {}
636
876
  ): Promise<GitPushResult> {
637
877
  if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
878
+ invalidateRemoteProbe(dir); // a publish changes ahead/behind — re-probe next status
638
879
  const remote = opts.remote || 'origin';
639
880
  return USE_SYSTEM_GIT
640
881
  ? pushSystem(dir, token, remote, opts.ref)
@@ -747,6 +988,7 @@ export async function gitPull(
747
988
  opts: { remote?: string; ref?: string } = {}
748
989
  ): Promise<GitPullResult> {
749
990
  if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
991
+ invalidateRemoteProbe(dir); // a pull changes ahead/behind — re-probe next status
750
992
  return USE_SYSTEM_GIT
751
993
  ? pullSystem(dir, token, opts.remote || 'origin', opts.ref)
752
994
  : pullIso(dir, token, opts.remote || 'origin', opts.ref);
@@ -805,6 +1047,118 @@ async function pullSystem(
805
1047
  return { ok: false, error: r.stderr.trim() || 'Get latest failed.' };
806
1048
  }
807
1049
 
1050
+ // ── fetch (Refresh drafts) ──────────────────────────────────────────────────
1051
+
1052
+ export interface GitFetchResult {
1053
+ ok: boolean;
1054
+ /** Unix seconds the refresh completed — the UI shows it as "as of <time>". */
1055
+ fetchedAt?: number;
1056
+ /** See GitPushResult.authRequired — a tokenless refresh on the iso engine. */
1057
+ authRequired?: boolean;
1058
+ /** The fetch exceeded FETCH_TIMEOUT_MS and was bounded (DDR-133). */
1059
+ timedOut?: boolean;
1060
+ error?: string;
1061
+ }
1062
+
1063
+ /** Refresh the team's drafts: fetch ALL remote heads (no singleBranch) so brand-new
1064
+ * teammate drafts surface in `gitListBranches`. Prunes deleted remotes. Same
1065
+ * optional-token model as gitPull. Explicit user gesture only — never auto-run. */
1066
+ export async function gitFetchRemote(
1067
+ dir: string,
1068
+ token: string | undefined,
1069
+ opts: { remote?: string } = {}
1070
+ ): Promise<GitFetchResult> {
1071
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
1072
+ const remote = opts.remote || DEFAULT_REMOTE;
1073
+ if (!isSafeGitPositional(remote)) return { ok: false, error: 'Invalid remote name.' };
1074
+ // SECURITY (DDR-131 hardening): classify the configured remote URL BEFORE spawning
1075
+ // git. iso-git speaks HTTP(S) only, so an ssh/git remote must go to the system
1076
+ // binary — but a command-executing (`ext::`) / local (`file://`) URL must be
1077
+ // REFUSED, never handed to `git fetch` (it would run as the user). And the GitHub
1078
+ // token may only ride a github.com HTTPS request, never an attacker-chosen host.
1079
+ const url = await readRemoteUrl(dir, remote);
1080
+ const transport = classifyRemoteUrl(url);
1081
+ if (transport === 'none') return { ok: false, error: 'This project has no remote to refresh.' };
1082
+ if (transport === 'unsafe')
1083
+ return { ok: false, error: 'Maude can only refresh github.com (HTTPS or SSH) projects.' };
1084
+ const trustedHttp = transport === 'http' && isTrustedTokenHost(url);
1085
+ // Token/host POLICY is engine-independent (DDR-133): an HTTPS remote must be
1086
+ // github.com (token-host = fetch-host — never lend the PAT to another host) and
1087
+ // requires sign-in; an ssh remote uses the user's own key against whatever host
1088
+ // they configured. The engine choice below only decides HOW a vetted fetch runs.
1089
+ if (transport === 'http' && !trustedHttp)
1090
+ return { ok: false, error: 'Maude can only refresh github.com projects.' };
1091
+ // Tokenless github HTTPS: the iso engine can't authenticate (→ sign in), but system
1092
+ // git uses the developer's own credential helper, so only block when there's no
1093
+ // system git to fall back on (DDR-133). With system git, a tokenless fetch Just Works.
1094
+ if (transport === 'http' && !token && !(await systemGitAvailable()))
1095
+ return { ok: false, authRequired: true, error: 'Sign in with GitHub to refresh.' };
1096
+ try {
1097
+ if ((await systemGitAvailable()) || transport === 'ssh') {
1098
+ // System git (auto-preferred when present — DDR-133): attach the token header
1099
+ // ONLY for a trusted-host HTTPS remote (else fall back to the user's own
1100
+ // credential helper); an ssh remote authenticates with the user's own key.
1101
+ const args = trustedHttp ? tokenHeaderArgs(token) : [];
1102
+ args.push(...HARDENED_REMOTE_FLAGS, 'fetch', '--prune', remote);
1103
+ const r = await runGit(dir, args, undefined, FETCH_TIMEOUT_MS);
1104
+ if (r.timedOut)
1105
+ return {
1106
+ ok: false,
1107
+ timedOut: true,
1108
+ error: 'Refresh timed out — check your connection and try again.',
1109
+ };
1110
+ if (r.code === 127)
1111
+ return {
1112
+ ok: false,
1113
+ error: 'Refresh needs the git command-line tool for this project’s connection.',
1114
+ };
1115
+ if (r.code !== 0) {
1116
+ const blob = `${r.stderr}\n${r.stdout}`.toLowerCase();
1117
+ if (transport === 'http' && /auth|denied|credential|terminal prompts disabled/.test(blob))
1118
+ return { ok: false, authRequired: true, error: 'Sign in with GitHub to refresh.' };
1119
+ if (
1120
+ /permission denied|publickey|host key|authenticity|could not read from remote/.test(blob)
1121
+ )
1122
+ return {
1123
+ ok: false,
1124
+ error: 'Couldn’t reach the remote — check your connection or SSH key.',
1125
+ };
1126
+ return { ok: false, error: r.stderr.trim() || 'Could not refresh drafts.' };
1127
+ }
1128
+ } else {
1129
+ // iso engine: github HTTPS with a token (guaranteed by the policy guards above).
1130
+ const fetched = await withTimeout(
1131
+ git.fetch({
1132
+ fs,
1133
+ http,
1134
+ dir,
1135
+ remote,
1136
+ prune: true,
1137
+ onAuth: () => ({ username: token ?? '', password: '' }),
1138
+ }),
1139
+ FETCH_TIMEOUT_MS
1140
+ );
1141
+ if (fetched === TIMED_OUT)
1142
+ return {
1143
+ ok: false,
1144
+ timedOut: true,
1145
+ error: 'Refresh timed out — check your connection and try again.',
1146
+ };
1147
+ }
1148
+ return { ok: true, fetchedAt: Math.floor(Date.now() / 1000) };
1149
+ } catch (e) {
1150
+ const msg = errMsg(e);
1151
+ if (/unrecognized transport|unsupported|protocol/i.test(msg))
1152
+ return {
1153
+ ok: false,
1154
+ error: 'Refresh needs the git command-line tool for this project’s connection.',
1155
+ };
1156
+ if (/auth|denied|credential|401|403/i.test(msg))
1157
+ return { ok: false, authRequired: true, error: 'Sign in with GitHub to refresh.' };
1158
+ return { ok: false, error: msg };
1159
+ }
1160
+ }
1161
+
808
1162
  // ── resolve (finish a Get-latest merge that hit a conflict) ─────────────────
809
1163
 
810
1164
  /** Finish the merge `gitPull` left unresolved, applying one CHOICE to every
@@ -1001,7 +1355,9 @@ function mineCopyPath(rel: string): string {
1001
1355
  * design tree). */
1002
1356
  export async function gitLog(dir: string, limit = 30, filepath?: string): Promise<GitLogEntry[]> {
1003
1357
  if (!isRepo(dir)) return [];
1004
- return USE_SYSTEM_GIT ? logSystem(dir, limit, filepath) : logIso(dir, limit, filepath);
1358
+ return (await systemGitAvailable())
1359
+ ? logSystem(dir, limit, filepath)
1360
+ : logIso(dir, limit, filepath);
1005
1361
  }
1006
1362
 
1007
1363
  async function logIso(dir: string, limit: number, filepath?: string): Promise<GitLogEntry[]> {
@@ -1061,7 +1417,7 @@ export async function gitDiff(
1061
1417
  ): Promise<GitDiffEntry[]> {
1062
1418
  if (!isRepo(dir)) return [];
1063
1419
  const prefix = normPrefix(opts.designPrefix);
1064
- return USE_SYSTEM_GIT ? diffSystem(dir, sha, prefix) : diffIso(dir, sha, prefix);
1420
+ return (await systemGitAvailable()) ? diffSystem(dir, sha, prefix) : diffIso(dir, sha, prefix);
1065
1421
  }
1066
1422
 
1067
1423
  async function diffIso(dir: string, sha: string, prefix: string): Promise<GitDiffEntry[]> {
@@ -1128,7 +1484,7 @@ export async function gitShowFile(
1128
1484
  const rel = repoRelPath.replace(/\\/g, '/');
1129
1485
  if (!isContainedRepoPath(dir, rel)) return null;
1130
1486
  try {
1131
- if (USE_SYSTEM_GIT) {
1487
+ if (await systemGitAvailable()) {
1132
1488
  const r = await runGit(dir, ['show', `${sha}:${rel}`]);
1133
1489
  return r.code === 0 ? r.stdout : null;
1134
1490
  }
@@ -1152,7 +1508,7 @@ async function localUnpushed(dir: string, branch: string | null, remote: string)
1152
1508
  // server-derived today, but never interpolate an unguarded positional into git
1153
1509
  // argv (defense-in-depth so a future caller can't re-open the injection class).
1154
1510
  if (!isSafeGitPositional(branch) || !isSafeGitPositional(remote)) return 0;
1155
- if (USE_SYSTEM_GIT) {
1511
+ if (await systemGitAvailable()) {
1156
1512
  const r = await runGit(dir, ['rev-list', '--count', `${remote}/${branch}..HEAD`]);
1157
1513
  if (r.code === 0) return Number(r.stdout.trim()) || 0;
1158
1514
  // No tracking ref — count all commits if a remote exists, else 0.
@@ -1182,26 +1538,110 @@ async function localUnpushed(dir: string, branch: string | null, remote: string)
1182
1538
 
1183
1539
  // ── remote ahead/behind (Get latest nudge) ─────────────────────────────────
1184
1540
 
1541
+ interface AheadBehind {
1542
+ ahead: number;
1543
+ behind: number;
1544
+ }
1545
+
1546
+ // In-memory TTL cache + in-flight dedupe for the network probe (DDR-132). A
1547
+ // Changes-panel toggle, a 60 s tick, and effect re-runs all call status?remote=1;
1548
+ // without this they each fire a fresh `git fetch`. The cache is per FRESH process
1549
+ // — a repo switch respawns the sidecar (fresh process ⇒ one fresh probe), which is
1550
+ // exactly the desired semantics, so it deliberately never persists to disk.
1551
+ const REMOTE_PROBE_TTL_MS = 45_000;
1552
+ const probeCache = new Map<string, { at: number; val: AheadBehind }>();
1553
+ const probeInflight = new Map<string, Promise<AheadBehind>>();
1554
+
1555
+ /** Drop any cached/in-flight probe for `dir` so the next status re-fetches —
1556
+ * called after a push/pull/fold so the "Get latest" nudge updates immediately. */
1557
+ export function invalidateRemoteProbe(dir: string): void {
1558
+ const prefix = `${dir}\0`;
1559
+ for (const k of probeCache.keys()) if (k.startsWith(prefix)) probeCache.delete(k);
1560
+ for (const k of probeInflight.keys()) if (k.startsWith(prefix)) probeInflight.delete(k);
1561
+ }
1562
+
1563
+ /** Resolve the current branch with the active engine — local-only, no network. */
1564
+ async function currentBranchOf(dir: string): Promise<string> {
1565
+ if (await systemGitAvailable()) {
1566
+ const r = await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
1567
+ return r.stdout.trim() || 'main';
1568
+ }
1569
+ return (await git.currentBranch({ fs, dir, fullname: false })) || 'main';
1570
+ }
1571
+
1185
1572
  /** Fetch the tracking remote and count commits ahead (local-only) / behind
1186
1573
  * (remote-only). `behind > 0` is what surfaces the "Get latest" banner. Network;
1187
- * callers guard with try/catch so an offline poll never breaks local status. */
1574
+ * callers guard with try/catch so an offline poll never breaks local status.
1575
+ *
1576
+ * TTL-cached + in-flight-deduped per `dir\0remote\0branch` (DDR-132): a value
1577
+ * younger than the TTL is served without a fetch, and concurrent callers share
1578
+ * one in-flight promise. Only the RESOLVED value is cached — a throw clears the
1579
+ * in-flight slot so a later call retries (failures are never cached). */
1188
1580
  export async function remoteAheadBehind(
1189
1581
  dir: string,
1190
1582
  token: string | undefined,
1191
1583
  remote = 'origin'
1192
- ): Promise<{ ahead: number; behind: number }> {
1193
- if (USE_SYSTEM_GIT) return remoteAheadBehindSystem(dir, token, remote);
1194
- const branch = (await git.currentBranch({ fs, dir, fullname: false })) || 'main';
1195
- await git.fetch({
1196
- fs,
1197
- http,
1198
- dir,
1199
- remote,
1200
- ref: branch,
1201
- singleBranch: true,
1202
- tags: false,
1203
- onAuth: () => ({ username: token ?? '', password: '' }),
1204
- });
1584
+ ): Promise<AheadBehind> {
1585
+ const branch = await currentBranchOf(dir);
1586
+ const key = `${dir}\0${remote}\0${branch}`;
1587
+ const hit = probeCache.get(key);
1588
+ if (hit && Date.now() - hit.at < REMOTE_PROBE_TTL_MS) return hit.val;
1589
+ const existing = probeInflight.get(key);
1590
+ if (existing) return existing;
1591
+ const p = remoteAheadBehindUncached(dir, token, remote, branch)
1592
+ .then((val) => {
1593
+ probeCache.set(key, { at: Date.now(), val });
1594
+ return val;
1595
+ })
1596
+ .finally(() => {
1597
+ probeInflight.delete(key);
1598
+ });
1599
+ probeInflight.set(key, p);
1600
+ return p;
1601
+ }
1602
+
1603
+ async function remoteAheadBehindUncached(
1604
+ dir: string,
1605
+ token: string | undefined,
1606
+ remote: string,
1607
+ branch: string
1608
+ ): Promise<AheadBehind> {
1609
+ // SECURITY (DDR-131 hardening): this probe runs UNATTENDED (status?remote=1 on a
1610
+ // 60s tick / post-action), so it's the highest-value RCE sink — classify the
1611
+ // remote URL before touching the git binary. A command-executing (`ext::`) or
1612
+ // local (`file://`) URL is refused (return 0/0, no spawn); the token rides only a
1613
+ // github.com HTTPS request, never an attacker-chosen host.
1614
+ const url = await readRemoteUrl(dir, remote);
1615
+ const transport = classifyRemoteUrl(url);
1616
+ if (transport === 'none' || transport === 'unsafe') return { ahead: 0, behind: 0 };
1617
+ const trustedHttp = transport === 'http' && isTrustedTokenHost(url);
1618
+ // SECURITY (DDR-133 fix — adversarial review F1): the host-allowlist MUST gate this
1619
+ // UNATTENDED probe BEFORE the engine branch, mirroring gitFetchRemote (`:1089`). With
1620
+ // system git now auto-preferred, an http transport otherwise reached the system path
1621
+ // for ANY host — turning a poisoned non-github `remote.origin.url` into an unattended
1622
+ // `git fetch` SSRF/beacon (the old `!trustedHttp` guard sat AFTER the engine branch,
1623
+ // so it only ever protected the now-dead iso path). github-only for http; ssh uses the
1624
+ // user's own key (DDR-131-accepted, same as the explicit Refresh).
1625
+ if (transport === 'http' && !trustedHttp) return { ahead: 0, behind: 0 };
1626
+ if ((await systemGitAvailable()) || transport === 'ssh')
1627
+ return remoteAheadBehindSystem(dir, trustedHttp ? token : undefined, remote, branch);
1628
+ // iso engine, HTTP(S) to github (trustedHttp guaranteed by the guard above).
1629
+ // Bounded (DDR-133): a timeout THROWS so the wrapper never caches it — the next
1630
+ // poll retries, and local status is unaffected (caller try/catches).
1631
+ const fetched = await withTimeout(
1632
+ git.fetch({
1633
+ fs,
1634
+ http,
1635
+ dir,
1636
+ remote,
1637
+ ref: branch,
1638
+ singleBranch: true,
1639
+ tags: false,
1640
+ onAuth: () => ({ username: token ?? '', password: '' }),
1641
+ }),
1642
+ PROBE_TIMEOUT_MS
1643
+ );
1644
+ if (fetched === TIMED_OUT) throw new Error('remote probe timed out');
1205
1645
  const localOid = await git.resolveRef({ fs, dir, ref: branch });
1206
1646
  const remoteOid = await git
1207
1647
  .resolveRef({ fs, dir, ref: `refs/remotes/${remote}/${branch}` })
@@ -1224,15 +1664,18 @@ export async function remoteAheadBehind(
1224
1664
  async function remoteAheadBehindSystem(
1225
1665
  dir: string,
1226
1666
  token: string | undefined,
1227
- remote: string
1667
+ remote: string,
1668
+ branch: string
1228
1669
  ): Promise<{ ahead: number; behind: number }> {
1229
- const branch = (await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() || 'main';
1230
1670
  if (!isSafeGitPositional(remote) || !isSafeGitPositional(branch)) {
1231
1671
  throw new Error('invalid remote or branch');
1232
1672
  }
1233
- const args = tokenHeaderArgs(token);
1234
- args.push('fetch', remote, branch);
1235
- const f = await runGit(dir, args);
1673
+ // Token only when the caller vetted a trusted HTTPS host; harden the transport
1674
+ // allowlist at the git layer too (defense-in-depth behind classifyRemoteUrl).
1675
+ const args = token ? tokenHeaderArgs(token) : [];
1676
+ args.push(...HARDENED_REMOTE_FLAGS, 'fetch', remote, branch);
1677
+ const f = await runGit(dir, args, undefined, PROBE_TIMEOUT_MS);
1678
+ if (f.timedOut) throw new Error('remote probe timed out');
1236
1679
  if (f.code !== 0) throw new Error(f.stderr.trim() || 'fetch failed');
1237
1680
  const counts = await runGit(dir, [
1238
1681
  'rev-list',